From 65e511f77bd59d9add3a535a923430e840fd5dc7 Mon Sep 17 00:00:00 2001 From: "DESKTOP-RTLN3BA\\$punk" Date: Wed, 10 Jun 2026 22:52:27 -0700 Subject: [PATCH] feat: enhance credit management and user experience - Updated database queries to check for column existence with schema context. - Modified credit purchase quantity limits to allow up to 10,000 credits. - Improved user interface for credit purchases, enabling custom amounts and clamping input values. - Adjusted FAQ content to clarify credit purchasing process. --- .../versions/156_unify_credits_wallet.py | 31 ++++++++--- .../versions/157_add_auto_reload_columns.py | 3 +- surfsense_backend/app/schemas/stripe.py | 2 +- .../components/pricing/pricing-section.tsx | 2 +- .../settings/buy-credits-content.tsx | 53 ++++++++++++++----- surfsense_web/contracts/types/stripe.types.ts | 2 +- 6 files changed, 70 insertions(+), 23 deletions(-) diff --git a/surfsense_backend/alembic/versions/156_unify_credits_wallet.py b/surfsense_backend/alembic/versions/156_unify_credits_wallet.py index 33e6d9087..1ecf1a255 100644 --- a/surfsense_backend/alembic/versions/156_unify_credits_wallet.py +++ b/surfsense_backend/alembic/versions/156_unify_credits_wallet.py @@ -55,7 +55,8 @@ def _column_exists(conn, table: str, column: str) -> bool: conn.execute( sa.text( "SELECT 1 FROM information_schema.columns " - "WHERE table_name = :tbl AND column_name = :col" + "WHERE table_name = :tbl AND column_name = :col " + "AND table_schema = current_schema()" ), {"tbl": table, "col": column}, ).fetchone() @@ -107,11 +108,17 @@ def upgrade() -> None: ), ) - # Backfill only when the legacy source columns are present (fresh DBs + # Backfill only when ALL legacy source columns are present (fresh DBs # created from current models won't have them). - if _column_exists( - conn, "user", "premium_credit_micros_limit" - ) and _column_exists(conn, "user", "pages_limit"): + if all( + _column_exists(conn, "user", col) + for col in ( + "premium_credit_micros_limit", + "premium_credit_micros_used", + "pages_limit", + "pages_used", + ) + ): conn.execute( sa.text( 'UPDATE "user" SET credit_micros_balance = ' @@ -163,8 +170,18 @@ def upgrade() -> None: """ DO $$ BEGIN - IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'premiumtokenpurchasestatus') - AND NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'creditpurchasestatus') + IF EXISTS ( + SELECT 1 FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE t.typname = 'premiumtokenpurchasestatus' + AND n.nspname = current_schema() + ) + AND NOT EXISTS ( + SELECT 1 FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE t.typname = 'creditpurchasestatus' + AND n.nspname = current_schema() + ) THEN ALTER TYPE premiumtokenpurchasestatus RENAME TO creditpurchasestatus; END IF; diff --git a/surfsense_backend/alembic/versions/157_add_auto_reload_columns.py b/surfsense_backend/alembic/versions/157_add_auto_reload_columns.py index 9a0dc2e48..ef021b6d2 100644 --- a/surfsense_backend/alembic/versions/157_add_auto_reload_columns.py +++ b/surfsense_backend/alembic/versions/157_add_auto_reload_columns.py @@ -39,7 +39,8 @@ def _column_exists(conn, table: str, column: str) -> bool: conn.execute( sa.text( "SELECT 1 FROM information_schema.columns " - "WHERE table_name = :tbl AND column_name = :col" + "WHERE table_name = :tbl AND column_name = :col " + "AND table_schema = current_schema()" ), {"tbl": table, "col": column}, ).fetchone() diff --git a/surfsense_backend/app/schemas/stripe.py b/surfsense_backend/app/schemas/stripe.py index 39c1c653a..95c946a3d 100644 --- a/surfsense_backend/app/schemas/stripe.py +++ b/surfsense_backend/app/schemas/stripe.py @@ -11,7 +11,7 @@ from app.db import PagePurchaseStatus class CreateCreditCheckoutSessionRequest(BaseModel): """Request body for creating a credit-purchase checkout session.""" - quantity: int = Field(ge=1, le=100) + quantity: int = Field(ge=1, le=10_000) search_space_id: int = Field(ge=1) diff --git a/surfsense_web/components/pricing/pricing-section.tsx b/surfsense_web/components/pricing/pricing-section.tsx index 014cebd66..bf4067336 100644 --- a/surfsense_web/components/pricing/pricing-section.tsx +++ b/surfsense_web/components/pricing/pricing-section.tsx @@ -149,7 +149,7 @@ const faqData: FAQSection[] = [ { question: "How does buying credit work?", answer: - "Top-ups are pay as you go, with no subscription. $1 buys $1 of credit, and your balance is spent at provider cost. Purchased credit is added to your account immediately, and you can buy up to $100 at a time. Enable auto-reload to top up automatically when your balance runs low.", + "Top-ups are pay as you go, with no subscription. $1 buys $1 of credit, and your balance is spent at provider cost. Purchased credit is added to your account immediately, and you can buy any custom amount. Enable auto-reload to top up automatically when your balance runs low.", }, { question: "Is there a separate balance for documents and AI?", diff --git a/surfsense_web/components/settings/buy-credits-content.tsx b/surfsense_web/components/settings/buy-credits-content.tsx index cfc1fcc49..8cb339420 100644 --- a/surfsense_web/components/settings/buy-credits-content.tsx +++ b/surfsense_web/components/settings/buy-credits-content.tsx @@ -18,7 +18,12 @@ import { queries } from "@/zero/queries"; // at the actual cost, so $1 of credit always buys $1 of usage at cost. const CREDIT_PER_PACK_MICROS = 1_000_000; const PRICE_PER_PACK_USD = 1; -const PRESET_MULTIPLIERS = [1, 2, 5, 10, 25, 50] as const; +const PRESET_MULTIPLIERS = [1, 2, 5, 10, 25, 50, 100] as const; +const MIN_QUANTITY = 1; +const MAX_QUANTITY = 10_000; + +const clampQuantity = (value: number) => + Math.min(MAX_QUANTITY, Math.max(MIN_QUANTITY, Math.floor(value))); const formatUsd = (micros: number) => { // Clamp at $0.00 — the balance can dip slightly negative when actual cost @@ -34,6 +39,15 @@ export function BuyCreditsContent() { const params = useParams(); const searchSpaceId = Number(params?.search_space_id); const [quantity, setQuantity] = useState(1); + // Raw text of the amount field so the user can clear it while typing; + // committed back to a clamped integer on blur. + const [amountInput, setAmountInput] = useState("1"); + + const commitQuantity = (value: number) => { + const clamped = clampQuantity(Number.isFinite(value) ? value : MIN_QUANTITY); + setQuantity(clamped); + setAmountInput(String(clamped)); + }; // Server config flag: stays on REST, not per-user. const { data: creditStatus } = useQuery({ @@ -78,9 +92,6 @@ export function BuyCreditsContent() {

Buy Credits

-

- $1 buys $1 of credit, billed at provider cost -

@@ -96,21 +107,39 @@ export function BuyCreditsContent() { type="button" variant="ghost" size="icon" - onClick={() => setQuantity((q) => Math.max(1, q - 1))} - disabled={quantity <= 1 || purchaseMutation.isPending} + onClick={() => commitQuantity(quantity - 1)} + disabled={quantity <= MIN_QUANTITY || purchaseMutation.isPending} className="size-8 text-muted-foreground shadow-none transition-colors hover:bg-muted hover:text-white disabled:opacity-40" > - - ${(totalCreditMicros / 1_000_000).toFixed(0)} of credit - +
+ $ + { + const raw = e.target.value.replace(/[^0-9]/g, ""); + setAmountInput(raw); + const parsed = Number.parseInt(raw, 10); + if (Number.isFinite(parsed)) { + setQuantity(clampQuantity(parsed)); + } + }} + onBlur={() => commitQuantity(Number.parseInt(amountInput, 10))} + disabled={purchaseMutation.isPending} + aria-label="Credit amount in US dollars" + className="w-20 rounded-md border bg-transparent px-2 py-1 text-center text-lg font-semibold tabular-nums outline-none focus:ring-2 focus:ring-ring disabled:opacity-60" + /> + of credit +