From cd0dd91ab93653631904f2ea0e574ccde6d60339 Mon Sep 17 00:00:00 2001 From: Chia Date: Wed, 5 Aug 2026 14:48:00 +1200 Subject: add passkey, totp. --- internal/billing/ledger.go | 38 ++++++++++++++++++++++++++++++++++++++ internal/billing/service_test.go | 12 ++++++++++++ internal/billing/stripe.go | 23 +++++++++++++++++++++-- internal/billing/types.go | 14 ++++++++++++++ 4 files changed, 85 insertions(+), 2 deletions(-) (limited to 'internal/billing') 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"` +} -- cgit v1.2.3