573 lines
24 KiB
Go
573 lines
24 KiB
Go
package userbilling
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type redeemCodeSummary struct {
|
|
ID string `json:"id"`
|
|
CodePrefix string `json:"codePrefix"`
|
|
DisplayName string `json:"displayName"`
|
|
Status string `json:"status"`
|
|
CreditAmount int64 `json:"creditAmount"`
|
|
Reason string `json:"reason"`
|
|
MaxRedemptions int64 `json:"maxRedemptions"`
|
|
PerUserLimit int64 `json:"perUserLimit"`
|
|
RedeemedCount int64 `json:"redeemedCount"`
|
|
RemainingCount int64 `json:"remainingCount"`
|
|
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
|
|
CreatedByUserID string `json:"createdByUserId,omitempty"`
|
|
CreatedByUsername string `json:"createdByUsername,omitempty"`
|
|
Metadata map[string]any `json:"metadata,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
UpdatedAt time.Time `json:"updatedAt"`
|
|
DisabledAt *time.Time `json:"disabledAt,omitempty"`
|
|
}
|
|
|
|
type redeemRedemptionSummary struct {
|
|
ID string `json:"id"`
|
|
CodeID string `json:"codeId"`
|
|
CodePrefix string `json:"codePrefix"`
|
|
UserID string `json:"userId"`
|
|
LedgerID string `json:"ledgerId,omitempty"`
|
|
CreditAmount int64 `json:"creditAmount"`
|
|
Status string `json:"status"`
|
|
IdempotencyKey string `json:"idempotencyKey,omitempty"`
|
|
Metadata map[string]any `json:"metadata,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
}
|
|
|
|
type redeemApplyResult struct {
|
|
Redeemed bool `json:"redeemed"`
|
|
Idempotent bool `json:"idempotent"`
|
|
RedemptionID string `json:"redemptionId"`
|
|
CodeID string `json:"codeId"`
|
|
CodePrefix string `json:"codePrefix"`
|
|
UserID string `json:"userId"`
|
|
LedgerID string `json:"ledgerId"`
|
|
CreditAmount int64 `json:"creditAmount"`
|
|
BalanceBefore int64 `json:"balanceBefore"`
|
|
BalanceAfter int64 `json:"balanceAfter"`
|
|
Reason string `json:"reason"`
|
|
Source string `json:"source"`
|
|
Status string `json:"status"`
|
|
IdempotencyKey string `json:"idempotencyKey,omitempty"`
|
|
}
|
|
|
|
func (s *Server) handleRedeem(w http.ResponseWriter, r *http.Request) {
|
|
principal, ok := s.requirePrincipal(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req struct {
|
|
Code string `json:"code"`
|
|
IdempotencyKey string `json:"idempotencyKey"`
|
|
Metadata map[string]any `json:"metadata"`
|
|
}
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
result, err := s.applyRedeemCode(ctx, principal.UserID, strings.TrimSpace(req.Code), strings.TrimSpace(req.IdempotencyKey), req.Metadata)
|
|
if err != nil {
|
|
status, code, message := redeemError(err)
|
|
writeAPIError(w, status, code, message)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"contractVersion": "user-billing-redeem-v1", "result": result, "valuesRedacted": true})
|
|
}
|
|
|
|
func (s *Server) handleSubscriptionSummary(w http.ResponseWriter, r *http.Request) {
|
|
principal, ok := s.requirePrincipal(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
summary, err := s.subscriptionSummary(ctx, principal.UserID)
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "subscription_summary_failed", "could not load subscription summary")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, summary)
|
|
}
|
|
|
|
func (s *Server) handlePaymentSummary(w http.ResponseWriter, r *http.Request) {
|
|
principal, ok := s.requirePrincipal(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
summary, err := s.paymentSummary(ctx, principal.UserID)
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "payment_summary_failed", "could not load payment summary")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, summary)
|
|
}
|
|
|
|
func (s *Server) handleAdminRedeemCodes(w http.ResponseWriter, r *http.Request) {
|
|
if !s.internalAllowed(w, r) || !s.databaseAvailable(w) {
|
|
return
|
|
}
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
s.handleAdminRedeemCodeList(w, r)
|
|
case http.MethodPost:
|
|
s.handleAdminCreateRedeemCode(w, r)
|
|
default:
|
|
w.Header().Set("Allow", "GET, POST")
|
|
writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleAdminRedeemCodeByID(w http.ResponseWriter, r *http.Request) {
|
|
if !s.internalAllowed(w, r) || !s.databaseAvailable(w) {
|
|
return
|
|
}
|
|
codeID := strings.TrimSpace(strings.TrimPrefix(r.URL.Path, "/internal/admin/redeem-codes/"))
|
|
if codeID == "" || strings.Contains(codeID, "/") {
|
|
writeAPIError(w, http.StatusNotFound, "redeem_code_not_found", "redeem code not found")
|
|
return
|
|
}
|
|
if r.Method != http.MethodPatch {
|
|
w.Header().Set("Allow", "PATCH")
|
|
writeAPIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
|
return
|
|
}
|
|
var req struct {
|
|
Status string `json:"status"`
|
|
}
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
status := strings.TrimSpace(strings.ToLower(req.Status))
|
|
if status != "active" && status != "disabled" {
|
|
writeAPIError(w, http.StatusBadRequest, "invalid_status", "status must be active or disabled")
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
code, err := s.updateRedeemCodeStatus(ctx, codeID, status)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
writeAPIError(w, http.StatusNotFound, "redeem_code_not_found", "redeem code not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "redeem_code_update_failed", "could not update redeem code")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"contractVersion": "user-billing-admin-redeem-codes-v1", "code": code, "updated": true, "valuesRedacted": true})
|
|
}
|
|
|
|
func (s *Server) handleAdminRedeemRedemptions(w http.ResponseWriter, r *http.Request) {
|
|
if !s.internalAllowed(w, r) || !s.databaseAvailable(w) {
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
limit := redeemPositiveInt(r.URL.Query().Get("limit"), 50)
|
|
if limit > 200 {
|
|
limit = 200
|
|
}
|
|
rows, err := s.redeemRedemptions(ctx, limit, strings.TrimSpace(r.URL.Query().Get("userId")), strings.TrimSpace(r.URL.Query().Get("codeId")))
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "redeem_redemptions_failed", "could not load redeem redemptions")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"contractVersion": "user-billing-admin-redeem-redemptions-v1", "redemptions": rows, "count": len(rows), "valuesRedacted": true})
|
|
}
|
|
|
|
func (s *Server) handleAdminCreateRedeemCode(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
DisplayName string `json:"displayName"`
|
|
CreditAmount int64 `json:"creditAmount"`
|
|
Reason string `json:"reason"`
|
|
MaxRedemptions int64 `json:"maxRedemptions"`
|
|
PerUserLimit int64 `json:"perUserLimit"`
|
|
ExpiresAt string `json:"expiresAt"`
|
|
CreatedByUserID string `json:"createdByUserId"`
|
|
CreatedByUsername string `json:"createdByUsername"`
|
|
Metadata map[string]any `json:"metadata"`
|
|
}
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
if req.CreditAmount <= 0 {
|
|
writeAPIError(w, http.StatusBadRequest, "invalid_credit_amount", "creditAmount must be positive")
|
|
return
|
|
}
|
|
if req.MaxRedemptions <= 0 {
|
|
req.MaxRedemptions = 1
|
|
}
|
|
if req.PerUserLimit <= 0 {
|
|
req.PerUserLimit = 1
|
|
}
|
|
var expiresAt *time.Time
|
|
if text := strings.TrimSpace(req.ExpiresAt); text != "" {
|
|
parsed, err := time.Parse(time.RFC3339, text)
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusBadRequest, "invalid_expires_at", "expiresAt must be RFC3339")
|
|
return
|
|
}
|
|
expiresAt = &parsed
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
code, rawCode, err := s.createRedeemCode(ctx, req.DisplayName, req.CreditAmount, req.Reason, req.MaxRedemptions, req.PerUserLimit, expiresAt, req.CreatedByUserID, req.CreatedByUsername, req.Metadata)
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "redeem_code_create_failed", "could not create redeem code")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, map[string]any{"contractVersion": "user-billing-admin-redeem-codes-v1", "code": code, "redeemCode": rawCode, "created": true, "valuesRedacted": true})
|
|
}
|
|
|
|
func (s *Server) handleAdminRedeemCodeList(w http.ResponseWriter, r *http.Request) {
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
limit := redeemPositiveInt(r.URL.Query().Get("limit"), 50)
|
|
if limit > 200 {
|
|
limit = 200
|
|
}
|
|
codes, err := s.redeemCodes(ctx, limit, strings.TrimSpace(r.URL.Query().Get("status")))
|
|
if err != nil {
|
|
writeAPIError(w, http.StatusInternalServerError, "redeem_codes_failed", "could not load redeem codes")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"contractVersion": "user-billing-admin-redeem-codes-v1", "codes": codes, "count": len(codes), "valuesRedacted": true})
|
|
}
|
|
|
|
func (s *Server) createRedeemCode(ctx context.Context, displayName string, creditAmount int64, reason string, maxRedemptions int64, perUserLimit int64, expiresAt *time.Time, createdByUserID string, createdByUsername string, metadata map[string]any) (redeemCodeSummary, string, error) {
|
|
rawCode := "hwl_redeem_" + randomToken(18)
|
|
codeHash := redeemCodeHash(rawCode)
|
|
codePrefix := redeemCodePrefix(rawCode)
|
|
if strings.TrimSpace(reason) == "" {
|
|
reason = "redeem"
|
|
}
|
|
codeID := newID("rdc")
|
|
_, err := s.db.ExecContext(ctx, `INSERT INTO hwlab_redeem_codes (id, code_hash, code_prefix, display_name, credit_amount, reason, max_redemptions, per_user_limit, expires_at, created_by_user_id, created_by_username, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, nullif($10, ''), nullif($11, ''), $12::jsonb)`, codeID, codeHash, codePrefix, strings.TrimSpace(displayName), creditAmount, strings.TrimSpace(reason), maxRedemptions, perUserLimit, expiresAt, strings.TrimSpace(createdByUserID), strings.TrimSpace(createdByUsername), jsonObject(metadata))
|
|
if err != nil {
|
|
return redeemCodeSummary{}, "", err
|
|
}
|
|
code, err := s.redeemCodeByID(ctx, codeID)
|
|
return code, rawCode, err
|
|
}
|
|
|
|
func (s *Server) applyRedeemCode(ctx context.Context, userID, rawCode, idempotencyKey string, metadata map[string]any) (redeemApplyResult, error) {
|
|
if rawCode == "" {
|
|
return redeemApplyResult{}, errRedeemCodeRequired
|
|
}
|
|
if idempotencyKey != "" {
|
|
result, found, err := s.redeemResultByIdempotency(ctx, idempotencyKey)
|
|
if err != nil || found {
|
|
return result, err
|
|
}
|
|
}
|
|
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
|
|
if err != nil {
|
|
return redeemApplyResult{}, err
|
|
}
|
|
defer tx.Rollback()
|
|
var code redeemCodeSummary
|
|
var expiresAt sql.NullTime
|
|
var disabledAt sql.NullTime
|
|
var metadataRaw string
|
|
err = tx.QueryRowContext(ctx, `SELECT id, code_prefix, display_name, status, credit_amount, reason, max_redemptions, per_user_limit, redeemed_count, expires_at, COALESCE(created_by_user_id, ''), COALESCE(created_by_username, ''), metadata::text, created_at, updated_at, disabled_at FROM hwlab_redeem_codes WHERE code_hash = $1 FOR UPDATE`, redeemCodeHash(rawCode)).Scan(&code.ID, &code.CodePrefix, &code.DisplayName, &code.Status, &code.CreditAmount, &code.Reason, &code.MaxRedemptions, &code.PerUserLimit, &code.RedeemedCount, &expiresAt, &code.CreatedByUserID, &code.CreatedByUsername, &metadataRaw, &code.CreatedAt, &code.UpdatedAt, &disabledAt)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return redeemApplyResult{}, errRedeemCodeNotFound
|
|
}
|
|
if err != nil {
|
|
return redeemApplyResult{}, err
|
|
}
|
|
if expiresAt.Valid {
|
|
code.ExpiresAt = &expiresAt.Time
|
|
}
|
|
if disabledAt.Valid {
|
|
code.DisabledAt = &disabledAt.Time
|
|
}
|
|
code.Metadata = parseJSONMap(metadataRaw)
|
|
if code.Status != "active" {
|
|
return redeemApplyResult{}, errRedeemCodeDisabled
|
|
}
|
|
if code.ExpiresAt != nil && time.Now().After(*code.ExpiresAt) {
|
|
_, _ = tx.ExecContext(ctx, `UPDATE hwlab_redeem_codes SET status = 'expired', updated_at = now() WHERE id = $1`, code.ID)
|
|
return redeemApplyResult{}, errRedeemCodeExpired
|
|
}
|
|
if code.RedeemedCount >= code.MaxRedemptions {
|
|
return redeemApplyResult{}, errRedeemCodeExhausted
|
|
}
|
|
var userRedemptions int64
|
|
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM hwlab_redeem_redemptions WHERE code_id = $1 AND user_id = $2 AND status = 'applied'`, code.ID, userID).Scan(&userRedemptions); err != nil {
|
|
return redeemApplyResult{}, err
|
|
}
|
|
if userRedemptions >= code.PerUserLimit {
|
|
return redeemApplyResult{}, errRedeemAlreadyUsed
|
|
}
|
|
var balance int64
|
|
if err := tx.QueryRowContext(ctx, `SELECT balance_credits FROM hwlab_credit_accounts WHERE user_id = $1 FOR UPDATE`, userID).Scan(&balance); err != nil {
|
|
return redeemApplyResult{}, err
|
|
}
|
|
newBalance := balance + code.CreditAmount
|
|
ledgerID := newID("led")
|
|
redemptionID := newID("rdm")
|
|
mergedMetadata := map[string]any{"redeemCodeId": code.ID, "redemptionId": redemptionID, "codePrefix": code.CodePrefix, "valuesRedacted": true}
|
|
for key, value := range metadata {
|
|
mergedMetadata[key] = value
|
|
}
|
|
_, err = tx.ExecContext(ctx, `UPDATE hwlab_credit_accounts SET balance_credits = $2, updated_at = now() WHERE user_id = $1`, userID, newBalance)
|
|
if err != nil {
|
|
return redeemApplyResult{}, err
|
|
}
|
|
_, err = tx.ExecContext(ctx, `INSERT INTO hwlab_credit_ledger (id, user_id, delta_credits, balance_before, balance_after, kind, reason, source, status, idempotency_key, metadata) VALUES ($1, $2, $3, $4, $5, 'redeem', $6, 'redeem', 'applied', nullif($7, ''), $8::jsonb)`, ledgerID, userID, code.CreditAmount, balance, newBalance, code.Reason, idempotencyKey, jsonObject(mergedMetadata))
|
|
if err != nil {
|
|
return redeemApplyResult{}, err
|
|
}
|
|
_, err = tx.ExecContext(ctx, `INSERT INTO hwlab_redeem_redemptions (id, code_id, user_id, ledger_id, credit_amount, status, idempotency_key, metadata) VALUES ($1, $2, $3, $4, $5, 'applied', nullif($6, ''), $7::jsonb)`, redemptionID, code.ID, userID, ledgerID, code.CreditAmount, idempotencyKey, jsonObject(mergedMetadata))
|
|
if err != nil {
|
|
return redeemApplyResult{}, err
|
|
}
|
|
_, err = tx.ExecContext(ctx, `UPDATE hwlab_redeem_codes SET redeemed_count = redeemed_count + 1, updated_at = now() WHERE id = $1`, code.ID)
|
|
if err != nil {
|
|
return redeemApplyResult{}, err
|
|
}
|
|
result := redeemApplyResult{Redeemed: true, CodeID: code.ID, CodePrefix: code.CodePrefix, UserID: userID, RedemptionID: redemptionID, LedgerID: ledgerID, CreditAmount: code.CreditAmount, BalanceBefore: balance, BalanceAfter: newBalance, Reason: code.Reason, Source: "redeem", Status: "applied", IdempotencyKey: idempotencyKey}
|
|
return result, tx.Commit()
|
|
}
|
|
|
|
func (s *Server) redeemResultByIdempotency(ctx context.Context, idempotencyKey string) (redeemApplyResult, bool, error) {
|
|
var result redeemApplyResult
|
|
var before sql.NullInt64
|
|
err := s.db.QueryRowContext(ctx, `SELECT r.id, r.code_id, c.code_prefix, r.user_id, COALESCE(r.ledger_id, ''), r.credit_amount, r.status, COALESCE(r.idempotency_key, ''), COALESCE(l.balance_before, l.balance_after - l.delta_credits), COALESCE(l.balance_after, 0), COALESCE(l.reason, c.reason) FROM hwlab_redeem_redemptions r JOIN hwlab_redeem_codes c ON c.id = r.code_id LEFT JOIN hwlab_credit_ledger l ON l.id = r.ledger_id WHERE r.idempotency_key = $1`, idempotencyKey).Scan(&result.RedemptionID, &result.CodeID, &result.CodePrefix, &result.UserID, &result.LedgerID, &result.CreditAmount, &result.Status, &result.IdempotencyKey, &before, &result.BalanceAfter, &result.Reason)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return redeemApplyResult{}, false, nil
|
|
}
|
|
if err != nil {
|
|
return redeemApplyResult{}, false, err
|
|
}
|
|
if before.Valid {
|
|
result.BalanceBefore = before.Int64
|
|
}
|
|
result.Redeemed = true
|
|
result.Idempotent = true
|
|
result.Source = "redeem"
|
|
return result, true, nil
|
|
}
|
|
|
|
func (s *Server) redeemCodes(ctx context.Context, limit int, status string) ([]redeemCodeSummary, error) {
|
|
args := []any{}
|
|
where := ""
|
|
if status != "" {
|
|
args = append(args, status)
|
|
where = "WHERE status = $1"
|
|
}
|
|
args = append(args, limit)
|
|
query := `SELECT id, code_prefix, display_name, status, credit_amount, reason, max_redemptions, per_user_limit, redeemed_count, expires_at, COALESCE(created_by_user_id, ''), COALESCE(created_by_username, ''), metadata::text, created_at, updated_at, disabled_at FROM hwlab_redeem_codes ` + where + ` ORDER BY created_at DESC, id DESC LIMIT $` + strconv.Itoa(len(args))
|
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []redeemCodeSummary{}
|
|
for rows.Next() {
|
|
item, err := scanRedeemCode(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
return items, rows.Err()
|
|
}
|
|
|
|
func (s *Server) redeemCodeByID(ctx context.Context, codeID string) (redeemCodeSummary, error) {
|
|
row := s.db.QueryRowContext(ctx, `SELECT id, code_prefix, display_name, status, credit_amount, reason, max_redemptions, per_user_limit, redeemed_count, expires_at, COALESCE(created_by_user_id, ''), COALESCE(created_by_username, ''), metadata::text, created_at, updated_at, disabled_at FROM hwlab_redeem_codes WHERE id = $1`, codeID)
|
|
return scanRedeemCode(row)
|
|
}
|
|
|
|
func (s *Server) updateRedeemCodeStatus(ctx context.Context, codeID, status string) (redeemCodeSummary, error) {
|
|
_, err := s.db.ExecContext(ctx, `UPDATE hwlab_redeem_codes SET status = $2, disabled_at = CASE WHEN $2 = 'disabled' THEN now() ELSE NULL END, updated_at = now() WHERE id = $1`, codeID, status)
|
|
if err != nil {
|
|
return redeemCodeSummary{}, err
|
|
}
|
|
return s.redeemCodeByID(ctx, codeID)
|
|
}
|
|
|
|
func (s *Server) redeemRedemptions(ctx context.Context, limit int, userID, codeID string) ([]redeemRedemptionSummary, error) {
|
|
args := []any{}
|
|
filters := []string{}
|
|
if userID != "" {
|
|
args = append(args, userID)
|
|
filters = append(filters, "r.user_id = $"+strconv.Itoa(len(args)))
|
|
}
|
|
if codeID != "" {
|
|
args = append(args, codeID)
|
|
filters = append(filters, "r.code_id = $"+strconv.Itoa(len(args)))
|
|
}
|
|
where := ""
|
|
if len(filters) > 0 {
|
|
where = "WHERE " + strings.Join(filters, " AND ")
|
|
}
|
|
args = append(args, limit)
|
|
query := `SELECT r.id, r.code_id, c.code_prefix, r.user_id, COALESCE(r.ledger_id, ''), r.credit_amount, r.status, COALESCE(r.idempotency_key, ''), r.metadata::text, r.created_at FROM hwlab_redeem_redemptions r JOIN hwlab_redeem_codes c ON c.id = r.code_id ` + where + ` ORDER BY r.created_at DESC, r.id DESC LIMIT $` + strconv.Itoa(len(args))
|
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []redeemRedemptionSummary{}
|
|
for rows.Next() {
|
|
var item redeemRedemptionSummary
|
|
var metadataRaw string
|
|
if err := rows.Scan(&item.ID, &item.CodeID, &item.CodePrefix, &item.UserID, &item.LedgerID, &item.CreditAmount, &item.Status, &item.IdempotencyKey, &metadataRaw, &item.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
item.Metadata = parseJSONMap(metadataRaw)
|
|
items = append(items, item)
|
|
}
|
|
return items, rows.Err()
|
|
}
|
|
|
|
func (s *Server) subscriptionSummary(ctx context.Context, userID string) (map[string]any, error) {
|
|
planID, plan, entitlements, err := s.accountPlanContext(ctx, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rows, err := s.db.QueryContext(ctx, `SELECT id, plan_id, status, source, current_period_start, current_period_end, metadata::text, created_at, updated_at FROM hwlab_user_subscriptions WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20`, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
subscriptions := []map[string]any{}
|
|
for rows.Next() {
|
|
var id, rowPlanID, status, source, metadataRaw string
|
|
var start, end sql.NullTime
|
|
var createdAt, updatedAt time.Time
|
|
if err := rows.Scan(&id, &rowPlanID, &status, &source, &start, &end, &metadataRaw, &createdAt, &updatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
item := map[string]any{"id": id, "planId": rowPlanID, "status": status, "source": source, "metadata": parseJSONMap(metadataRaw), "createdAt": createdAt, "updatedAt": updatedAt}
|
|
if start.Valid {
|
|
item["currentPeriodStart"] = start.Time
|
|
}
|
|
if end.Valid {
|
|
item["currentPeriodEnd"] = end.Time
|
|
}
|
|
subscriptions = append(subscriptions, item)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{"contractVersion": "user-billing-subscription-v1", "planId": planID, "plan": plan, "entitlements": entitlements, "subscriptions": subscriptions, "payment": paymentProviderStatus(), "valuesRedacted": true}, nil
|
|
}
|
|
|
|
func (s *Server) paymentSummary(ctx context.Context, userID string) (map[string]any, error) {
|
|
rows, err := s.db.QueryContext(ctx, `SELECT id, provider, status, credit_amount, amount_minor, currency, reason, metadata::text, created_at, updated_at FROM hwlab_payment_orders WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20`, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
orders := []map[string]any{}
|
|
for rows.Next() {
|
|
var id, provider, status, currency, reason, metadataRaw string
|
|
var credits, amountMinor int64
|
|
var createdAt, updatedAt time.Time
|
|
if err := rows.Scan(&id, &provider, &status, &credits, &amountMinor, ¤cy, &reason, &metadataRaw, &createdAt, &updatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
orders = append(orders, map[string]any{"id": id, "provider": provider, "status": status, "creditAmount": credits, "amountMinor": amountMinor, "currency": currency, "reason": reason, "metadata": parseJSONMap(metadataRaw), "createdAt": createdAt, "updatedAt": updatedAt})
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{"contractVersion": "user-billing-payment-v1", "provider": paymentProviderStatus(), "orders": orders, "count": len(orders), "valuesRedacted": true}, nil
|
|
}
|
|
|
|
type redeemCodeScanner interface {
|
|
Scan(dest ...any) error
|
|
}
|
|
|
|
func scanRedeemCode(row redeemCodeScanner) (redeemCodeSummary, error) {
|
|
var item redeemCodeSummary
|
|
var expiresAt sql.NullTime
|
|
var disabledAt sql.NullTime
|
|
var metadataRaw string
|
|
if err := row.Scan(&item.ID, &item.CodePrefix, &item.DisplayName, &item.Status, &item.CreditAmount, &item.Reason, &item.MaxRedemptions, &item.PerUserLimit, &item.RedeemedCount, &expiresAt, &item.CreatedByUserID, &item.CreatedByUsername, &metadataRaw, &item.CreatedAt, &item.UpdatedAt, &disabledAt); err != nil {
|
|
return item, err
|
|
}
|
|
if expiresAt.Valid {
|
|
item.ExpiresAt = &expiresAt.Time
|
|
}
|
|
if disabledAt.Valid {
|
|
item.DisabledAt = &disabledAt.Time
|
|
}
|
|
item.Metadata = parseJSONMap(metadataRaw)
|
|
item.RemainingCount = item.MaxRedemptions - item.RedeemedCount
|
|
if item.RemainingCount < 0 {
|
|
item.RemainingCount = 0
|
|
}
|
|
return item, nil
|
|
}
|
|
|
|
func paymentProviderStatus() map[string]any {
|
|
return map[string]any{"configured": false, "status": "unconfigured", "provider": "none", "message": "payment provider is not configured; redeem/manual recharge is available", "valuesRedacted": true}
|
|
}
|
|
|
|
func redeemCodeHash(code string) string {
|
|
sum := sha256.Sum256([]byte(strings.TrimSpace(code)))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func redeemCodePrefix(code string) string {
|
|
text := strings.TrimSpace(code)
|
|
if len(text) <= 16 {
|
|
return text
|
|
}
|
|
return text[:16]
|
|
}
|
|
|
|
func redeemPositiveInt(value string, fallback int) int {
|
|
parsed, err := strconv.Atoi(strings.TrimSpace(value))
|
|
if err != nil || parsed <= 0 {
|
|
return fallback
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
var (
|
|
errRedeemCodeRequired = errors.New("redeem code is required")
|
|
errRedeemCodeNotFound = errors.New("redeem code not found")
|
|
errRedeemCodeDisabled = errors.New("redeem code is disabled")
|
|
errRedeemCodeExpired = errors.New("redeem code is expired")
|
|
errRedeemCodeExhausted = errors.New("redeem code is exhausted")
|
|
errRedeemAlreadyUsed = errors.New("redeem code already used by this user")
|
|
)
|
|
|
|
func redeemError(err error) (int, string, string) {
|
|
switch {
|
|
case errors.Is(err, errRedeemCodeRequired):
|
|
return http.StatusBadRequest, "redeem_code_required", "redeem code is required"
|
|
case errors.Is(err, errRedeemCodeNotFound):
|
|
return http.StatusNotFound, "redeem_code_not_found", "redeem code was not found"
|
|
case errors.Is(err, errRedeemCodeDisabled):
|
|
return http.StatusForbidden, "redeem_code_disabled", "redeem code is disabled"
|
|
case errors.Is(err, errRedeemCodeExpired):
|
|
return http.StatusForbidden, "redeem_code_expired", "redeem code is expired"
|
|
case errors.Is(err, errRedeemCodeExhausted):
|
|
return http.StatusConflict, "redeem_code_exhausted", "redeem code has no remaining redemptions"
|
|
case errors.Is(err, errRedeemAlreadyUsed):
|
|
return http.StatusConflict, "redeem_code_already_used", "redeem code has already been redeemed by this user"
|
|
default:
|
|
return http.StatusInternalServerError, "redeem_failed", fmt.Sprintf("redeem failed: %s", err.Error())
|
|
}
|
|
}
|