mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-12 20:45:20 +02:00
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.
This commit is contained in:
parent
a7407502d3
commit
65e511f77b
6 changed files with 70 additions and 23 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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?",
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<div className="w-full space-y-5">
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-bold tracking-tight">Buy Credits</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
$1 buys $1 of credit, billed at provider cost
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-muted/20 p-3">
|
||||
|
|
@ -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"
|
||||
>
|
||||
<Minus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<span className="min-w-32 text-center text-lg font-semibold tabular-nums">
|
||||
${(totalCreditMicros / 1_000_000).toFixed(0)} of credit
|
||||
</span>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-lg font-semibold">$</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={amountInput}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">of credit</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setQuantity((q) => Math.min(100, q + 1))}
|
||||
disabled={quantity >= 100 || purchaseMutation.isPending}
|
||||
onClick={() => commitQuantity(quantity + 1)}
|
||||
disabled={quantity >= MAX_QUANTITY || purchaseMutation.isPending}
|
||||
className="size-8 text-muted-foreground shadow-none transition-colors hover:bg-muted hover:text-white disabled:opacity-40"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
|
|
@ -123,7 +152,7 @@ export function BuyCreditsContent() {
|
|||
key={m}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setQuantity(m)}
|
||||
onClick={() => commitQuantity(m)}
|
||||
disabled={purchaseMutation.isPending}
|
||||
className={cn(
|
||||
"h-auto rounded-md px-2.5 py-1 text-xs font-medium tabular-nums transition-colors disabled:opacity-60",
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ export const purchaseStatusEnum = z.enum(["pending", "completed", "failed"]);
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const createCreditCheckoutSessionRequest = z.object({
|
||||
quantity: z.number().int().min(1).max(100),
|
||||
quantity: z.number().int().min(1).max(10_000),
|
||||
search_space_id: z.number().int().min(1),
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue