diff options
| author | Chia <Chia@93.nz> | 2026-08-06 09:29:41 +1200 |
|---|---|---|
| committer | Chia <Chia@93.nz> | 2026-08-06 09:32:46 +1200 |
| commit | 41e322c53d7b4b796eb377d0df9c29ecd10ba431 (patch) | |
| tree | c730526150e55e39b822d5197e4a20318ecaa449 /internal/billing/operations.go | |
| parent | eadb2ffe85c43cf6fc741c9823cd28eedb4a844c (diff) | |
feat: complete commercial control plane, billing, auth, and model catalog
- add PostgreSQL control-plane persistence with Redis-degraded hot reload
- implement prepaid balance, usage ledger, Stripe top-up and reconciliation
- add registration, email verification, password reset, invitations and RBAC
- support TOTP, Passkey MFA, device sessions, quotas and rate limits
- add tenant billing profiles, audit logs and operational readiness checks
- build authenticated admin console, Quickstart, Playground and usage analytics
- add public model catalog with pricing, filtering and cost estimation
- support OpenAI Responses providers and provider health failover
- validate real upstream usage reporting and balance settlement
Diffstat (limited to 'internal/billing/operations.go')
| -rw-r--r-- | internal/billing/operations.go | 84 |
1 files changed, 79 insertions, 5 deletions
diff --git a/internal/billing/operations.go b/internal/billing/operations.go index a6dc653..461c59a 100644 --- a/internal/billing/operations.go +++ b/internal/billing/operations.go @@ -19,13 +19,13 @@ func (s *Service) CreatePortalSession(ctx context.Context, tenantID string) (Por if !s.stripeEnabled || s.stripeClient == nil { return PortalResult{}, ErrStripeDisabled } - var customerID string - if err := s.db.QueryRow(ctx, `SELECT stripe_customer_id FROM stripe_customers WHERE tenant_id=$1`, tenantID).Scan(&customerID); err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return PortalResult{}, errors.New("no Stripe customer exists for this account") - } + customerID, err := s.ensureStripeCustomer(ctx, tenantID) + if err != nil { return PortalResult{}, err } + if customerID == "" { + return PortalResult{}, errors.New("no Stripe customer exists for this account") + } session, err := s.stripeClient.V1BillingPortalSessions.Create(ctx, &stripe.BillingPortalSessionCreateParams{ Customer: stripe.String(customerID), ReturnURL: stripe.String(s.stripePortalReturnURL), }) @@ -353,6 +353,12 @@ func (s *Service) RunStripeOperations(ctx context.Context) { cancel() s.refreshOperationalMetrics(ctx) for { + for i := 0; i < 4; i++ { + ok, _ := s.processAutoTopUpOnce(ctx) + if !ok { + break + } + } for i := 0; i < 8; i++ { ok, _ := s.processRefundOperation(ctx) if !ok { @@ -793,6 +799,74 @@ func (s *Service) Reconcile(ctx context.Context, limit int) (ReconciliationResul return s.failReconciliation(ctx, result, fmt.Errorf("record clean reconciliation for order %s: %w", item.id, err)) } } + if s.retrieveStripePaymentIntent == nil { + return s.failReconciliation(ctx, result, errors.New("Stripe PaymentIntent retrieval is unavailable")) + } + piRows, err := s.db.Query(ctx, `SELECT id::text,stripe_payment_intent_id,status,amount_minor,currency FROM topup_orders + WHERE trigger_type='auto' AND stripe_payment_intent_id IS NOT NULL ORDER BY created_at DESC LIMIT $1`, limit) + if err != nil { + return s.failReconciliation(ctx, result, err) + } + type paymentIntentOrder struct { + id, paymentIntent, status, currency string + amount int64 + } + var paymentIntentOrders []paymentIntentOrder + for piRows.Next() { + var item paymentIntentOrder + if err := piRows.Scan(&item.id, &item.paymentIntent, &item.status, &item.amount, &item.currency); err != nil { + piRows.Close() + return s.failReconciliation(ctx, result, err) + } + paymentIntentOrders = append(paymentIntentOrders, item) + } + piRows.Close() + for _, item := range paymentIntentOrders { + intent, retrieveErr := s.retrieveStripePaymentIntent(ctx, item.paymentIntent, &stripe.PaymentIntentRetrieveParams{}) + result.CheckedOrders++ + if retrieveErr != nil { + message := truncateError(retrieveErr) + if err := s.updateOrderReconciliation(ctx, item.id, "mismatch", message); err != nil { + return s.failReconciliation(ctx, result, fmt.Errorf("record automatic top-up retrieval failure for order %s: %w", item.id, err)) + } + result.Mismatches = append(result.Mismatches, map[string]any{"order_id": item.id, "type": "payment_intent_retrieve_failed", "error": message}) + continue + } + if intent == nil || intent.Amount != item.amount || string(intent.Currency) != item.currency { + if err := s.updateOrderReconciliation(ctx, item.id, "mismatch", "amount or currency mismatch"); err != nil { + return s.failReconciliation(ctx, result, err) + } + result.Mismatches = append(result.Mismatches, map[string]any{"order_id": item.id, "type": "payment_intent_mismatch"}) + continue + } + expectedPaid := item.status == "paid" || item.status == "partially_refunded" || item.status == "refunded" || item.status == "disputed" + stripePaid := intent.Status == stripe.PaymentIntentStatusSucceeded + if stripePaid && !expectedPaid { + if repairErr := s.creditAutoTopUpPaymentIntent(ctx, intent); repairErr != nil { + message := truncateError(repairErr) + result.Mismatches = append(result.Mismatches, map[string]any{"order_id": item.id, "type": "payment_intent_repair_failed", "error": message}) + if err := s.updateOrderReconciliation(ctx, item.id, "mismatch", message); err != nil { + return s.failReconciliation(ctx, result, err) + } + continue + } + result.Repairs = append(result.Repairs, map[string]any{"order_id": item.id, "type": "credited_paid_payment_intent"}) + if err := s.updateOrderReconciliation(ctx, item.id, "repaired", ""); err != nil { + return s.failReconciliation(ctx, result, err) + } + continue + } + if expectedPaid != stripePaid { + result.Mismatches = append(result.Mismatches, map[string]any{"order_id": item.id, "type": "payment_intent_state_mismatch", "local_status": item.status, "stripe_status": intent.Status}) + if err := s.updateOrderReconciliation(ctx, item.id, "mismatch", "payment state mismatch"); err != nil { + return s.failReconciliation(ctx, result, err) + } + continue + } + if err := s.updateOrderReconciliation(ctx, item.id, "ok", ""); err != nil { + return s.failReconciliation(ctx, result, err) + } + } result.MismatchCount = int64(len(result.Mismatches)) result.Status = "clean" if result.MismatchCount > 0 { |
