-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Add minimum withdrawal amount to partners #2612
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
33 commits
Select commit
Hold shift + click to select a range
d85ad68
remove minPayoutAmount
devkiran cfb70f4
add minWithdrawalAmount
devkiran d4d3701
Add small withdrawal fee constant, implement manual payout schedule i…
devkiran 41f8540
run format
devkiran 83d9484
send amount
devkiran a7c39ba
Implement balance available webhook and refactor payout logic in Stri…
devkiran 1caae2f
source_transaction
devkiran 6624d61
Update programs.ts
devkiran 5cf8c83
Update constants.ts
devkiran be4c11d
Update route.ts
devkiran 99d9c35
Update route.ts
devkiran 5419088
Update route.ts
devkiran 64f9140
Update split-payouts.ts
devkiran 79e88b8
Update confirm-payouts.ts
devkiran c7f23a4
revert minPayoutAmount
devkiran 9263a1e
Update earnings-table.tsx
devkiran 99770ef
Update payout-table.tsx
devkiran 2b66cda
revert some of the changes
devkiran 68428eb
Update payouts.ts
devkiran 0077c97
Update check-pending-paypal-payouts.ts
devkiran 31b3457
Update commission-status-badges.tsx
devkiran f4a409e
Merge branch 'main' into minimum-withdrawal-amount
devkiran 0959d63
Update constants.ts
devkiran 2ed9a97
Update programs.ts
devkiran 72ecb53
Update convert-currency.ts
devkiran 04d4a62
Update balance-available.ts
devkiran 8f28378
remove DUB_MIN_PAYOUT_AMOUNT_CENTS
steven-tey 8b0f6ba
move payout settings
steven-tey 1b6b5f2
small changes
steven-tey f7293f6
Update balance-available.ts
steven-tey c8fc5fa
Update balance-available.ts
steven-tey 51db0dc
final changes
steven-tey 3329b98
improve logs
steven-tey File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
156 changes: 156 additions & 0 deletions
156
apps/web/app/(ee)/api/stripe/connect/webhook/balance-available.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,156 @@ | ||
| import { ZERO_DECIMAL_CURRENCIES } from "@/lib/analytics/convert-currency"; | ||
| import { | ||
| BELOW_MIN_WITHDRAWAL_FEE_CENTS, | ||
| MIN_WITHDRAWAL_AMOUNT_CENTS, | ||
| } from "@/lib/partners/constants"; | ||
| import { stripe } from "@/lib/stripe"; | ||
| import { redis } from "@/lib/upstash"; | ||
| import { prisma } from "@dub/prisma"; | ||
| import { currencyFormatter, log } from "@dub/utils"; | ||
| import Stripe from "stripe"; | ||
|
|
||
| export async function balanceAvailable(event: Stripe.Event) { | ||
| const stripeAccount = event.account; | ||
|
|
||
| if (!stripeAccount) { | ||
| console.error("Stripe account not found. Skipping..."); | ||
| return; | ||
| } | ||
|
|
||
| const partner = await prisma.partner.findUnique({ | ||
| where: { | ||
| stripeConnectId: stripeAccount, | ||
| }, | ||
| }); | ||
|
|
||
| if (!partner) { | ||
| console.error( | ||
| `Partner not found with stripeConnectId ${stripeAccount}. Skipping...`, | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| // Get the partner's current balance | ||
| const balance = await stripe.balance.retrieve({ | ||
| stripeAccount, | ||
| }); | ||
|
|
||
| // Check if there's any available balance | ||
| if (balance.available.length === 0 || balance.available[0].amount === 0) { | ||
| console.log( | ||
| `No available balance found for partner ${partner.email} (${stripeAccount}). Skipping...`, | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| const { amount, currency } = balance.available[0]; | ||
|
|
||
| let availableBalance = amount; | ||
| let convertedUsdAmount = amount; | ||
|
|
||
| if (currency !== "usd") { | ||
| const fxRates = await redis.hget("fxRates:usd", currency.toUpperCase()); | ||
|
|
||
| if (!fxRates) { | ||
| console.error( | ||
| `Failed to get exchange rate from Redis for ${currency}. Skipping...`, | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| convertedUsdAmount = availableBalance / Number(fxRates); | ||
|
|
||
| const isZeroDecimalCurrency = ZERO_DECIMAL_CURRENCIES.includes( | ||
| currency.toUpperCase(), | ||
| ); | ||
|
|
||
| if (isZeroDecimalCurrency) { | ||
| convertedUsdAmount = convertedUsdAmount * 100; | ||
| } | ||
| } | ||
|
|
||
| // Check minimum withdrawal amount | ||
| if (convertedUsdAmount < partner.minWithdrawalAmount) { | ||
| console.log( | ||
| `The available balance (${currencyFormatter(convertedUsdAmount / 100, { maximumFractionDigits: 2 })}) for partner ${partner.email} (${stripeAccount}) is less than their minimum withdrawal amount (${currencyFormatter(partner.minWithdrawalAmount / 100, { maximumFractionDigits: 2 })})`, | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| let withdrawalFee = 0; | ||
|
|
||
| // Decide if we need to charge a withdrawal fee for the partner | ||
| if (partner.minWithdrawalAmount < MIN_WITHDRAWAL_AMOUNT_CENTS) { | ||
| withdrawalFee = BELOW_MIN_WITHDRAWAL_FEE_CENTS; | ||
|
|
||
| const transfers = await stripe.transfers.list({ | ||
| destination: stripeAccount, | ||
| }); | ||
|
|
||
| if (transfers.data.length === 0) { | ||
| console.error( | ||
| `No transfers found for partner ${partner.email} (${stripeAccount}). Skipping...`, | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| // Find the latest transfer that's large enough to cover the withdrawal fee | ||
| const suitableTransfer = transfers.data | ||
| .filter((transfer) => transfer.amount >= withdrawalFee) | ||
| .sort((a, b) => b.created - a.created)[0]; | ||
|
|
||
| // This should never happen, but just in case | ||
| if (!suitableTransfer) { | ||
| const errorMessage = `Error processing withdrawal for partner ${partner.email} (${stripeAccount}): No transfer found with amount >= withdrawal fee (${currencyFormatter(withdrawalFee / 100)}). Available transfers: ${transfers.data | ||
| .map((t) => currencyFormatter(t.amount / 100)) | ||
| .join(", ")}. Skipping...`; | ||
| console.error(errorMessage); | ||
| await log({ | ||
| message: errorMessage, | ||
| type: "errors", | ||
| mention: true, | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| // Charge the withdrawal fee to the partner's account | ||
| await stripe.transfers.createReversal(suitableTransfer.id, { | ||
| amount: withdrawalFee, | ||
| description: "Dub Partners withdrawal fee", | ||
| }); | ||
|
|
||
| // If the withdrawal fee was charged, we need to fetch the partner's updated balance | ||
| const updatedBalance = await stripe.balance.retrieve({ | ||
| stripeAccount, | ||
| }); | ||
|
|
||
| if ( | ||
| updatedBalance.available.length === 0 || | ||
| updatedBalance.available[0].amount === 0 | ||
| ) { | ||
| // this should never happen, but just in case | ||
| console.log( | ||
| `No available balance found after withdrawal fee for partner ${partner.email} (${stripeAccount}). Skipping...`, | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| availableBalance = updatedBalance.available[0].amount; | ||
| } | ||
|
|
||
| const payout = await stripe.payouts.create( | ||
| { | ||
| amount: availableBalance, | ||
| currency, | ||
| description: "Dub Partners payout", | ||
| method: "standard", | ||
| }, | ||
| { | ||
| stripeAccount, | ||
| }, | ||
| ); | ||
|
|
||
| console.log( | ||
| `Stripe payout created for partner ${partner.email} (${stripeAccount}): ${payout.id} (${currencyFormatter(payout.amount / 100, { maximumFractionDigits: 2 })})`, | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -216,7 +216,7 @@ export function EarningsTablePartner({ limit }: { limit?: number }) { | |
| holdingPeriodDays: | ||
| programEnrollment?.program.holdingPeriodDays ?? 0, | ||
| minPayoutAmount: | ||
| programEnrollment?.program.minPayoutAmount ?? 10000, | ||
| programEnrollment?.program.minPayoutAmount ?? 0, | ||
| supportEmail: | ||
| programEnrollment?.program.supportEmail ?? "[email protected]", | ||
| })} | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
21 changes: 21 additions & 0 deletions
21
...app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/program-payout-settings-button.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| "use client"; | ||
|
|
||
| import { Button } from "@dub/ui"; | ||
| import { useProgramPayoutSettingsModal } from "./program-payout-settings-modal"; | ||
|
|
||
| export function ProgramPayoutSettingsButton() { | ||
| const { ProgramPayoutSettingsModal, setShowProgramPayoutSettingsModal } = | ||
| useProgramPayoutSettingsModal(); | ||
|
|
||
| return ( | ||
| <> | ||
| <ProgramPayoutSettingsModal /> | ||
| <Button | ||
| type="button" | ||
| text="Payout settings" | ||
| variant="secondary" | ||
| onClick={() => setShowProgramPayoutSettingsModal(true)} | ||
| /> | ||
| </> | ||
| ); | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 Verification agent
🧩 Analysis chain
Verify the behavioral impact of broadened filtering criteria.
The change from
DUB_MIN_PAYOUT_AMOUNT_CENTSto0broadens the selection criteria to include any positive amounts rather than those above the previous $100 threshold. This will likely increase the number of reminder emails sent to program owners.Please confirm this behavioral change is intentional and aligns with the business requirements:
Also applies to: 43-43
🏁 Script executed:
Length of output: 9464
Document & test the broadened payout reminder threshold
The filter in both cron reminder endpoints now uses
gt: 0instead ofgt: DUB_MIN_PAYOUT_AMOUNT_CENTS, but there are no tests or docs updated to reflect the removal of the previous ~$100 minimum. Please confirm this change aligns with business requirements and add/update:Locations to update:
apps/web/app/(ee)/api/cron/payouts/reminders/program-owners/route.ts(lines 33 & 43:gt: DUB_MIN_PAYOUT_AMOUNT_CENTS→gt: 0)apps/web/app/(ee)/api/cron/payouts/reminders/partners/route.ts(correspondinggroupByfilter)🤖 Prompt for AI Agents