Skip to content

Commit 86f1a0e

Browse files
committed
add Stripe payment methods management for teams
1 parent 5371820 commit 86f1a0e

File tree

9 files changed

+1162
-19
lines changed

9 files changed

+1162
-19
lines changed

apps/dashboard/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@
4747
"@radix-ui/react-tooltip": "1.1.8",
4848
"@sentry/nextjs": "9.5.0",
4949
"@shazow/whatsabi": "0.20.0",
50+
"@stripe/react-stripe-js": "3.4.0",
51+
"@stripe/stripe-js": "6.1.0",
5052
"@tanstack/react-query": "5.67.3",
5153
"@tanstack/react-table": "^8.21.2",
5254
"@thirdweb-dev/service-utils": "workspace:*",

apps/dashboard/src/@/actions/stripe-actions.ts

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
"use server";
12
import "server-only";
23

34
import Stripe from "stripe";
@@ -58,3 +59,181 @@ export async function getTeamInvoices(
5859
throw new Error("Failed to fetch billing history");
5960
}
6061
}
62+
63+
export async function getTeamPaymentMethods(team: Team) {
64+
try {
65+
const customerId = team.stripeCustomerId;
66+
67+
if (!customerId) {
68+
throw new Error("No customer ID found");
69+
}
70+
71+
const [paymentMethods, customer] = await Promise.all([
72+
// Get all payment methods, not just cards
73+
getStripe().paymentMethods.list({
74+
customer: customerId,
75+
}),
76+
// Get the customer to determine the default payment method
77+
getStripe().customers.retrieve(customerId),
78+
]);
79+
80+
const defaultPaymentMethodId = customer.deleted
81+
? null
82+
: customer.invoice_settings?.default_payment_method;
83+
84+
// Add isDefault flag to each payment method
85+
return paymentMethods.data.map((method) => ({
86+
...method,
87+
isDefault: method.id === defaultPaymentMethodId,
88+
}));
89+
} catch (error) {
90+
console.error("Error fetching payment methods:", error);
91+
throw new Error("Failed to fetch payment methods");
92+
}
93+
}
94+
95+
export async function createSetupIntent(team: Team) {
96+
try {
97+
const customerId = team.stripeCustomerId;
98+
99+
if (!customerId) {
100+
throw new Error("No customer ID found");
101+
}
102+
103+
const setupIntent = await getStripe().setupIntents.create({
104+
customer: customerId,
105+
payment_method_types: ["card"],
106+
});
107+
108+
return {
109+
clientSecret: setupIntent.client_secret,
110+
};
111+
} catch (error) {
112+
console.error("Error creating setup intent:", error);
113+
114+
throw new Error("Failed to create setup intent");
115+
}
116+
}
117+
118+
export async function addPaymentMethod(
119+
team: Team,
120+
paymentMethodId: string,
121+
setAsDefault = false,
122+
) {
123+
try {
124+
const customerId = team.stripeCustomerId;
125+
126+
if (!customerId) {
127+
throw new Error("No customer ID found");
128+
}
129+
130+
// Attach the payment method to the customer
131+
await getStripe().paymentMethods.attach(paymentMethodId, {
132+
customer: customerId,
133+
});
134+
135+
// Create a $5 payment intent to validate the card
136+
const paymentIntent = await getStripe().paymentIntents.create({
137+
amount: 500, // $5.00 in cents
138+
currency: "usd",
139+
customer: customerId,
140+
payment_method: paymentMethodId,
141+
capture_method: "manual", // Authorize only, don't capture
142+
confirm: true, // Confirm the payment immediately
143+
description: "Card validation - temporary hold",
144+
metadata: {
145+
purpose: "card_validation",
146+
},
147+
off_session: true, // Since this is a server-side operation
148+
});
149+
150+
// If the payment intent succeeded, cancel it to release the hold
151+
if (paymentIntent.status === "requires_capture") {
152+
await getStripe().paymentIntents.cancel(paymentIntent.id, {
153+
cancellation_reason: "requested_by_customer",
154+
});
155+
console.log(
156+
`Successfully validated card ${paymentMethodId} with temporary hold`,
157+
);
158+
} else {
159+
// If the payment intent didn't succeed, detach the payment method
160+
await getStripe().paymentMethods.detach(paymentMethodId);
161+
throw new Error(`Card validation failed: ${paymentIntent.status}`);
162+
}
163+
164+
// If setAsDefault is true, update the customer's default payment method
165+
if (setAsDefault) {
166+
await getStripe().customers.update(customerId, {
167+
invoice_settings: {
168+
default_payment_method: paymentMethodId,
169+
},
170+
});
171+
}
172+
173+
return { success: true };
174+
} catch (error) {
175+
console.error("Error adding payment method:", error);
176+
177+
// Try to detach the payment method if it was attached
178+
try {
179+
if (paymentMethodId) {
180+
await getStripe().paymentMethods.detach(paymentMethodId);
181+
}
182+
} catch (detachError) {
183+
console.error(
184+
"Error detaching payment method after validation failure:",
185+
detachError,
186+
);
187+
}
188+
189+
// Determine the error message to return
190+
let errorMessage = "Failed to add payment method";
191+
192+
if (error instanceof Stripe.errors.StripeCardError) {
193+
errorMessage = error.message || "Your card was declined";
194+
} else if (error instanceof Stripe.errors.StripeInvalidRequestError) {
195+
errorMessage = "Invalid card information";
196+
} else if (error instanceof Error) {
197+
errorMessage = error.message;
198+
}
199+
200+
throw new Error(errorMessage);
201+
}
202+
}
203+
204+
export async function deletePaymentMethod(paymentMethodId: string) {
205+
try {
206+
// Detach the payment method from the customer
207+
await getStripe().paymentMethods.detach(paymentMethodId);
208+
209+
return { success: true };
210+
} catch (error) {
211+
console.error("Error deleting payment method:", error);
212+
throw new Error("Failed to delete payment method");
213+
}
214+
}
215+
216+
export async function setDefaultPaymentMethod(
217+
team: Team,
218+
paymentMethodId: string,
219+
) {
220+
try {
221+
const customerId = team.stripeCustomerId;
222+
223+
if (!customerId) {
224+
throw new Error("No customer ID found");
225+
}
226+
227+
// Update the customer's default payment method
228+
await getStripe().customers.update(customerId, {
229+
invoice_settings: {
230+
default_payment_method: paymentMethodId,
231+
},
232+
});
233+
234+
return { success: true };
235+
} catch (error) {
236+
console.error("Error setting default payment method:", error);
237+
throw new Error("Failed to set default payment method");
238+
}
239+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import type Stripe from "stripe";
2+
3+
export type ExtendedPaymentMethod = Stripe.PaymentMethod & {
4+
isDefault: boolean;
5+
};
6+
7+
function formatExpiryDate(month: number, year: number): string {
8+
// Format as "Valid until MM/YYYY"
9+
return `Valid until ${month}/${year}`;
10+
}
11+
12+
function isExpiringSoon(month: number, year: number): boolean {
13+
const today = new Date();
14+
const currentMonth = today.getMonth() + 1; // JavaScript months are 0-indexed
15+
const currentYear = today.getFullYear();
16+
17+
// Card expires this month or next month
18+
if (
19+
year === currentYear &&
20+
(month === currentMonth || month === currentMonth + 1)
21+
) {
22+
return true;
23+
}
24+
25+
return false;
26+
}
27+
28+
function isExpired(month: number, year: number): boolean {
29+
const today = new Date();
30+
const currentMonth = today.getMonth() + 1; // JavaScript months are 0-indexed
31+
const currentYear = today.getFullYear();
32+
33+
if (year < currentYear || (year === currentYear && month < currentMonth)) {
34+
return true;
35+
}
36+
37+
return false;
38+
}
39+
40+
export function formatPaymentMethodDetails(method: Stripe.PaymentMethod): {
41+
label: string;
42+
expiryInfo?: string;
43+
isExpiringSoon?: boolean;
44+
isExpired?: boolean;
45+
} {
46+
switch (method.type) {
47+
case "card": {
48+
if (!method.card) {
49+
return { label: "Unknown card" };
50+
}
51+
return {
52+
label: `${method.card.brand} ${method.card.funding || ""} •••• ${method.card.last4}`,
53+
expiryInfo: formatExpiryDate(
54+
method.card.exp_month,
55+
method.card.exp_year,
56+
),
57+
isExpiringSoon: isExpiringSoon(
58+
method.card.exp_month,
59+
method.card.exp_year,
60+
),
61+
isExpired: isExpired(method.card.exp_month, method.card.exp_year),
62+
};
63+
}
64+
65+
case "us_bank_account": {
66+
if (!method.us_bank_account) {
67+
return { label: "Unknown bank account" };
68+
}
69+
return {
70+
label: `${method.us_bank_account.bank_name} ${method.us_bank_account.account_type} •••• ${method.us_bank_account.last4}`,
71+
};
72+
}
73+
74+
case "sepa_debit": {
75+
if (!method.sepa_debit) {
76+
return { label: "Unknown SEPA account" };
77+
}
78+
return {
79+
label: `SEPA Direct Debit •••• ${method.sepa_debit.last4}`,
80+
};
81+
}
82+
83+
default:
84+
return {
85+
label: `${method.type.replace("_", " ")}`,
86+
};
87+
}
88+
}

0 commit comments

Comments
 (0)