Skip to content

Commit 0db9890

Browse files
committed
task: Use Stripe PaymentIntents API for credit history
1 parent a8594e0 commit 0db9890

File tree

7 files changed

+400
-295
lines changed

7 files changed

+400
-295
lines changed

web/.env.example

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,8 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJz
1919
NEXT_PUBLIC_SUPABASE_URL="http://localhost:54321"
2020
SUPABASE_SERVICE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU"
2121
SUPABASE_URL="http://localhost:54321"
22-
NEXT_PUBLIC_BETTER_AUTH="false"
22+
NEXT_PUBLIC_BETTER_AUTH="false"
23+
24+
# only required for pass through billing development
25+
STRIPE_CLOUD_GATEWAY_TOKEN_USAGE_PRODUCT="prod_"
26+
STRIPE_SECRET_KEY=""
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import {
2+
HandlerWrapperOptions,
3+
withAuth,
4+
} from "../../../../lib/api/handlerWrappers";
5+
import { stripeServer } from "../../../../utils/stripeServer";
6+
import { Result } from "@/packages/common/result";
7+
8+
interface StripePaymentIntentsResponse {
9+
data: any[];
10+
has_more: boolean;
11+
next_page: string | null;
12+
count: number;
13+
}
14+
15+
async function handler({
16+
req,
17+
res,
18+
userData: { orgId },
19+
}: HandlerWrapperOptions<Result<StripePaymentIntentsResponse, string>>) {
20+
if (req.method !== "GET") {
21+
return res.status(405).json({
22+
error: "Method not allowed",
23+
data: null,
24+
});
25+
}
26+
27+
try {
28+
const { limit = "10", page } = req.query;
29+
const productId = process.env.STRIPE_CLOUD_GATEWAY_TOKEN_USAGE_PRODUCT;
30+
31+
if (!productId) {
32+
console.error(
33+
"[Stripe API] STRIPE_CLOUD_GATEWAY_TOKEN_USAGE_PRODUCT not configured",
34+
);
35+
return res.status(500).json({
36+
error: "Stripe product ID not configured",
37+
data: null,
38+
});
39+
}
40+
41+
// Include orgId in the metadata query
42+
const query = `metadata['productId']:'${productId}' AND metadata['orgId']:'${orgId}'`;
43+
44+
// Search payment intents using Stripe API
45+
const searchParams: any = {
46+
query,
47+
limit: parseInt(limit as string, 10),
48+
};
49+
50+
// Add page parameter if provided (Stripe uses page token for search pagination)
51+
if (page) {
52+
searchParams.page = page as string;
53+
}
54+
55+
const paymentIntents =
56+
await stripeServer.paymentIntents.search(searchParams);
57+
58+
return res.status(200).json({
59+
error: null,
60+
data: {
61+
data: paymentIntents.data,
62+
has_more: paymentIntents.has_more,
63+
next_page: paymentIntents.next_page || null,
64+
count: paymentIntents.data.length,
65+
},
66+
});
67+
} catch (error: any) {
68+
console.error("Error searching payment intents:", error);
69+
return res.status(500).json({
70+
error: "Failed to search payment intents",
71+
data: null,
72+
});
73+
}
74+
}
75+
76+
export default withAuth(handler);

web/pages/credits.tsx

Lines changed: 116 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,15 @@ import {
1818
type PurchasedCredits,
1919
} from "@/services/hooks/useCredits";
2020
import { formatDate } from "@/utils/date";
21-
import { ChevronLeft, ChevronRight, RefreshCcw } from "lucide-react";
21+
import {
22+
ChevronLeft,
23+
ChevronRight,
24+
RefreshCcw,
25+
AlertCircle,
26+
Clock,
27+
CheckCircle,
28+
XCircle,
29+
} from "lucide-react";
2230
import Link from "next/link";
2331
import Image from "next/image";
2432
import { ReactElement, useState } from "react";
@@ -28,7 +36,8 @@ import { useFeatureFlag } from "@/services/hooks/admin";
2836
import { FeatureWaitlist } from "@/components/templates/waitlist/FeatureWaitlist";
2937

3038
const Credits: NextPageWithLayout<void> = () => {
31-
const [currentPage, setCurrentPage] = useState(0);
39+
const [currentPageToken, setCurrentPageToken] = useState<string | null>(null);
40+
const [pageTokenHistory, setPageTokenHistory] = useState<string[]>([]);
3241
const [pageSize, setPageSize] = useState(5);
3342
const [isPaymentModalOpen, setIsPaymentModalOpen] = useState(false);
3443

@@ -49,15 +58,14 @@ const Credits: NextPageWithLayout<void> = () => {
4958
refetch: refetchTransactions,
5059
error: transactionsError,
5160
} = useCreditTransactions({
52-
page: currentPage,
53-
pageSize: pageSize,
61+
limit: pageSize,
62+
page: currentPageToken,
5463
});
5564

5665
const transactions = transactionsData?.purchases || [];
57-
const totalTransactions = transactionsData?.total || 0;
58-
const totalPages = Math.ceil(totalTransactions / pageSize);
59-
const hasMore = currentPage < totalPages - 1;
60-
const hasPrevious = currentPage > 0;
66+
const hasMore = transactionsData?.hasMore || false;
67+
const hasPrevious = pageTokenHistory.length > 0;
68+
const currentPageNumber = pageTokenHistory.length + 1;
6169

6270
const hasAccess = hasCreditsFeatureFlag?.data;
6371

@@ -330,7 +338,8 @@ const Credits: NextPageWithLayout<void> = () => {
330338
onValueChange={(value) => {
331339
setPageSize(Number(value));
332340
// Reset pagination when changing page size
333-
setCurrentPage(0);
341+
setCurrentPageToken(null);
342+
setPageTokenHistory([]);
334343
}}
335344
>
336345
<SelectTrigger className="h-8 w-[60px]">
@@ -367,41 +376,98 @@ const Credits: NextPageWithLayout<void> = () => {
367376
const created = new Date(transaction.createdAt);
368377
const createdStr = created.toISOString();
369378

370-
// All transactions are credit purchases for now
371-
const description = "Credit purchase";
372-
const isCredit = true; // All are credits being added
379+
// Get status from transaction
380+
const status = transaction.status;
381+
382+
// Determine status display properties
383+
const getStatusDisplay = () => {
384+
switch (status) {
385+
case "succeeded":
386+
return {
387+
label: "Completed",
388+
icon: CheckCircle,
389+
className:
390+
"text-green-600 dark:text-green-500",
391+
showAmount: true,
392+
};
393+
case "processing":
394+
return {
395+
label: "Processing",
396+
icon: Clock,
397+
className:
398+
"text-blue-600 dark:text-blue-500",
399+
showAmount: true,
400+
};
401+
case "canceled":
402+
return {
403+
label: "Canceled",
404+
icon: XCircle,
405+
className: "text-muted-foreground",
406+
showAmount: false,
407+
};
408+
case "requires_action":
409+
case "requires_capture":
410+
case "requires_confirmation":
411+
case "requires_payment_method":
412+
return {
413+
label: "Action Required",
414+
icon: AlertCircle,
415+
className:
416+
"text-amber-600 dark:text-amber-500",
417+
showAmount: true,
418+
};
419+
default:
420+
return {
421+
label: "Credit purchase",
422+
icon: CheckCircle,
423+
className:
424+
"text-green-600 dark:text-green-500",
425+
showAmount: true,
426+
};
427+
}
428+
};
429+
430+
const statusDisplay = getStatusDisplay();
431+
const StatusIcon = statusDisplay.icon;
373432

374433
return (
375434
<div
376435
key={transaction.id || index}
377436
className="flex items-center justify-between border-b border-border py-4 last:border-b-0"
378437
>
379-
<div className="flex flex-col gap-1">
438+
<div className="flex items-start gap-3">
439+
<StatusIcon
440+
className={`mt-0.5 h-4 w-4 ${statusDisplay.className}`}
441+
/>
442+
<div className="flex flex-col gap-1">
443+
<div
444+
className="text-sm"
445+
title={created.toLocaleString("en-US", {
446+
month: "short",
447+
day: "numeric",
448+
year: "numeric",
449+
hour: "2-digit",
450+
minute: "2-digit",
451+
})}
452+
>
453+
{formatDate(createdStr)}
454+
</div>
455+
<XSmall className="text-muted-foreground">
456+
{statusDisplay.label}
457+
</XSmall>
458+
</div>
459+
</div>
460+
{statusDisplay.showAmount && (
380461
<div
381-
className="text-sm"
382-
title={created.toLocaleString("en-US", {
383-
month: "short",
384-
day: "numeric",
385-
year: "numeric",
386-
hour: "2-digit",
387-
minute: "2-digit",
388-
})}
462+
className={`text-sm font-medium ${statusDisplay.className}`}
389463
>
390-
{formatDate(createdStr)}
464+
{status === "succeeded" ? "+" : ""}
465+
{(amount / 100).toLocaleString("en-US", {
466+
style: "currency",
467+
currency: "usd",
468+
})}
391469
</div>
392-
<XSmall className="text-muted-foreground">
393-
{description}
394-
</XSmall>
395-
</div>
396-
<div
397-
className={`text-sm font-medium ${isCredit ? "text-green-600 dark:text-green-500" : "text-muted-foreground"}`}
398-
>
399-
{isCredit ? "+" : "-"}
400-
{(amount / 100).toLocaleString("en-US", {
401-
style: "currency",
402-
currency: "usd",
403-
})}
404-
</div>
470+
)}
405471
</div>
406472
);
407473
},
@@ -423,23 +489,34 @@ const Credits: NextPageWithLayout<void> = () => {
423489
onClick={() => {
424490
// Go to previous page
425491
if (hasPrevious) {
426-
setCurrentPage(currentPage - 1);
492+
const newHistory = [...pageTokenHistory];
493+
newHistory.pop();
494+
const previousToken =
495+
newHistory.length > 0
496+
? newHistory[newHistory.length - 1]
497+
: null;
498+
setPageTokenHistory(newHistory);
499+
setCurrentPageToken(previousToken);
427500
}
428501
}}
429502
disabled={!hasPrevious}
430503
>
431504
<ChevronLeft className="h-3 w-3" />
432505
</Button>
433506
<Badge variant="secondary" className="text-xs">
434-
Page {currentPage + 1} of {totalPages || 1}
507+
Page {currentPageNumber}
435508
</Badge>
436509
<Button
437510
variant="ghost"
438511
size="sm"
439512
onClick={() => {
440513
// Go to next page
441-
if (hasMore) {
442-
setCurrentPage(currentPage + 1);
514+
if (hasMore && transactionsData?.nextPage) {
515+
setPageTokenHistory([
516+
...pageTokenHistory,
517+
currentPageToken || "",
518+
]);
519+
setCurrentPageToken(transactionsData.nextPage);
443520
}
444521
}}
445522
disabled={!hasMore}

0 commit comments

Comments
 (0)