mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-06 06:12:40 +02:00
Merge commit '9576d1f01f' into dev
Some checks are pending
Build and Push Docker Images / tag_release (push) Waiting to run
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (web, surfsense-web) (push) Blocked by required conditions
Some checks are pending
Build and Push Docker Images / tag_release (push) Waiting to run
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (web, surfsense-web) (push) Blocked by required conditions
This commit is contained in:
commit
b5be9408f7
5 changed files with 461 additions and 46 deletions
|
|
@ -1,8 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import { AlertCircle, CheckCircle2, Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
|
|
@ -12,23 +13,133 @@ import {
|
|||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import type { FinalizeCheckoutResponse } from "@/contracts/types/stripe.types";
|
||||
import { stripeApiService } from "@/lib/apis/stripe-api.service";
|
||||
|
||||
type FinalizeState =
|
||||
| { kind: "loading" }
|
||||
| { kind: "completed"; data: FinalizeCheckoutResponse }
|
||||
| { kind: "pending"; data: FinalizeCheckoutResponse }
|
||||
| { kind: "still_pending"; data: FinalizeCheckoutResponse }
|
||||
| { kind: "failed"; data: FinalizeCheckoutResponse }
|
||||
| { kind: "error"; message: string }
|
||||
| { kind: "no_session" };
|
||||
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
const MAX_POLL_ATTEMPTS = 15; // ~30s total before falling back to the still_pending state
|
||||
|
||||
export default function PurchaseSuccessPage() {
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const searchSpaceId = String(params.search_space_id ?? "");
|
||||
const sessionId = searchParams.get("session_id");
|
||||
|
||||
const [state, setState] = useState<FinalizeState>(
|
||||
sessionId ? { kind: "loading" } : { kind: "no_session" }
|
||||
);
|
||||
// Tracks active polling so component unmount cancels it
|
||||
const cancelledRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
|
||||
cancelledRef.current = false;
|
||||
|
||||
const poll = async (attempt: number): Promise<void> => {
|
||||
if (cancelledRef.current) return;
|
||||
try {
|
||||
const data = await stripeApiService.finalizeCheckout(sessionId);
|
||||
if (cancelledRef.current) return;
|
||||
|
||||
if (data.status === "completed") {
|
||||
setState({ kind: "completed", data });
|
||||
return;
|
||||
}
|
||||
if (data.status === "failed") {
|
||||
setState({ kind: "failed", data });
|
||||
return;
|
||||
}
|
||||
|
||||
// Status is "pending" - either the user paid via async
|
||||
// payment method (Klarna, ACH) or webhook + finalize both
|
||||
// raced and lost. Keep polling up to MAX_POLL_ATTEMPTS,
|
||||
// then fall back to a friendlier message that explains
|
||||
// fulfilment may complete asynchronously.
|
||||
if (attempt < MAX_POLL_ATTEMPTS) {
|
||||
setState({ kind: "pending", data });
|
||||
setTimeout(() => poll(attempt + 1), POLL_INTERVAL_MS);
|
||||
} else {
|
||||
setState({ kind: "still_pending", data });
|
||||
}
|
||||
} catch (err) {
|
||||
if (cancelledRef.current) return;
|
||||
const message = err instanceof Error ? err.message : "Unable to finalize checkout.";
|
||||
setState({ kind: "error", message });
|
||||
}
|
||||
};
|
||||
|
||||
void poll(1);
|
||||
|
||||
return () => {
|
||||
cancelledRef.current = true;
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[calc(100vh-64px)] items-center justify-center px-4 py-8">
|
||||
<Card className="w-full max-w-lg">
|
||||
<CardHeader className="text-center">
|
||||
<CheckCircle2 className="mx-auto h-10 w-10 text-emerald-500" />
|
||||
<CardTitle className="text-2xl">Purchase complete</CardTitle>
|
||||
<CardDescription>Your purchase is being applied to your account now.</CardDescription>
|
||||
{state.kind === "loading" || state.kind === "pending" ? (
|
||||
<Loader2 className="mx-auto h-10 w-10 animate-spin text-primary" />
|
||||
) : state.kind === "completed" ? (
|
||||
<CheckCircle2 className="mx-auto h-10 w-10 text-emerald-500" />
|
||||
) : (
|
||||
<AlertCircle className="mx-auto h-10 w-10 text-amber-500" />
|
||||
)}
|
||||
<CardTitle className="text-2xl">
|
||||
{state.kind === "loading" && "Confirming payment…"}
|
||||
{state.kind === "pending" && "Processing your payment…"}
|
||||
{state.kind === "still_pending" && "Payment still processing"}
|
||||
{state.kind === "completed" && "Purchase complete"}
|
||||
{state.kind === "failed" && "Purchase failed"}
|
||||
{state.kind === "error" && "Couldn't confirm payment"}
|
||||
{state.kind === "no_session" && "Purchase complete"}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{state.kind === "loading" && "We're verifying your payment with Stripe."}
|
||||
{state.kind === "pending" &&
|
||||
"Your bank is taking a moment to confirm. This usually takes 5–30 seconds."}
|
||||
{state.kind === "still_pending" &&
|
||||
"Your payment is still being processed by your bank. We'll apply your purchase as soon as it clears — usually within a few minutes. You can safely close this page."}
|
||||
{state.kind === "completed" &&
|
||||
(state.data.purchase_type === "page_packs"
|
||||
? `Added ${formatNumber(state.data.pages_granted ?? 0)} pages to your account.`
|
||||
: `Added ${formatCredit(state.data.premium_credit_micros_granted ?? 0)} of premium credit to your account.`)}
|
||||
{state.kind === "failed" &&
|
||||
"Stripe reported the checkout as failed or expired. Your card was not charged."}
|
||||
{state.kind === "error" &&
|
||||
"Don't worry — if your card was charged, your purchase will still apply within a minute or two."}
|
||||
{state.kind === "no_session" &&
|
||||
"Your purchase is being applied to your account."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your usage meters should refresh automatically in a moment.
|
||||
</p>
|
||||
{state.kind === "completed" && state.data.purchase_type === "page_packs" && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
New balance: {formatNumber(state.data.pages_limit ?? 0)} total pages
|
||||
{typeof state.data.pages_used === "number"
|
||||
? ` (${formatNumber((state.data.pages_limit ?? 0) - state.data.pages_used)} remaining)`
|
||||
: ""}
|
||||
</p>
|
||||
)}
|
||||
{state.kind === "completed" && state.data.purchase_type === "premium_tokens" && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
New premium credit balance: {formatCredit(state.data.premium_credit_micros_limit ?? 0)}
|
||||
</p>
|
||||
)}
|
||||
{state.kind === "error" && (
|
||||
<p className="text-sm text-muted-foreground">{state.message}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-2">
|
||||
<Button asChild className="w-full">
|
||||
|
|
@ -42,3 +153,16 @@ export default function PurchaseSuccessPage() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
return new Intl.NumberFormat("en-US").format(n);
|
||||
}
|
||||
|
||||
function formatCredit(micros: number): string {
|
||||
const dollars = micros / 1_000_000;
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 2,
|
||||
}).format(dollars);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,6 +73,19 @@ export const getTokenPurchasesResponse = z.object({
|
|||
purchases: z.array(tokenPurchase),
|
||||
});
|
||||
|
||||
// Response from /stripe/finalize-checkout. Either page or token fields
|
||||
// are populated depending on purchase_type.
|
||||
export const finalizeCheckoutResponse = z.object({
|
||||
purchase_type: z.enum(["page_packs", "premium_tokens"]),
|
||||
status: pagePurchaseStatusEnum,
|
||||
pages_limit: z.number().nullable().optional(),
|
||||
pages_used: z.number().nullable().optional(),
|
||||
pages_granted: z.number().nullable().optional(),
|
||||
premium_credit_micros_limit: z.number().nullable().optional(),
|
||||
premium_credit_micros_used: z.number().nullable().optional(),
|
||||
premium_credit_micros_granted: z.number().nullable().optional(),
|
||||
});
|
||||
|
||||
export type PagePurchaseStatus = z.infer<typeof pagePurchaseStatusEnum>;
|
||||
export type CreateCheckoutSessionRequest = z.infer<typeof createCheckoutSessionRequest>;
|
||||
export type CreateCheckoutSessionResponse = z.infer<typeof createCheckoutSessionResponse>;
|
||||
|
|
@ -85,3 +98,4 @@ export type TokenStripeStatusResponse = z.infer<typeof tokenStripeStatusResponse
|
|||
export type TokenPurchaseStatus = z.infer<typeof tokenPurchaseStatusEnum>;
|
||||
export type TokenPurchase = z.infer<typeof tokenPurchase>;
|
||||
export type GetTokenPurchasesResponse = z.infer<typeof getTokenPurchasesResponse>;
|
||||
export type FinalizeCheckoutResponse = z.infer<typeof finalizeCheckoutResponse>;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import {
|
|||
type CreateTokenCheckoutSessionResponse,
|
||||
createCheckoutSessionResponse,
|
||||
createTokenCheckoutSessionResponse,
|
||||
type FinalizeCheckoutResponse,
|
||||
finalizeCheckoutResponse,
|
||||
type GetPagePurchasesResponse,
|
||||
type GetTokenPurchasesResponse,
|
||||
getPagePurchasesResponse,
|
||||
|
|
@ -54,6 +56,20 @@ class StripeApiService {
|
|||
getTokenPurchases = async (): Promise<GetTokenPurchasesResponse> => {
|
||||
return baseApiService.get("/api/v1/stripe/token-purchases", getTokenPurchasesResponse);
|
||||
};
|
||||
|
||||
/**
|
||||
* Synchronously fulfil a checkout session from the success page.
|
||||
*
|
||||
* Solves the race where the user lands on /purchase-success before
|
||||
* Stripe's checkout.session.completed webhook arrives. Idempotent —
|
||||
* safe to call concurrently with the webhook.
|
||||
*/
|
||||
finalizeCheckout = async (sessionId: string): Promise<FinalizeCheckoutResponse> => {
|
||||
return baseApiService.get(
|
||||
`/api/v1/stripe/finalize-checkout?session_id=${encodeURIComponent(sessionId)}`,
|
||||
finalizeCheckoutResponse
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export const stripeApiService = new StripeApiService();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue