Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@
"@radix-ui/react-tooltip": "1.1.8",
"@sentry/nextjs": "9.5.0",
"@shazow/whatsabi": "0.20.0",
"@stripe/react-stripe-js": "3.4.0",
"@stripe/stripe-js": "6.1.0",
"@tanstack/react-query": "5.67.3",
"@tanstack/react-table": "^8.21.2",
"@thirdweb-dev/service-utils": "workspace:*",
Expand Down
179 changes: 179 additions & 0 deletions apps/dashboard/src/@/actions/stripe-actions.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
"use server";
import "server-only";

import Stripe from "stripe";
Expand Down Expand Up @@ -58,3 +59,181 @@ export async function getTeamInvoices(
throw new Error("Failed to fetch billing history");
}
}

export async function getTeamPaymentMethods(team: Team) {
try {
const customerId = team.stripeCustomerId;

if (!customerId) {
throw new Error("No customer ID found");
}

const [paymentMethods, customer] = await Promise.all([
// Get all payment methods, not just cards
getStripe().paymentMethods.list({
customer: customerId,
}),
// Get the customer to determine the default payment method
getStripe().customers.retrieve(customerId),
]);

const defaultPaymentMethodId = customer.deleted
? null
: customer.invoice_settings?.default_payment_method;

// Add isDefault flag to each payment method
return paymentMethods.data.map((method) => ({
...method,
isDefault: method.id === defaultPaymentMethodId,
}));
} catch (error) {
console.error("Error fetching payment methods:", error);
throw new Error("Failed to fetch payment methods");
}
}

export async function createSetupIntent(team: Team) {
try {
const customerId = team.stripeCustomerId;

if (!customerId) {
throw new Error("No customer ID found");
}

const setupIntent = await getStripe().setupIntents.create({
customer: customerId,
payment_method_types: ["card"],
});

return {
clientSecret: setupIntent.client_secret,
};
} catch (error) {
console.error("Error creating setup intent:", error);

throw new Error("Failed to create setup intent");
}
}

export async function addPaymentMethod(
team: Team,
paymentMethodId: string,
setAsDefault = false,
) {
try {
const customerId = team.stripeCustomerId;

if (!customerId) {
throw new Error("No customer ID found");
}

// Attach the payment method to the customer
await getStripe().paymentMethods.attach(paymentMethodId, {
customer: customerId,
});

// Create a $5 payment intent to validate the card
const paymentIntent = await getStripe().paymentIntents.create({
amount: 500, // $5.00 in cents
currency: "usd",
customer: customerId,
payment_method: paymentMethodId,
capture_method: "manual", // Authorize only, don't capture
confirm: true, // Confirm the payment immediately
description: "Card validation - temporary hold",
metadata: {
purpose: "card_validation",
},
off_session: true, // Since this is a server-side operation
});

// If the payment intent succeeded, cancel it to release the hold
if (paymentIntent.status === "requires_capture") {
await getStripe().paymentIntents.cancel(paymentIntent.id, {
cancellation_reason: "requested_by_customer",
});
console.log(
`Successfully validated card ${paymentMethodId} with temporary hold`,
);
} else {
// If the payment intent didn't succeed, detach the payment method
await getStripe().paymentMethods.detach(paymentMethodId);
throw new Error(`Card validation failed: ${paymentIntent.status}`);
}

// If setAsDefault is true, update the customer's default payment method
if (setAsDefault) {
await getStripe().customers.update(customerId, {
invoice_settings: {
default_payment_method: paymentMethodId,
},
});
}

return { success: true };
} catch (error) {
console.error("Error adding payment method:", error);

// Try to detach the payment method if it was attached
try {
if (paymentMethodId) {
await getStripe().paymentMethods.detach(paymentMethodId);
}
} catch (detachError) {
console.error(
"Error detaching payment method after validation failure:",
detachError,
);
}

// Determine the error message to return
let errorMessage = "Failed to add payment method";

if (error instanceof Stripe.errors.StripeCardError) {
errorMessage = error.message || "Your card was declined";
} else if (error instanceof Stripe.errors.StripeInvalidRequestError) {
errorMessage = "Invalid card information";
} else if (error instanceof Error) {
errorMessage = error.message;
}

throw new Error(errorMessage);
}
}

export async function deletePaymentMethod(paymentMethodId: string) {
try {
// Detach the payment method from the customer
await getStripe().paymentMethods.detach(paymentMethodId);

return { success: true };
} catch (error) {
console.error("Error deleting payment method:", error);
throw new Error("Failed to delete payment method");
}
}

export async function setDefaultPaymentMethod(
team: Team,
paymentMethodId: string,
) {
try {
const customerId = team.stripeCustomerId;

if (!customerId) {
throw new Error("No customer ID found");
}

// Update the customer's default payment method
await getStripe().customers.update(customerId, {
invoice_settings: {
default_payment_method: paymentMethodId,
},
});

return { success: true };
} catch (error) {
console.error("Error setting default payment method:", error);
throw new Error("Failed to set default payment method");
}
}
81 changes: 81 additions & 0 deletions apps/dashboard/src/@/lib/payment-methods.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import type Stripe from "stripe";

export type ExtendedPaymentMethod = Stripe.PaymentMethod & {
isDefault: boolean;
};

function formatExpiryDate(month: number, year: number): string {
// Format as "Valid until MM/YYYY"
return `Valid until ${month}/${year}`;
}

function isExpiringSoon(month: number, year: number): boolean {
const today = new Date();
const expiryDate = new Date(year, month - 1, 1); // First day of expiry month
const monthsDifference =
(expiryDate.getFullYear() - today.getFullYear()) * 12 +
(expiryDate.getMonth() - today.getMonth());
return monthsDifference >= 0 && monthsDifference <= 2; // Within next 3 months
}

function isExpired(month: number, year: number): boolean {
const today = new Date();
const currentMonth = today.getMonth() + 1; // JavaScript months are 0-indexed
const currentYear = today.getFullYear();

if (year < currentYear || (year === currentYear && month < currentMonth)) {
return true;
}

return false;
}

export function formatPaymentMethodDetails(method: Stripe.PaymentMethod): {
label: string;
expiryInfo?: string;
isExpiringSoon?: boolean;
isExpired?: boolean;
} {
switch (method.type) {
case "card": {
if (!method.card) {
return { label: "Unknown card" };
}
return {
label: `${method.card.brand} ${method.card.funding || ""} •••• ${method.card.last4}`,
expiryInfo: formatExpiryDate(
method.card.exp_month,
method.card.exp_year,
),
isExpiringSoon: isExpiringSoon(
method.card.exp_month,
method.card.exp_year,
),
isExpired: isExpired(method.card.exp_month, method.card.exp_year),
};
}

case "us_bank_account": {
if (!method.us_bank_account) {
return { label: "Unknown bank account" };
}
return {
label: `${method.us_bank_account.bank_name} ${method.us_bank_account.account_type} •••• ${method.us_bank_account.last4}`,
};
}

case "sepa_debit": {
if (!method.sepa_debit) {
return { label: "Unknown SEPA account" };
}
return {
label: `SEPA Direct Debit •••• ${method.sepa_debit.last4}`,
};
}

default:
return {
label: `${method.type.replace("_", " ")}`,
};
}
}
Loading