Skip to content

Commit 9ea4c84

Browse files
committed
add Stripe payment methods management for teams
1 parent 5371820 commit 9ea4c84

File tree

9 files changed

+1153
-19
lines changed

9 files changed

+1153
-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: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
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 expiryDate = new Date(year, month - 1, 1); // First day of expiry month
15+
const monthsDifference =
16+
(expiryDate.getFullYear() - today.getFullYear()) * 12 +
17+
(expiryDate.getMonth() - today.getMonth());
18+
return monthsDifference >= 0 && monthsDifference <= 2; // Within next 3 months
19+
}
20+
21+
function isExpired(month: number, year: number): boolean {
22+
const today = new Date();
23+
const currentMonth = today.getMonth() + 1; // JavaScript months are 0-indexed
24+
const currentYear = today.getFullYear();
25+
26+
if (year < currentYear || (year === currentYear && month < currentMonth)) {
27+
return true;
28+
}
29+
30+
return false;
31+
}
32+
33+
export function formatPaymentMethodDetails(method: Stripe.PaymentMethod): {
34+
label: string;
35+
expiryInfo?: string;
36+
isExpiringSoon?: boolean;
37+
isExpired?: boolean;
38+
} {
39+
switch (method.type) {
40+
case "card": {
41+
if (!method.card) {
42+
return { label: "Unknown card" };
43+
}
44+
return {
45+
label: `${method.card.brand} ${method.card.funding || ""} •••• ${method.card.last4}`,
46+
expiryInfo: formatExpiryDate(
47+
method.card.exp_month,
48+
method.card.exp_year,
49+
),
50+
isExpiringSoon: isExpiringSoon(
51+
method.card.exp_month,
52+
method.card.exp_year,
53+
),
54+
isExpired: isExpired(method.card.exp_month, method.card.exp_year),
55+
};
56+
}
57+
58+
case "us_bank_account": {
59+
if (!method.us_bank_account) {
60+
return { label: "Unknown bank account" };
61+
}
62+
return {
63+
label: `${method.us_bank_account.bank_name} ${method.us_bank_account.account_type} •••• ${method.us_bank_account.last4}`,
64+
};
65+
}
66+
67+
case "sepa_debit": {
68+
if (!method.sepa_debit) {
69+
return { label: "Unknown SEPA account" };
70+
}
71+
return {
72+
label: `SEPA Direct Debit •••• ${method.sepa_debit.last4}`,
73+
};
74+
}
75+
76+
default:
77+
return {
78+
label: `${method.type.replace("_", " ")}`,
79+
};
80+
}
81+
}

0 commit comments

Comments
 (0)