Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
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
115 changes: 115 additions & 0 deletions apps/web/app/(ee)/api/cron/payouts/payout-failed/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { handleAndReturnErrorResponse } from "@/lib/api/errors";
import { verifyQstashSignature } from "@/lib/cron/verify-qstash";
import { stripe } from "@/lib/stripe";
import { sendEmail } from "@dub/email";
import PartnerPayoutWithdrawalFailed from "@dub/email/templates/partner-payout-withdrawal-failed";
import { prisma } from "@dub/prisma";
import { log, pluralize, prettyPrint } from "@dub/utils";
import { z } from "zod";
import { logAndRespond } from "../../utils";

const payloadSchema = z.object({
stripeAccount: z.string(),
stripePayout: z.object({
id: z.string(),
amount: z.number(),
currency: z.string(),
failureMessage: z.string().nullable(),
}),
});

// POST /api/cron/payouts/payout-failed
export async function POST(req: Request) {
try {
const rawBody = await req.text();
await verifyQstashSignature({ req, rawBody });

const { stripeAccount, stripePayout } = payloadSchema.parse(
JSON.parse(rawBody),
);

const partner = await prisma.partner.findUnique({
where: {
stripeConnectId: stripeAccount,
},
select: {
email: true,
},
});

if (!partner) {
return logAndRespond(
`Partner not found with Stripe connect account ${stripeAccount}. Skipping...`,
);
}

const updatedPayouts = await prisma.payout.updateMany({
where: {
stripePayoutId: stripePayout.id,
},
data: {
status: "failed",
failureReason: stripePayout.failureMessage,
},
});

if (partner.email) {
try {
// Fetch bank account information
const { data: externalAccounts } =
await stripe.accounts.listExternalAccounts(stripeAccount);

const defaultExternalAccount = externalAccounts.find(
(account) =>
account.default_for_currency && account.object === "bank_account",
);

const bankAccount =
defaultExternalAccount &&
defaultExternalAccount.object === "bank_account"
? {
account_holder_name: defaultExternalAccount.account_holder_name,
bank_name: defaultExternalAccount.bank_name,
last4: defaultExternalAccount.last4,
routing_number: defaultExternalAccount.routing_number,
}
: undefined;

const sentEmail = await sendEmail({
variant: "notifications",
subject: `[Action Required]: Your recent Dub auto-withdrawal failed`,
to: partner.email,
react: PartnerPayoutWithdrawalFailed({
email: partner.email,
bankAccount,
payout: {
amount: stripePayout.amount,
currency: stripePayout.currency,
failureReason: stripePayout.failureMessage,
},
}),
});

console.log(
`Sent email to partner ${partner.email} (${stripeAccount}): ${prettyPrint(sentEmail)}`,
);
} catch (error) {
console.error(
`Failed to send payout failed email to ${partner.email}:`,
error,
);
}
}

return logAndRespond(
`Updated ${updatedPayouts.count} ${pluralize("payout", updatedPayouts.count)} for partner ${partner.email} (${stripeAccount}) to "failed" status.`,
);
} catch (error) {
await log({
message: `Error handling "payout.failed" ${error.message}.`,
type: "errors",
});

return handleAndReturnErrorResponse(error);
}
}
7 changes: 3 additions & 4 deletions apps/web/app/(ee)/api/cron/payouts/payout-paid/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { verifyQstashSignature } from "@/lib/cron/verify-qstash";
import { sendEmail } from "@dub/email";
import PartnerPayoutWithdrawalCompleted from "@dub/email/templates/partner-payout-withdrawal-completed";
import { prisma } from "@dub/prisma";
import { currencyFormatter, log } from "@dub/utils";
import { currencyFormatter, log, pluralize, prettyPrint } from "@dub/utils";
import { z } from "zod";
import { logAndRespond } from "../../utils";

Expand Down Expand Up @@ -45,7 +45,6 @@ export async function POST(req: Request) {

const updatedPayouts = await prisma.payout.updateMany({
where: {
status: "sent",
stripePayoutId: stripePayout.id,
},
data: {
Expand All @@ -71,12 +70,12 @@ export async function POST(req: Request) {
});

console.log(
`Sent email to partner ${partner.email} (${stripeAccount}): ${JSON.stringify(sentEmail, null, 2)}`,
`Sent email to partner ${partner.email} (${stripeAccount}): ${prettyPrint(sentEmail)}`,
);
}

return logAndRespond(
`Updated ${updatedPayouts.count} payouts for partner ${partner.email} (${stripeAccount}) to "completed" status.`,
`Updated ${updatedPayouts.count} ${pluralize("payout", updatedPayouts.count)} for partner ${partner.email} (${stripeAccount}) to "completed" status.`,
);
} catch (error) {
await log({
Expand Down
34 changes: 34 additions & 0 deletions apps/web/app/(ee)/api/stripe/connect/webhook/payout-failed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { qstash } from "@/lib/cron";
import { APP_DOMAIN_WITH_NGROK } from "@dub/utils";
import Stripe from "stripe";

const queue = qstash.queue({
queueName: "handle-payout-failed",
});

export async function payoutFailed(event: Stripe.Event) {
const stripeAccount = event.account;

if (!stripeAccount) {
return "No stripeConnectId found in event. Skipping...";
}

const stripePayout = event.data.object as Stripe.Payout;

const response = await queue.enqueueJSON({
url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/payout-failed`,
deduplicationId: event.id,
method: "POST",
body: {
stripeAccount,
stripePayout: {
id: stripePayout.id,
amount: stripePayout.amount,
currency: stripePayout.currency,
failureMessage: stripePayout.failure_message,
},
},
});

return `Enqueued payout failed for partner ${stripeAccount}: ${response.messageId}`;
}
5 changes: 5 additions & 0 deletions apps/web/app/(ee)/api/stripe/connect/webhook/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import Stripe from "stripe";
import { accountApplicationDeauthorized } from "./account-application-deauthorized";
import { accountUpdated } from "./account-updated";
import { balanceAvailable } from "./balance-available";
import { payoutFailed } from "./payout-failed";
import { payoutPaid } from "./payout-paid";

const relevantEvents = new Set([
"account.application.deauthorized",
"account.updated",
"balance.available",
"payout.paid",
"payout.failed",
]);

// POST /api/stripe/connect/webhook – listen to Stripe Connect webhooks (for connected accounts)
Expand Down Expand Up @@ -53,6 +55,9 @@ export const POST = async (req: Request) => {
case "payout.paid":
response = await payoutPaid(event);
break;
case "payout.failed":
response = await payoutFailed(event);
break;
}
} catch (error) {
await log({
Expand Down
169 changes: 169 additions & 0 deletions packages/email/src/templates/partner-payout-withdrawal-failed.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { currencyFormatter, DUB_WORDMARK } from "@dub/utils";
import {
Body,
Column,
Container,
Head,
Heading,
Html,
Img,
Link,
Preview,
Row,
Section,
Tailwind,
Text,
} from "@react-email/components";
import { Footer } from "../components/footer";

// Send this email after payout.failed webhook is received
export default function PartnerPayoutWithdrawalFailed({
email = "[email protected]",
payout = {
amount: 530000,
currency: "usd",
failureReason:
"Your bank notified us that the bank account holder tax ID on file is incorrect.",
},
bankAccount = {
account_holder_name: "Brendon Urie",
bank_name: "BANK OF AMERICA, N.A.",
last4: "1234",
routing_number: "1234567890",
},
}: {
email: string;
payout: {
amount: number; // in cents
currency: string;
failureReason?: string | null;
};
bankAccount?: {
account_holder_name: string | null;
bank_name: string | null;
last4: string;
routing_number: string | null;
};
}) {
const amountFormatted = currencyFormatter(payout.amount, {
currency: payout.currency,
});

return (
<Html>
<Head />
<Preview>
Please update your bank account details to receive payouts.
</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-white font-sans">
<Container className="mx-auto my-8 max-w-[600px] px-8 py-8">
<Section className="mt-8">
<Img src={DUB_WORDMARK} height="32" alt="Dub" />
</Section>

<Heading className="mx-0 my-8 p-0 text-lg font-medium text-black">
Your recent auto-withdrawal failed
</Heading>

<Text>
We attempted to transfer{" "}
<span className="font-semibold text-purple-600">
{amountFormatted}
</span>{" "}
from your Stripe Express account to your connected bank account,
but the transaction failed.
</Text>

{payout.failureReason && (
<Text className="text-sm leading-6 text-neutral-600">
Reason:{" "}
<span className="font-semibold italic text-neutral-800">
{payout.failureReason}
</span>
</Text>
)}

{bankAccount && (
<Section className="my-6 rounded-lg border border-solid border-neutral-200 bg-neutral-50 p-4 pt-0">
<Text className="mb-3 text-sm font-semibold text-neutral-800">
Current bank account
</Text>

{bankAccount.account_holder_name && (
<Row className="mb-2">
<Column className="text-sm text-neutral-600">
Account Holder
</Column>
<Column className="text-right text-sm font-medium text-neutral-800">
{bankAccount.account_holder_name}
</Column>
</Row>
)}

{bankAccount.bank_name && (
<Row className="mb-2">
<Column className="text-sm text-neutral-600">
Bank Name
</Column>
<Column className="text-right text-sm font-medium text-neutral-800">
{bankAccount.bank_name}
</Column>
</Row>
)}

<Row className="mb-2">
<Column className="text-sm text-neutral-600">
Account Number
</Column>
<Column className="text-right text-sm font-medium text-neutral-800">
•••• {bankAccount.last4}
</Column>
</Row>

{bankAccount.routing_number && (
<Row>
<Column className="text-sm text-neutral-600">
Routing Number
</Column>
<Column className="text-right text-sm font-medium text-neutral-800">
{bankAccount.routing_number}
</Column>
</Row>
)}
</Section>
)}

<Text>
Please update your bank account details as soon as possible. A
failed payout is automatically retried, so having accurate bank
details on file ensures your payout can be successfully deposited.
</Text>

<Section className="my-8">
<Link
className="rounded-lg bg-neutral-900 px-6 py-3 text-[13px] font-medium text-white no-underline"
href="https://partners.dub.co/payouts"
>
Update bank account
</Link>
</Section>

<Text className="text-sm leading-6 text-neutral-600">
If you have any questions, please{" "}
<Link
href="https://dub.co/contact/support"
className="font-medium text-black underline"
>
reach out to support
</Link>
.
</Text>

<Footer email={email} />
</Container>
</Body>
</Tailwind>
</Html>
);
}