summaryrefslogtreecommitdiff
path: root/internal/billing
diff options
context:
space:
mode:
authorChia <Chia@93.nz>2026-08-05 14:48:00 +1200
committerChia <Chia@93.nz>2026-08-05 14:48:00 +1200
commitcd0dd91ab93653631904f2ea0e574ccde6d60339 (patch)
treec65417b880a3f4a35c504c44edae821bc2122f70 /internal/billing
parent86b1f42e3c5601ff10621a9779cf0076590797a1 (diff)
add passkey, totp.
Diffstat (limited to 'internal/billing')
-rw-r--r--internal/billing/ledger.go38
-rw-r--r--internal/billing/service_test.go12
-rw-r--r--internal/billing/stripe.go23
-rw-r--r--internal/billing/types.go14
4 files changed, 85 insertions, 2 deletions
diff --git a/internal/billing/ledger.go b/internal/billing/ledger.go
index 28cbe4c..c408cfe 100644
--- a/internal/billing/ledger.go
+++ b/internal/billing/ledger.go
@@ -73,6 +73,44 @@ func (s *Service) ListLedger(ctx context.Context, tenantID string, limit int) ([
return result, rows.Err()
}
+func (s *Service) ListTopUpOrders(ctx context.Context, tenantID string, limit int) ([]TopUpOrder, error) {
+ if limit < 1 || limit > 200 {
+ limit = 50
+ }
+ rows, err := s.db.Query(ctx, `SELECT id::text,tenant_id::text,amount_minor,amount_micros,currency,status,
+ COALESCE(stripe_session_id,''),COALESCE(checkout_url,''),created_at,paid_at FROM topup_orders
+ WHERE tenant_id=$1 ORDER BY created_at DESC LIMIT $2`, tenantID, limit)
+ if err != nil {
+ return nil, fmt.Errorf("query top-up orders: %w", err)
+ }
+ defer rows.Close()
+ result := make([]TopUpOrder, 0)
+ for rows.Next() {
+ var item TopUpOrder
+ if err := rows.Scan(&item.ID, &item.TenantID, &item.AmountMinor, &item.AmountMicros, &item.Currency, &item.Status,
+ &item.StripeSessionID, &item.CheckoutURL, &item.CreatedAt, &item.PaidAt); err != nil {
+ return nil, err
+ }
+ result = append(result, item)
+ }
+ return result, rows.Err()
+}
+
+func (s *Service) GetTopUpOrder(ctx context.Context, tenantID, orderID string) (TopUpOrder, error) {
+ var result TopUpOrder
+ err := s.db.QueryRow(ctx, `SELECT id::text,tenant_id::text,amount_minor,amount_micros,currency,status,
+ COALESCE(stripe_session_id,''),COALESCE(checkout_url,''),created_at,paid_at FROM topup_orders
+ WHERE id=$1 AND tenant_id=$2`, orderID, tenantID).Scan(&result.ID, &result.TenantID, &result.AmountMinor, &result.AmountMicros,
+ &result.Currency, &result.Status, &result.StripeSessionID, &result.CheckoutURL, &result.CreatedAt, &result.PaidAt)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return TopUpOrder{}, ErrTopUpOrderNotFound
+ }
+ if err != nil {
+ return TopUpOrder{}, fmt.Errorf("get top-up order: %w", err)
+ }
+ return result, nil
+}
+
func (s *Service) AdjustBalance(ctx context.Context, input AdjustmentInput) (LedgerEntry, error) {
input.TenantID = strings.TrimSpace(input.TenantID)
input.Description = normalizeDescription(input.Description)
diff --git a/internal/billing/service_test.go b/internal/billing/service_test.go
index 17f8143..f675da7 100644
--- a/internal/billing/service_test.go
+++ b/internal/billing/service_test.go
@@ -85,6 +85,18 @@ func TestIntegrationIdentifierSuffixUsesLetters(t *testing.T) {
}
}
+func TestCheckoutReturnURLPreservesCallbackAndSessionPlaceholder(t *testing.T) {
+ success := checkoutReturnURL("https://console.example.test/admin/?topup=success", "order-123", true)
+ if !strings.Contains(success, "topup=success") || !strings.Contains(success, "order_id=order-123") ||
+ !strings.Contains(success, "session_id={CHECKOUT_SESSION_ID}") {
+ t.Fatalf("unexpected success URL %q", success)
+ }
+ cancel := checkoutReturnURL("https://console.example.test/admin/?topup=cancel&session_id=stale", "order-123", false)
+ if !strings.Contains(cancel, "topup=cancel") || !strings.Contains(cancel, "order_id=order-123") || strings.Contains(cancel, "session_id=") {
+ t.Fatalf("unexpected cancel URL %q", cancel)
+ }
+}
+
func TestWebhookRejectsInvalidSignatureBeforeProcessing(t *testing.T) {
service := &Service{stripeWebhookSecret: "whsec_test"}
request := httptest.NewRequest(http.MethodPost, "/billing/stripe/webhook", strings.NewReader(`{"id":"evt_fake"}`))
diff --git a/internal/billing/stripe.go b/internal/billing/stripe.go
index b06eb6a..b887035 100644
--- a/internal/billing/stripe.go
+++ b/internal/billing/stripe.go
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"net/http"
+ "net/url"
"strings"
"github.com/jackc/pgx/v5"
@@ -32,8 +33,8 @@ func (s *Service) CreateCheckout(ctx context.Context, input CheckoutInput) (Chec
Mode: stripe.String("payment"),
ClientReferenceID: stripe.String(orderID),
IntegrationIdentifier: stripe.String(s.integrationIdentifier),
- SuccessURL: stripe.String(s.stripeSuccessURL),
- CancelURL: stripe.String(s.stripeCancelURL),
+ SuccessURL: stripe.String(checkoutReturnURL(s.stripeSuccessURL, orderID, true)),
+ CancelURL: stripe.String(checkoutReturnURL(s.stripeCancelURL, orderID, false)),
Metadata: map[string]string{
"aigw_topup_order_id": orderID,
"aigw_tenant_id": strings.TrimSpace(input.TenantID),
@@ -67,6 +68,24 @@ func (s *Service) CreateCheckout(ctx context.Context, input CheckoutInput) (Chec
return CheckoutResult{OrderID: orderID, SessionID: session.ID, URL: session.URL}, nil
}
+func checkoutReturnURL(raw, orderID string, includeStripeSession bool) string {
+ parsed, err := url.Parse(raw)
+ if err != nil {
+ return raw
+ }
+ query := parsed.Query()
+ query.Set("order_id", orderID)
+ if includeStripeSession {
+ query.Set("session_id", "{CHECKOUT_SESSION_ID}")
+ } else {
+ query.Del("session_id")
+ }
+ encoded := query.Encode()
+ encoded = strings.ReplaceAll(encoded, url.QueryEscape("{CHECKOUT_SESSION_ID}"), "{CHECKOUT_SESSION_ID}")
+ parsed.RawQuery = encoded
+ return parsed.String()
+}
+
func (s *Service) WebhookHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
diff --git a/internal/billing/types.go b/internal/billing/types.go
index 24694cc..3d0461e 100644
--- a/internal/billing/types.go
+++ b/internal/billing/types.go
@@ -13,6 +13,7 @@ var (
ErrStripeDisabled = errors.New("Stripe top-ups are disabled")
ErrInvalidAmount = errors.New("invalid amount")
ErrQuotaExceeded = errors.New("monthly spend quota exceeded")
+ ErrTopUpOrderNotFound = errors.New("top-up order not found")
)
type Meter interface {
@@ -81,3 +82,16 @@ type CheckoutResult struct {
SessionID string `json:"session_id"`
URL string `json:"url"`
}
+
+type TopUpOrder struct {
+ ID string `json:"id"`
+ TenantID string `json:"tenant_id"`
+ AmountMinor int64 `json:"amount_minor"`
+ AmountMicros int64 `json:"amount_micros"`
+ Currency string `json:"currency"`
+ Status string `json:"status"`
+ StripeSessionID string `json:"stripe_session_id,omitempty"`
+ CheckoutURL string `json:"checkout_url,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ PaidAt *time.Time `json:"paid_at,omitempty"`
+}