diff --git a/apps/web/app/(ee)/api/stripe/connect/v2/webhook/outbound-payment-failed.ts b/apps/web/app/(ee)/api/stripe/connect/v2/webhook/outbound-payment-failed.ts index 2f683c4ed0e..ebfdf1792e9 100644 --- a/apps/web/app/(ee)/api/stripe/connect/v2/webhook/outbound-payment-failed.ts +++ b/apps/web/app/(ee)/api/stripe/connect/v2/webhook/outbound-payment-failed.ts @@ -3,6 +3,7 @@ import { getStripeOutboundPayment } from "@/lib/stripe/get-stripe-outbound-payme import { OUTBOUND_PAYMENT_FAILURE_REASONS } from "@/lib/stripe/stripe-v2-schemas"; import { pluralize } from "@dub/utils"; import Stripe from "stripe"; +import { notifyPartnerStablecoinPayoutFailed } from "./utils/notify-partner-stablecoin-payout-failed"; export async function outboundPaymentFailed(event: Stripe.ThinEvent) { const { related_object: relatedObject } = event; @@ -30,8 +31,7 @@ export async function outboundPaymentFailed(event: Stripe.ThinEvent) { }, }); - // TODO: - // Send email notification + await notifyPartnerStablecoinPayoutFailed(outboundPaymentId); return `Updated ${updatedPayouts.count} ${pluralize("payout", updatedPayouts.count)} to failed status.`; } diff --git a/apps/web/app/(ee)/api/stripe/connect/v2/webhook/outbound-payment-returned.ts b/apps/web/app/(ee)/api/stripe/connect/v2/webhook/outbound-payment-returned.ts index cf14efa3e00..1d0edb66181 100644 --- a/apps/web/app/(ee)/api/stripe/connect/v2/webhook/outbound-payment-returned.ts +++ b/apps/web/app/(ee)/api/stripe/connect/v2/webhook/outbound-payment-returned.ts @@ -3,6 +3,7 @@ import { getStripeOutboundPayment } from "@/lib/stripe/get-stripe-outbound-payme import { OUTBOUND_PAYMENT_RETURNED_REASONS } from "@/lib/stripe/stripe-v2-schemas"; import { pluralize } from "@dub/utils"; import Stripe from "stripe"; +import { notifyPartnerStablecoinPayoutFailed } from "./utils/notify-partner-stablecoin-payout-failed"; export async function outboundPaymentReturned(event: Stripe.ThinEvent) { const { related_object: relatedObject } = event; @@ -30,8 +31,7 @@ export async function outboundPaymentReturned(event: Stripe.ThinEvent) { }, }); - // TODO: - // Send email notification + await notifyPartnerStablecoinPayoutFailed(outboundPaymentId); return `Updated ${updatedPayouts.count} ${pluralize("payout", updatedPayouts.count)} to failed status.`; } diff --git a/apps/web/app/(ee)/api/stripe/connect/v2/webhook/utils/notify-partner-stablecoin-payout-failed.ts b/apps/web/app/(ee)/api/stripe/connect/v2/webhook/utils/notify-partner-stablecoin-payout-failed.ts new file mode 100644 index 00000000000..9a0e0984fa2 --- /dev/null +++ b/apps/web/app/(ee)/api/stripe/connect/v2/webhook/utils/notify-partner-stablecoin-payout-failed.ts @@ -0,0 +1,88 @@ +import { prisma } from "@/lib/prisma"; +import { sendEmail } from "@dub/email"; +import PartnerStablecoinPayoutFailed from "@dub/email/templates/partner-stablecoin-payout-failed"; +import { currencyFormatter, prettyPrint } from "@dub/utils"; +import { PayoutStatus } from "@prisma/client"; + +function maskCryptoWalletAddress(address: string) { + return address.length > 10 + ? `${address.slice(0, 6)}••••${address.slice(-4)}` + : address; +} + +export async function notifyPartnerStablecoinPayoutFailed( + outboundPaymentId: string, +) { + const payouts = await prisma.payout.findMany({ + where: { + stripePayoutId: outboundPaymentId, + status: PayoutStatus.failed, + }, + include: { + partner: { + select: { + email: true, + cryptoWalletAddress: true, + }, + }, + program: { + select: { + name: true, + }, + }, + }, + }); + + if (payouts.length === 0) { + console.log( + `No payouts found for outbound payment ${outboundPaymentId}. Skipping email send...`, + ); + return; + } + + const partner = payouts[0].partner; + + if (!partner.email) { + console.log( + `Partner email not found for outbound payment ${outboundPaymentId}. Skipping email send...`, + ); + return; + } + + const totalAmount = payouts.reduce((acc, payout) => acc + payout.amount, 0); + const failureReason = payouts[0].failureReason ?? undefined; + + const programs = [ + ...new Map( + payouts.map((payout) => [payout.program.name, payout.program]), + ).values(), + ]; + + const maskedAddress = partner.cryptoWalletAddress + ? maskCryptoWalletAddress(partner.cryptoWalletAddress) + : undefined; + + const emailResponse = await sendEmail({ + variant: "notifications", + subject: `Your recent partner payout of ${currencyFormatter(totalAmount)} failed`, + to: partner.email, + react: PartnerStablecoinPayoutFailed({ + email: partner.email, + programs, + payout: { + amount: totalAmount, + failureReason, + }, + wallet: maskedAddress + ? { + maskedAddress, + } + : undefined, + }), + }); + + console.log( + `Stablecoin payout failed email sent to partner ${partner.email}:`, + prettyPrint(emailResponse), + ); +} diff --git a/packages/email/src/templates/partner-stablecoin-payout-failed.tsx b/packages/email/src/templates/partner-stablecoin-payout-failed.tsx new file mode 100644 index 00000000000..2c8b3c7bcbf --- /dev/null +++ b/packages/email/src/templates/partner-stablecoin-payout-failed.tsx @@ -0,0 +1,135 @@ +import { currencyFormatter, DUB_WORDMARK } from "@dub/utils"; +import { + Body, + Container, + Head, + Heading, + Html, + Img, + Link, + Preview, + Section, + Tailwind, + Text, +} from "@react-email/components"; +import { Footer } from "../components/footer"; + +// Send this email to the partner when a stablecoin payout fails or is returned +export default function PartnerStablecoinPayoutFailed({ + programs = [{ name: "Acme" }], + payout = { + amount: 530000, + failureReason: "The destination wallet address is invalid.", + }, + wallet = { + maskedAddress: "0x1234••••5678", + network: "ethereum", + }, + email = "panic@thedis.co", +}: { + programs: { + name: string; + }[]; + payout: { + amount: number; // in cents + failureReason?: string; + }; + wallet?: { + maskedAddress?: string; + network?: string; + }; + email: string; +}) { + const amountFormatted = currencyFormatter(payout.amount); + const programNamesFormatted = programs.map((p) => p.name).join(", "); + + return ( + + + Action Required - Your recent stablecoin payout failed + + + +
+ Dub +
+ + + Your recent payout + {programs.length === 1 + ? ` from ${programNamesFormatted}` + : ""}{" "} + failed + + + + We attempted to send your recent payout of{" "} + + {amountFormatted} + + {programs.length > 1 ? ( + <> + {" "} + from{" "} + + {programNamesFormatted} + + + ) : null}{" "} + to your stablecoin wallet + {wallet?.maskedAddress ? ( + <> + {" "} + ( + + {wallet.maskedAddress} + {wallet.network ? ` · ${wallet.network}` : ""} + + ) + + ) : null} + , but the transaction failed. + + + {payout.failureReason && ( + + Reason:{" "} + + {payout.failureReason} + + + )} + + + Payout failures are usually due to incorrect wallet configuration. + Please reconnect your stablecoin wallet at your earliest + convenience and retry the payout from your{" "} + + Payout settings + + . + + +
+ + Reconnect wallet + +
+ + + If you have any questions, just reply to this email. + + +