Skip to content

Commit c52363a

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

File tree

7 files changed

+297
-268
lines changed

7 files changed

+297
-268
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: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { NextApiRequest, NextApiResponse } from "next";
2+
import { stripeServer } from "../../../../utils/stripeServer";
3+
import { createServerSupabaseClient } from "@supabase/auth-helpers-nextjs";
4+
import { Database } from "../../../../db/database.types";
5+
6+
export default async function handler(
7+
req: NextApiRequest,
8+
res: NextApiResponse,
9+
) {
10+
if (req.method !== "GET") {
11+
return res.status(405).json({ error: "Method not allowed" });
12+
}
13+
14+
// Check authentication
15+
const supabase = createServerSupabaseClient<Database>({
16+
req,
17+
res,
18+
});
19+
20+
const {
21+
data: { user },
22+
} = await supabase.auth.getUser();
23+
24+
if (!user) {
25+
return res.status(401).json({ error: "Unauthorized" });
26+
}
27+
28+
try {
29+
const { limit = "10", page } = req.query;
30+
const productId = process.env.STRIPE_CLOUD_GATEWAY_TOKEN_USAGE_PRODUCT;
31+
32+
if (!productId) {
33+
console.error("[Stripe API] STRIPE_CLOUD_GATEWAY_TOKEN_USAGE_PRODUCT not configured");
34+
return res.status(500).json({ error: "Stripe product ID not configured" });
35+
}
36+
37+
const query = `metadata['productId']:'${productId}'`;
38+
39+
// Search payment intents using Stripe API
40+
const searchParams: any = {
41+
query,
42+
limit: parseInt(limit as string, 10),
43+
};
44+
45+
// Add page parameter if provided (Stripe uses page token for search pagination)
46+
if (page) {
47+
searchParams.page = page as string;
48+
}
49+
50+
const paymentIntents = await stripeServer.paymentIntents.search(searchParams);
51+
52+
// Filter to only include succeeded payment intents
53+
const filteredData = paymentIntents.data.filter(
54+
(intent) => intent.status === "succeeded",
55+
);
56+
57+
return res.status(200).json({
58+
data: filteredData,
59+
has_more: paymentIntents.has_more,
60+
next_page: paymentIntents.next_page || null,
61+
count: filteredData.length,
62+
});
63+
} catch (error: any) {
64+
console.error("Error searching payment intents:", error);
65+
return res.status(500).json({
66+
error: "Failed to search payment intents",
67+
message: error.message,
68+
});
69+
}
70+
}

web/pages/credits.tsx

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ import { useFeatureFlag } from "@/services/hooks/admin";
2828
import { FeatureWaitlist } from "@/components/templates/waitlist/FeatureWaitlist";
2929

3030
const Credits: NextPageWithLayout<void> = () => {
31-
const [currentPage, setCurrentPage] = useState(0);
31+
const [currentPageToken, setCurrentPageToken] = useState<string | null>(null);
32+
const [pageTokenHistory, setPageTokenHistory] = useState<string[]>([]);
3233
const [pageSize, setPageSize] = useState(5);
3334
const [isPaymentModalOpen, setIsPaymentModalOpen] = useState(false);
3435

@@ -49,15 +50,14 @@ const Credits: NextPageWithLayout<void> = () => {
4950
refetch: refetchTransactions,
5051
error: transactionsError,
5152
} = useCreditTransactions({
52-
page: currentPage,
53-
pageSize: pageSize,
53+
limit: pageSize,
54+
page: currentPageToken,
5455
});
5556

5657
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;
58+
const hasMore = transactionsData?.hasMore || false;
59+
const hasPrevious = pageTokenHistory.length > 0;
60+
const currentPageNumber = pageTokenHistory.length + 1;
6161

6262
const hasAccess = hasCreditsFeatureFlag?.data;
6363

@@ -330,7 +330,8 @@ const Credits: NextPageWithLayout<void> = () => {
330330
onValueChange={(value) => {
331331
setPageSize(Number(value));
332332
// Reset pagination when changing page size
333-
setCurrentPage(0);
333+
setCurrentPageToken(null);
334+
setPageTokenHistory([]);
334335
}}
335336
>
336337
<SelectTrigger className="h-8 w-[60px]">
@@ -423,23 +424,34 @@ const Credits: NextPageWithLayout<void> = () => {
423424
onClick={() => {
424425
// Go to previous page
425426
if (hasPrevious) {
426-
setCurrentPage(currentPage - 1);
427+
const newHistory = [...pageTokenHistory];
428+
newHistory.pop();
429+
const previousToken =
430+
newHistory.length > 0
431+
? newHistory[newHistory.length - 1]
432+
: null;
433+
setPageTokenHistory(newHistory);
434+
setCurrentPageToken(previousToken);
427435
}
428436
}}
429437
disabled={!hasPrevious}
430438
>
431439
<ChevronLeft className="h-3 w-3" />
432440
</Button>
433441
<Badge variant="secondary" className="text-xs">
434-
Page {currentPage + 1} of {totalPages || 1}
442+
Page {currentPageNumber}
435443
</Badge>
436444
<Button
437445
variant="ghost"
438446
size="sm"
439447
onClick={() => {
440448
// Go to next page
441-
if (hasMore) {
442-
setCurrentPage(currentPage + 1);
449+
if (hasMore && transactionsData?.nextPage) {
450+
setPageTokenHistory([
451+
...pageTokenHistory,
452+
currentPageToken || "",
453+
]);
454+
setCurrentPageToken(transactionsData.nextPage);
443455
}
444456
}}
445457
disabled={!hasMore}

web/services/hooks/useCredits.ts

Lines changed: 63 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useOrg } from "../../components/layout/org/organizationContext";
22
import { $JAWN_API } from "../../lib/clients/jawn";
3+
import { useQuery } from "@tanstack/react-query";
34

45
// Types matching the backend
56
export interface PurchasedCredits {
@@ -12,8 +13,8 @@ export interface PurchasedCredits {
1213
export interface PaginatedPurchasedCredits {
1314
purchases: PurchasedCredits[];
1415
total: number;
15-
page: number;
16-
pageSize: number;
16+
nextPage: string | null;
17+
hasMore: boolean;
1718
}
1819

1920
export interface CreditBalanceResponse {
@@ -24,14 +25,14 @@ export interface CreditBalanceResponse {
2425
// A hook for the user's credit balance in cents
2526
export const useCredits = () => {
2627
const org = useOrg();
27-
28+
2829
const result = $JAWN_API.useQuery(
2930
"get",
3031
"/v1/credits/balance",
3132
{},
3233
{
3334
enabled: !!org?.currentOrg?.id,
34-
}
35+
},
3536
);
3637

3738
return {
@@ -40,36 +41,73 @@ export const useCredits = () => {
4041
};
4142
};
4243

44+
// Helper function to fetch payment intents from Stripe API
45+
const fetchStripePaymentIntents = async (
46+
limit: number,
47+
page?: string | null,
48+
) => {
49+
const queryParams = new URLSearchParams();
50+
queryParams.set("limit", limit.toString());
51+
if (page) {
52+
queryParams.set("page", page);
53+
}
54+
55+
const url = `/api/stripe/payment-intents/search?${queryParams.toString()}`;
56+
const response = await fetch(url);
57+
58+
if (!response.ok) {
59+
const errorText = await response.text();
60+
throw new Error(`Failed to fetch payment intents: ${response.status} ${errorText}`);
61+
}
62+
63+
return response.json();
64+
};
65+
4366
// A hook for fetching credit balance transactions with pagination
4467
export const useCreditTransactions = (params?: {
45-
page?: number;
46-
pageSize?: number;
68+
limit?: number;
69+
page?: string | null;
4770
}) => {
4871
const org = useOrg();
49-
50-
const result = $JAWN_API.useQuery(
51-
"get",
52-
"/v1/credits/payments",
53-
{
54-
params: {
55-
query: {
56-
page: params?.page ?? 0,
57-
pageSize: params?.pageSize ?? 10,
58-
},
59-
},
72+
73+
const result = useQuery({
74+
queryKey: [
75+
"credit-transactions",
76+
org?.currentOrg?.id,
77+
params?.limit,
78+
params?.page,
79+
],
80+
queryFn: async () => {
81+
const data = await fetchStripePaymentIntents(
82+
params?.limit ?? 10,
83+
params?.page,
84+
);
85+
86+
// Transform Stripe payment intents to our format
87+
const purchases: PurchasedCredits[] = data.data.map((intent: any) => ({
88+
id: intent.id,
89+
createdAt: intent.created * 1000, // Convert from seconds to milliseconds
90+
credits: intent.amount, // Amount is in cents
91+
referenceId: intent.id,
92+
}));
93+
94+
return {
95+
purchases,
96+
total: data.count || 0,
97+
nextPage: data.next_page || null,
98+
hasMore: data.has_more || false,
99+
};
60100
},
61-
{
62-
enabled: !!org?.currentOrg?.id,
63-
}
64-
);
101+
enabled: !!org?.currentOrg?.id,
102+
});
65103

66104
return {
67105
...result,
68-
data: result.data?.data || {
106+
data: result.data || {
69107
purchases: [],
70108
total: 0,
71-
page: 0,
72-
pageSize: 10,
109+
nextPage: null,
110+
hasMore: false,
73111
},
74112
};
75113
};
@@ -86,4 +124,4 @@ export const useCreateCheckoutSession = () => {
86124
console.error("Failed to create checkout session:", error);
87125
},
88126
});
89-
};
127+
};

worker/src/lib/durable-objects/Wallet.ts

Lines changed: 0 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -185,44 +185,6 @@ export class Wallet extends DurableObject<Env> {
185185
});
186186
}
187187

188-
getCreditsPurchases(
189-
page?: number,
190-
pageSize?: number,
191-
): PaginatedPurchasedCredits {
192-
return this.ctx.storage.transactionSync(() => {
193-
const pageSizeValue = pageSize ?? 10;
194-
const pageValue = page ?? 0;
195-
const result = this.ctx.storage.sql.exec<{
196-
id: string;
197-
created_at: number;
198-
credits: number;
199-
reference_id: string;
200-
}>(
201-
"SELECT id, created_at, credits, reference_id FROM credit_purchases LIMIT ? OFFSET ?",
202-
pageSizeValue,
203-
pageValue * pageSizeValue
204-
).toArray();
205-
const total = this.ctx.storage.sql.exec<{ total: number }>(
206-
"SELECT COALESCE(COUNT(*), 0) as total FROM credit_purchases"
207-
).one().total;
208-
let purchases: PurchasedCredits[] = [];
209-
if (result.length > 0) {
210-
purchases = result.map((row) => ({
211-
id: row.id,
212-
createdAt: row.created_at,
213-
credits: row.credits / SCALE_FACTOR,
214-
referenceId: row.reference_id,
215-
}));
216-
}
217-
return {
218-
purchases,
219-
total,
220-
page: pageValue,
221-
pageSize: pageSizeValue,
222-
} as PaginatedPurchasedCredits;
223-
});
224-
}
225-
226188
getTotalCreditsPurchased(): TotalCreditsPurchased {
227189
const result = this.ctx.storage.sql.exec<{ totalCredits: number }>(
228190
"SELECT COALESCE(SUM(credits), 0) as totalCredits FROM credit_purchases"

0 commit comments

Comments
 (0)