You've already forked AstralRinth
forked from didirus/AstralRinth
feat: creator revenue page overhaul (#4204)
* feat: start on tax compliance * feat: avarala1099 composable * fix: shouldShow should be managed on the page itself * refactor: move show logic to revenue page * feat: security practices rather than info * feat: withdraw page lock * fix: empty modal bug & lint issues * feat: hide behind feature flag * Use standard admonition components, make casing consistent * modal title * lint * feat: withdrawal check * feat: tax cap on withdrawals warning * feat: start on revenue page overhaul * feat: segment generation for bar * feat: tooltips and links * fix: tooltip border * feat: finish initial layout, start on withdraw modal * feat: start on withdrawal limit stage * feat: shade support for primary colors * feat: start on withdraw details stage * fix: convert swatches to hex * feat: payout method/region dropdown temporarily using multiselect * feat: fix modal open issues and use teleport dropdowns * feat: hide transactions section if there are no transactions * refactor: NavStack surfaces * feat: new dropdown component * feat: remove teleport dropdown modal in favour of new combobox component * fix: lint * refactor: dashboard sidebar layout * feat: cleanup * fix: niche bugs * fix: ComboBox styling * feat: first part of qa * feat: animate flash rather than tooltip * fix: lint * feat: qa border gradient * fix: seg hover flashes * feat: i18n * feat: i18n and final QA * fix: lint * feat: QA * fix: lint * fix: merge conflicts * fix: intl * fix: blue hover * fix: transfers page * feat: surface variables & gradients * feat: text vars * fix: lint * fix: intl * feat: stages * fix: lint * feat: region selection * feat: method selection btns * fix: flex col on transactions * feat: hook up method selection to ctx * feat: muralpay kyc stage info * wip: muralpay integration * Basic Mural Pay API bindings * Fix clippy * use dotenvy in muralpay example * Refactor payout creation code * wip: muralpay payout requests * Mural Pay payouts work * Fix clippy * feat: progress * fix: broken tax form stage logic * polish: tax form stage and method selection stage layout * add mural pay fees API * Work on payout fee API * Fees API for more payment methods * Fix CI * polish: muralpay qa * refactor: clean up combobox component * polish: change from critical -> warning admonition in MuralpayDetailsStage * Temporarily disable Venmo and PayPal methods from frontend * polish: clean up transaction component & page * polish: navbar qa, text color-contrast in chips type buttonstyled, mb on rev/index.vue page * fix: incorrectly using available balance as tax form withdraw limit after tax forms submitted * wip: counterparties * Start on counterparties and payment methods API * polish: combobox component * polish: fix broken scroll logic using a composable & web:fix * fix: lint * polish: various QA fixes * feat: hook up with backend (wip) * feat: draft muralpay rails dynamic logic * polish: modify rails to support backend changes * Mural Pay multiple methods when fetching * Don't send supported_countries to frontend * Mural Pay multiple methods when fetching * Don't send supported_countries to frontend * feat: fees & methods endpoint hookup * chore: remove duplicates fix * polish: qa changes + figma match * Add countries to muralpay fiat methods * Compile fix * Add exchange rate info to fees endpoint * Add fees to premium Tremendous options * polish: i18n and better document type dropdown -> id input labels * feat: tremendous * fix: lint & i18n * feat: reintroduce tin mismatch logic to index.vue * polish: qa * fix: i18n * feat: remove teleport dropdown menu - combobox should be used * fix: lint * fix: jsdoc * feat: checkbox for reward program terms * Add delivery email field to Tremendous payouts * Add Tremendous product category to payout methods * Add bank details API to muralpay * Fix CI * Fix CI * polish: qa changes * feat: i18n pass * feat: deduplicate methods endpoint & fix i18n issues * chore: deduplicate i18n strings into common-messages.ts * fix: lint * fix: i18n * feat: estimates * polish: more QA * Remove prepaid visa, compute fees properly for Tremendous methods * Add more details to Tremendous errors * feat: withdraw endpoint impl & internals refactor * Add more details to Tremendous errors * feat: completion stage * Add fees to Mural * feat: transactions page match figma * fix: i18n * polish: QA changes * polish: qa * Payout history route and bank details * polish: autofill and requirements checks * fix: i18n + lint * fix: fiat rail fees * polish: move scroll fade stuff into NewModal rather than just CreatorWithdrawModal * feat: simplify action btn logic & tax form error * fix: tax -> Tax form * Re-add legacy PayPal/Venmo options for US * feat: mobile responsiveness fixes for modal * fix: responsiveness issues * feat: navstack responsiveness * fix: responsiveness * move the mural bank details route * fix: generated state cleanup & bank details input * fix: lint & i18n * Add utoipa support to payout endpoints * address some PR comments * polish: qa * add CORS to new utoipa routes * feat: legacy paypal/venmo stage * polish: reset amount on back qa * revert: navstack mr changes * polish: loading indicator on method selection stage * fix: paypal modal doesnt reopen after auth * fix: lint & i18n * fix: paypal flow * polish: qa changes * fix: gitignore * polish: qa fixes * fix: payouts_available in payouts.rs * fix: bug when limit is zero * polish: qa changes * fix: qa stuff & muralpay sub-division fix * Immediately approve mural payouts * Add currency support to Tremendous payouts * Currency forex * add forex to tremendous fee request * polish: qa & currency support for paypal tremendous * polish: fx qa * feat: demo mode flag * fix: i18n & padding issues * polish: qa changes * fix: ml * Add Mural balance to bank balance info * polish: show warning for paypal international USD withdrawals + more currencies * Add more Tremendous currencies support * fix: colors on balance bars * fix: empty states * fix: pl-8 mobile issue * fix: hide see all * Transaction payouts available use the correct date * Address my own review comment * Address PR comments * Change Mural withdrawal limit to 3k * fix: empty state + paypal warning * maybe fix tremendous gift cards * Change how Mural minimum withdrawals are calculated * Tweak min/max withdrawal values * fix: segment brightness * fix: min & max for muralpay & legacy paypal * Fix some icon issues * more issues * fix user menu * fix: remove + network --------- Signed-off-by: Calum H. <contact@cal.engineer> Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com> Co-authored-by: aecsocket <aecsocket@tutanota.com> Co-authored-by: Alejandro González <me@alegon.dev>
This commit is contained in:
917
apps/frontend/src/providers/creator-withdraw.ts
Normal file
917
apps/frontend/src/providers/creator-withdraw.ts
Normal file
@@ -0,0 +1,917 @@
|
||||
import {
|
||||
BadgeDollarSignIcon,
|
||||
GiftIcon,
|
||||
HandHelpingIcon,
|
||||
LandmarkIcon,
|
||||
PayPalColorIcon,
|
||||
VenmoColorIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { createContext, paymentMethodMessages, useDebugLogger } from '@modrinth/ui'
|
||||
import type { MessageDescriptor } from '@vintl/vintl'
|
||||
import { type Component, computed, type ComputedRef, type Ref, ref } from 'vue'
|
||||
|
||||
import { getRailConfig } from '@/utils/muralpay-rails'
|
||||
|
||||
// Tax form is required when withdrawn_ytd >= $600
|
||||
// Therefore, the maximum withdrawal without a tax form is $599.99
|
||||
export const TAX_THRESHOLD_REQUIREMENT = 600
|
||||
export const TAX_THRESHOLD_ACTUAL = 599.99
|
||||
|
||||
export type WithdrawStage =
|
||||
| 'tax-form'
|
||||
| 'method-selection'
|
||||
| 'tremendous-details'
|
||||
| 'muralpay-kyc'
|
||||
| 'muralpay-details'
|
||||
| 'paypal-details'
|
||||
| 'completion'
|
||||
|
||||
export type PaymentProvider = 'tremendous' | 'muralpay' | 'paypal' | 'venmo'
|
||||
|
||||
/**
|
||||
* only used for the method selection stage logic - not actually for API requests
|
||||
**/
|
||||
export type PaymentMethod = 'gift_card' | 'paypal' | 'venmo' | 'bank' | 'crypto'
|
||||
|
||||
export interface PayoutMethod {
|
||||
id: string
|
||||
type: string
|
||||
name: string
|
||||
category?: string
|
||||
image_url: string | null
|
||||
image_logo_url: string | null
|
||||
fee: {
|
||||
percentage: number
|
||||
min: number
|
||||
max: number | null
|
||||
}
|
||||
interval: {
|
||||
standard: {
|
||||
min: number
|
||||
max: number
|
||||
}
|
||||
fixed?: {
|
||||
values: number[]
|
||||
}
|
||||
}
|
||||
config?: {
|
||||
fiat?: string | null
|
||||
blockchain?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface PaymentOption {
|
||||
value: string
|
||||
label: string | MessageDescriptor
|
||||
icon: Component
|
||||
methodId: string | undefined
|
||||
fee: string
|
||||
type: string
|
||||
}
|
||||
|
||||
export interface Country {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface WithdrawalResult {
|
||||
created: Date
|
||||
amount: number
|
||||
fee: number
|
||||
netAmount: number
|
||||
methodType: string
|
||||
recipientDisplay: string
|
||||
}
|
||||
|
||||
export interface KycData {
|
||||
type: 'individual' | 'business'
|
||||
email: string
|
||||
firstName?: string
|
||||
lastName?: string
|
||||
dateOfBirth?: string
|
||||
name?: string
|
||||
physicalAddress: {
|
||||
address1: string
|
||||
address2?: string
|
||||
city: string
|
||||
state: string
|
||||
country: string
|
||||
zip: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface AccountDetails {
|
||||
bankName?: string
|
||||
walletAddress?: string
|
||||
documentNumber?: string
|
||||
[key: string]: any // for dynamic rail fields
|
||||
}
|
||||
|
||||
export interface GiftCardDetails {
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface SelectionData {
|
||||
country: Country | null
|
||||
provider: PaymentProvider | null
|
||||
method: string | null
|
||||
methodId: string | null
|
||||
}
|
||||
|
||||
export interface TaxData {
|
||||
skipped: boolean
|
||||
}
|
||||
|
||||
export interface CalculationData {
|
||||
amount: number
|
||||
fee: number | null
|
||||
exchangeRate: number | null
|
||||
}
|
||||
|
||||
export interface TremendousProviderData {
|
||||
type: 'tremendous'
|
||||
deliveryEmail: string
|
||||
giftCardDetails: GiftCardDetails | null
|
||||
currency?: string
|
||||
}
|
||||
|
||||
export interface MuralPayProviderData {
|
||||
type: 'muralpay'
|
||||
kycData: KycData
|
||||
accountDetails: AccountDetails
|
||||
}
|
||||
|
||||
export interface PayPalVenmoProviderData {
|
||||
type: 'paypal' | 'venmo'
|
||||
}
|
||||
|
||||
export interface NoProviderData {
|
||||
type: null
|
||||
}
|
||||
|
||||
export type ProviderData =
|
||||
| TremendousProviderData
|
||||
| MuralPayProviderData
|
||||
| PayPalVenmoProviderData
|
||||
| NoProviderData
|
||||
|
||||
export interface WithdrawData {
|
||||
selection: SelectionData
|
||||
tax: TaxData
|
||||
calculation: CalculationData
|
||||
providerData: ProviderData
|
||||
result: WithdrawalResult | null
|
||||
agreedTerms: boolean
|
||||
stageValidation: {
|
||||
paypalDetails?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface SavedWithdrawState {
|
||||
timestamp: number
|
||||
stage: WithdrawStage
|
||||
data: WithdrawData
|
||||
}
|
||||
|
||||
export interface WithdrawContextValue {
|
||||
currentStage: Ref<WithdrawStage | undefined>
|
||||
stages: ComputedRef<WithdrawStage[]>
|
||||
|
||||
canProceed: ComputedRef<boolean>
|
||||
nextStep: ComputedRef<WithdrawStage | undefined>
|
||||
previousStep: ComputedRef<WithdrawStage | undefined>
|
||||
currentStepIndex: ComputedRef<number>
|
||||
|
||||
withdrawData: Ref<WithdrawData>
|
||||
balance: Ref<any>
|
||||
maxWithdrawAmount: ComputedRef<number>
|
||||
availableMethods: Ref<PayoutMethod[]>
|
||||
paymentOptions: ComputedRef<PaymentOption[]>
|
||||
preloadedCountry: Ref<string | undefined>
|
||||
paymentMethodsCache: Ref<Record<string, PayoutMethod[]>>
|
||||
|
||||
setStage: (stage: WithdrawStage | undefined, skipValidation?: boolean) => Promise<void>
|
||||
validateCurrentStage: () => boolean
|
||||
resetData: () => void
|
||||
calculateFees: () => Promise<{ fee: number | null; exchange_rate: number | null }>
|
||||
submitWithdrawal: () => Promise<void>
|
||||
saveStateToStorage: () => void
|
||||
restoreStateFromStorage: () => SavedWithdrawState | null
|
||||
clearSavedState: () => void
|
||||
}
|
||||
|
||||
export const [injectWithdrawContext, provideWithdrawContext] =
|
||||
createContext<WithdrawContextValue>('CreatorWithdrawModal')
|
||||
|
||||
export function useWithdrawContext() {
|
||||
return injectWithdrawContext()
|
||||
}
|
||||
|
||||
function isTremendousProvider(data: ProviderData): data is TremendousProviderData {
|
||||
return data.type === 'tremendous'
|
||||
}
|
||||
|
||||
function isMuralPayProvider(data: ProviderData): data is MuralPayProviderData {
|
||||
return data.type === 'muralpay'
|
||||
}
|
||||
|
||||
function buildRecipientInfo(kycData: KycData) {
|
||||
return {
|
||||
type: kycData.type,
|
||||
...(kycData.type === 'individual'
|
||||
? {
|
||||
firstName: kycData.firstName,
|
||||
lastName: kycData.lastName,
|
||||
dateOfBirth: kycData.dateOfBirth,
|
||||
}
|
||||
: {
|
||||
name: kycData.name,
|
||||
}),
|
||||
email: kycData.email,
|
||||
physicalAddress: kycData.physicalAddress,
|
||||
}
|
||||
}
|
||||
|
||||
function getAccountOwnerName(kycData: KycData): string {
|
||||
if (kycData.type === 'individual') {
|
||||
return `${kycData.firstName} ${kycData.lastName}`
|
||||
}
|
||||
return kycData.name || ''
|
||||
}
|
||||
|
||||
function getMethodDisplayName(method: string | null): string {
|
||||
if (!method) return ''
|
||||
const methodMap: Record<string, string> = {
|
||||
paypal: 'PayPal',
|
||||
venmo: 'Venmo',
|
||||
merchant_card: 'Gift Card',
|
||||
charity: 'Charity',
|
||||
visa_card: 'Virtual Visa',
|
||||
}
|
||||
if (methodMap[method]) return methodMap[method]
|
||||
if (method.startsWith('fiat_')) {
|
||||
return 'Bank Transfer'
|
||||
}
|
||||
if (method.startsWith('blockchain_')) {
|
||||
return 'Crypto'
|
||||
}
|
||||
return method
|
||||
}
|
||||
|
||||
function getRecipientDisplay(data: WithdrawData): string {
|
||||
if (isTremendousProvider(data.providerData)) {
|
||||
return data.providerData.deliveryEmail
|
||||
}
|
||||
if (isMuralPayProvider(data.providerData)) {
|
||||
const kycData = data.providerData.kycData
|
||||
if (kycData.type === 'individual') {
|
||||
return `${kycData.firstName} ${kycData.lastName}`
|
||||
}
|
||||
return kycData.name || ''
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
interface PayoutPayload {
|
||||
amount: number
|
||||
method: 'tremendous' | 'muralpay' | 'paypal' | 'venmo'
|
||||
method_id: string
|
||||
method_details?: {
|
||||
delivery_email?: string
|
||||
payout_details?: any
|
||||
recipient_info?: any
|
||||
}
|
||||
}
|
||||
|
||||
function buildPayoutPayload(data: WithdrawData): PayoutPayload {
|
||||
if (data.selection.provider === 'paypal' || data.selection.provider === 'venmo') {
|
||||
return {
|
||||
amount: data.calculation.amount,
|
||||
method: data.selection.provider,
|
||||
method_id: data.selection.methodId!,
|
||||
}
|
||||
} else if (data.selection.provider === 'tremendous') {
|
||||
if (!isTremendousProvider(data.providerData)) {
|
||||
throw new Error('Invalid provider data for Tremendous')
|
||||
}
|
||||
const methodDetails: any = {
|
||||
delivery_email: data.providerData.deliveryEmail,
|
||||
}
|
||||
if (data.providerData.currency && data.selection.method === 'paypal') {
|
||||
methodDetails.currency = data.providerData.currency
|
||||
}
|
||||
return {
|
||||
amount: data.calculation.amount,
|
||||
method: 'tremendous',
|
||||
method_id: data.selection.methodId!,
|
||||
method_details: methodDetails,
|
||||
}
|
||||
} else if (data.selection.provider === 'muralpay') {
|
||||
if (!isMuralPayProvider(data.providerData)) {
|
||||
throw new Error('Invalid provider data for MuralPay')
|
||||
}
|
||||
const railId = data.selection.method!
|
||||
const rail = getRailConfig(railId)
|
||||
|
||||
if (!rail) throw new Error('Invalid payment method')
|
||||
|
||||
if (rail.type === 'crypto') {
|
||||
return {
|
||||
amount: data.calculation.amount,
|
||||
method: 'muralpay',
|
||||
method_id: data.selection.methodId!,
|
||||
method_details: {
|
||||
payout_details: {
|
||||
type: 'blockchain',
|
||||
wallet_address: data.providerData.accountDetails.walletAddress || null,
|
||||
},
|
||||
recipient_info: buildRecipientInfo(data.providerData.kycData),
|
||||
},
|
||||
}
|
||||
} else if (rail.type === 'fiat') {
|
||||
const fiatAndRailDetails: Record<string, any> = {
|
||||
type: rail.railCode || '',
|
||||
symbol: rail.currency || '',
|
||||
}
|
||||
|
||||
for (const field of rail.fields) {
|
||||
const value = data.providerData.accountDetails[field.name]
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
fiatAndRailDetails[field.name] = value
|
||||
}
|
||||
}
|
||||
|
||||
if (data.providerData.accountDetails.documentNumber) {
|
||||
fiatAndRailDetails.documentNumber = data.providerData.accountDetails.documentNumber
|
||||
}
|
||||
|
||||
return {
|
||||
amount: data.calculation.amount,
|
||||
method: 'muralpay',
|
||||
method_id: data.selection.methodId!,
|
||||
method_details: {
|
||||
payout_details: {
|
||||
type: 'fiat',
|
||||
bank_name: data.providerData.accountDetails.bankName || '',
|
||||
bank_account_owner: getAccountOwnerName(data.providerData.kycData),
|
||||
fiat_and_rail_details: fiatAndRailDetails,
|
||||
},
|
||||
recipient_info: buildRecipientInfo(data.providerData.kycData),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Invalid provider')
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'modrinth_withdraw_state'
|
||||
const STATE_EXPIRY_MS = 15 * 60 * 1000 // 15 minutes
|
||||
|
||||
export function createWithdrawContext(
|
||||
balance: any,
|
||||
preloadedPaymentData?: { country: string; methods: PayoutMethod[] },
|
||||
): WithdrawContextValue {
|
||||
const debug = useDebugLogger('CreatorWithdraw')
|
||||
const currentStage = ref<WithdrawStage | undefined>()
|
||||
|
||||
const withdrawData = ref<WithdrawData>({
|
||||
selection: {
|
||||
country: null,
|
||||
provider: null,
|
||||
method: null,
|
||||
methodId: null,
|
||||
},
|
||||
tax: {
|
||||
skipped: false,
|
||||
},
|
||||
calculation: {
|
||||
amount: 0,
|
||||
fee: null,
|
||||
exchangeRate: null,
|
||||
},
|
||||
providerData: {
|
||||
type: null,
|
||||
},
|
||||
result: null,
|
||||
agreedTerms: false,
|
||||
stageValidation: {},
|
||||
})
|
||||
|
||||
const balanceRef = ref(balance)
|
||||
const availableMethods = ref<PayoutMethod[]>(preloadedPaymentData?.methods || [])
|
||||
const preloadedCountry = ref(preloadedPaymentData?.country)
|
||||
|
||||
const paymentMethodsCache = ref<Record<string, PayoutMethod[]>>(
|
||||
preloadedPaymentData ? { [preloadedPaymentData.country]: preloadedPaymentData.methods } : {},
|
||||
)
|
||||
|
||||
const stages = computed<WithdrawStage[]>(() => {
|
||||
const dynamicStages: WithdrawStage[] = []
|
||||
|
||||
const usedLimit = balance?.withdrawn_ytd ?? 0
|
||||
const available = balance?.available ?? 0
|
||||
|
||||
const needsTaxForm =
|
||||
balance?.form_completion_status !== 'complete' && usedLimit + available >= 600
|
||||
|
||||
debug('Tax form check:', {
|
||||
usedLimit,
|
||||
available,
|
||||
total: usedLimit + available,
|
||||
status: balance?.form_completion_status,
|
||||
needsTaxForm,
|
||||
})
|
||||
|
||||
if (needsTaxForm) {
|
||||
dynamicStages.push('tax-form')
|
||||
}
|
||||
|
||||
dynamicStages.push('method-selection')
|
||||
|
||||
const selectedProvider = withdrawData.value.selection.provider
|
||||
if (selectedProvider === 'tremendous') {
|
||||
dynamicStages.push('tremendous-details')
|
||||
} else if (selectedProvider === 'muralpay') {
|
||||
dynamicStages.push('muralpay-kyc')
|
||||
dynamicStages.push('muralpay-details')
|
||||
} else if (selectedProvider === 'paypal' || selectedProvider === 'venmo') {
|
||||
dynamicStages.push('paypal-details')
|
||||
}
|
||||
|
||||
dynamicStages.push('completion')
|
||||
|
||||
return dynamicStages
|
||||
})
|
||||
|
||||
const maxWithdrawAmount = computed(() => {
|
||||
const availableBalance = balance?.available ?? 0
|
||||
const formCompleted = balance?.form_completion_status === 'complete'
|
||||
|
||||
if (formCompleted) {
|
||||
return availableBalance
|
||||
}
|
||||
|
||||
const usedLimit = balance?.withdrawn_ytd ?? 0
|
||||
const remainingLimit = Math.max(0, TAX_THRESHOLD_ACTUAL - usedLimit)
|
||||
return Math.min(remainingLimit, availableBalance)
|
||||
})
|
||||
|
||||
const paymentOptions = computed<PaymentOption[]>(() => {
|
||||
const methods = availableMethods.value
|
||||
if (!methods || methods.length === 0) {
|
||||
debug('No payment methods available')
|
||||
return []
|
||||
}
|
||||
|
||||
debug('Available methods:', methods)
|
||||
|
||||
const options: PaymentOption[] = []
|
||||
|
||||
const tremendousMethods = methods.filter((m) => m.type === 'tremendous')
|
||||
|
||||
const internationalPaypalMethod = tremendousMethods.find(
|
||||
(m) => m.type === 'tremendous' && m.category === 'paypal',
|
||||
)
|
||||
// TODO: remove this US check when boris removes it from backend
|
||||
if (internationalPaypalMethod && withdrawData.value.selection.country?.id != 'US') {
|
||||
options.push({
|
||||
value: 'paypal',
|
||||
label: paymentMethodMessages.paypalInternational,
|
||||
icon: PayPalColorIcon,
|
||||
methodId: internationalPaypalMethod.id,
|
||||
fee: '≈ 6%, max $25',
|
||||
type: 'tremendous',
|
||||
})
|
||||
}
|
||||
|
||||
const merchantMethods = tremendousMethods.filter(
|
||||
(m) => m.category === 'merchant_card' || m.category === 'merchant_cards',
|
||||
)
|
||||
if (merchantMethods.length > 0) {
|
||||
options.push({
|
||||
value: 'merchant_card',
|
||||
label: paymentMethodMessages.giftCard,
|
||||
icon: GiftIcon,
|
||||
methodId: undefined,
|
||||
fee: '≈ 0%',
|
||||
type: 'tremendous',
|
||||
})
|
||||
}
|
||||
|
||||
const charityMethods = tremendousMethods.filter((m) => m.category === 'charity')
|
||||
if (charityMethods.length > 0) {
|
||||
options.push({
|
||||
value: 'charity',
|
||||
label: paymentMethodMessages.charity,
|
||||
icon: HandHelpingIcon,
|
||||
methodId: undefined,
|
||||
fee: '≈ 0%',
|
||||
type: 'tremendous',
|
||||
})
|
||||
}
|
||||
|
||||
const muralPayMethods = methods.filter((m) => m.type === 'muralpay')
|
||||
for (const method of muralPayMethods) {
|
||||
const methodId = method.id
|
||||
|
||||
if (methodId.startsWith('fiat_')) {
|
||||
const rail = getRailConfig(methodId)
|
||||
|
||||
if (!rail) {
|
||||
debug('Warning: No rail config found for', methodId)
|
||||
continue
|
||||
}
|
||||
|
||||
options.push({
|
||||
value: methodId,
|
||||
label: rail.name,
|
||||
icon: LandmarkIcon,
|
||||
methodId: method.id,
|
||||
fee: rail.fee,
|
||||
type: 'fiat',
|
||||
})
|
||||
} else if (methodId.startsWith('blockchain_')) {
|
||||
const rail = getRailConfig(methodId)
|
||||
|
||||
if (!rail) {
|
||||
debug('Warning: No rail config found for', methodId)
|
||||
continue
|
||||
}
|
||||
|
||||
options.push({
|
||||
value: methodId,
|
||||
label: rail.name,
|
||||
icon: getCurrencyIcon(rail.currency) || BadgeDollarSignIcon,
|
||||
methodId: method.id,
|
||||
fee: rail.fee,
|
||||
type: 'crypto',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const directPaypal = methods.find((m) => m.type === 'paypal')
|
||||
if (directPaypal) {
|
||||
options.push({
|
||||
value: directPaypal.id,
|
||||
label: paymentMethodMessages.paypal,
|
||||
icon: PayPalColorIcon,
|
||||
methodId: directPaypal.id,
|
||||
fee: `≈ 2% + $0.25, max $1`,
|
||||
type: 'paypal',
|
||||
})
|
||||
}
|
||||
|
||||
const directVenmo = methods.find((m) => m.type === 'venmo')
|
||||
if (directVenmo) {
|
||||
options.push({
|
||||
value: directVenmo.id,
|
||||
label: paymentMethodMessages.venmo,
|
||||
icon: VenmoColorIcon,
|
||||
methodId: directVenmo.id,
|
||||
fee: `≈ 2% + $0.25, max $1`,
|
||||
type: 'venmo',
|
||||
})
|
||||
}
|
||||
|
||||
const sortOrder = ['fiat', 'paypal', 'venmo', 'crypto', 'merchant_card', 'charity']
|
||||
options.sort((a, b) => {
|
||||
const getOrder = (item: PaymentOption) => {
|
||||
let order = sortOrder.indexOf(item.type)
|
||||
if (order === -1) order = sortOrder.indexOf(item.value)
|
||||
return order !== -1 ? order : 999
|
||||
}
|
||||
return getOrder(a) - getOrder(b)
|
||||
})
|
||||
|
||||
debug('Payment options computed:', options)
|
||||
return options
|
||||
})
|
||||
|
||||
const currentStepIndex = computed(() =>
|
||||
currentStage.value ? stages.value.indexOf(currentStage.value) : -1,
|
||||
)
|
||||
|
||||
const nextStep = computed(() => {
|
||||
if (!currentStage.value) return undefined
|
||||
const currentIndex = currentStepIndex.value
|
||||
if (currentIndex === -1 || currentIndex >= stages.value.length - 1) return undefined
|
||||
return stages.value[currentIndex + 1]
|
||||
})
|
||||
|
||||
const previousStep = computed(() => {
|
||||
if (!currentStage.value) return undefined
|
||||
const currentIndex = currentStepIndex.value
|
||||
if (currentIndex <= 0) return undefined
|
||||
return stages.value[currentIndex - 1]
|
||||
})
|
||||
|
||||
const canProceed = computed(() => {
|
||||
return validateCurrentStage()
|
||||
})
|
||||
|
||||
function validateCurrentStage(): boolean {
|
||||
switch (currentStage.value) {
|
||||
case 'tax-form': {
|
||||
if (!balanceRef.value) return true
|
||||
const ytd = balanceRef.value.withdrawn_ytd ?? 0
|
||||
const remainingLimit = Math.max(0, TAX_THRESHOLD_ACTUAL - ytd)
|
||||
const form_completion_status = balanceRef.value.form_completion_status
|
||||
if (ytd < 600) return true
|
||||
if (withdrawData.value.tax.skipped && remainingLimit > 0) return true
|
||||
return form_completion_status === 'complete'
|
||||
}
|
||||
case 'method-selection':
|
||||
return !!(
|
||||
withdrawData.value.selection.country &&
|
||||
withdrawData.value.selection.provider &&
|
||||
withdrawData.value.selection.method &&
|
||||
(withdrawData.value.selection.method === 'merchant_card' ||
|
||||
withdrawData.value.selection.method === 'charity' ||
|
||||
withdrawData.value.selection.methodId)
|
||||
)
|
||||
case 'tremendous-details': {
|
||||
const method = withdrawData.value.selection.method
|
||||
const amount = withdrawData.value.calculation.amount
|
||||
|
||||
const selectedMethod = availableMethods.value.find(
|
||||
(m) => m.id === withdrawData.value.selection.methodId,
|
||||
)
|
||||
|
||||
if (selectedMethod?.interval) {
|
||||
if (selectedMethod.interval.standard) {
|
||||
const { min, max } = selectedMethod.interval.standard
|
||||
if (amount < min || amount > max) return false
|
||||
}
|
||||
if (selectedMethod.interval.fixed) {
|
||||
if (!selectedMethod.interval.fixed.values.includes(amount)) return false
|
||||
}
|
||||
}
|
||||
|
||||
if (method === 'merchant_card' || method === 'charity') {
|
||||
if (!isTremendousProvider(withdrawData.value.providerData)) return false
|
||||
return !!(
|
||||
withdrawData.value.selection.methodId &&
|
||||
amount > 0 &&
|
||||
withdrawData.value.providerData.deliveryEmail &&
|
||||
withdrawData.value.agreedTerms
|
||||
)
|
||||
}
|
||||
if (!isTremendousProvider(withdrawData.value.providerData)) return false
|
||||
return !!(
|
||||
amount > 0 &&
|
||||
withdrawData.value.providerData.deliveryEmail &&
|
||||
withdrawData.value.agreedTerms
|
||||
)
|
||||
}
|
||||
case 'muralpay-kyc': {
|
||||
if (!isMuralPayProvider(withdrawData.value.providerData)) return false
|
||||
|
||||
const kycData = withdrawData.value.providerData.kycData
|
||||
if (!kycData) return false
|
||||
|
||||
const hasValidAddress = !!(
|
||||
kycData.physicalAddress?.address1 &&
|
||||
kycData.physicalAddress?.city &&
|
||||
kycData.physicalAddress?.state &&
|
||||
kycData.physicalAddress?.country &&
|
||||
kycData.physicalAddress?.zip
|
||||
)
|
||||
|
||||
if (kycData.type === 'individual') {
|
||||
return !!(
|
||||
kycData.firstName &&
|
||||
kycData.lastName &&
|
||||
kycData.email &&
|
||||
kycData.dateOfBirth &&
|
||||
hasValidAddress
|
||||
)
|
||||
} else if (kycData.type === 'business') {
|
||||
return !!(kycData.name && kycData.email && hasValidAddress)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
case 'muralpay-details': {
|
||||
if (!isMuralPayProvider(withdrawData.value.providerData)) return false
|
||||
|
||||
const railId = withdrawData.value.selection.method
|
||||
const rail = getRailConfig(railId as string)
|
||||
if (!rail) return false
|
||||
|
||||
if (!withdrawData.value.calculation.amount || withdrawData.value.calculation.amount <= 0)
|
||||
return false
|
||||
|
||||
const amount = withdrawData.value.calculation.amount
|
||||
const selectedMethod = availableMethods.value.find(
|
||||
(m) => m.id === withdrawData.value.selection.methodId,
|
||||
)
|
||||
if (selectedMethod?.interval?.standard) {
|
||||
const { min, max } = selectedMethod.interval.standard
|
||||
if (amount < min || amount > max) return false
|
||||
}
|
||||
|
||||
const accountDetails = withdrawData.value.providerData.accountDetails
|
||||
if (!accountDetails) return false
|
||||
|
||||
if (rail.requiresBankName && !accountDetails.bankName) return false
|
||||
|
||||
const requiredFields = rail.fields.filter((f) => f.required)
|
||||
const allRequiredPresent = requiredFields.every((f) => {
|
||||
const value = accountDetails[f.name]
|
||||
return value !== undefined && value !== null && value !== ''
|
||||
})
|
||||
|
||||
return allRequiredPresent && withdrawData.value.agreedTerms
|
||||
}
|
||||
case 'paypal-details': {
|
||||
const amount = withdrawData.value.calculation.amount
|
||||
if (!amount || amount <= 0) return false
|
||||
|
||||
const selectedMethod = availableMethods.value.find(
|
||||
(m) => m.id === withdrawData.value.selection.methodId,
|
||||
)
|
||||
if (selectedMethod?.interval?.standard) {
|
||||
const { min, max } = selectedMethod.interval.standard
|
||||
if (amount < min || amount > max) return false
|
||||
}
|
||||
|
||||
return !!withdrawData.value.stageValidation?.paypalDetails
|
||||
}
|
||||
case 'completion':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function setStage(stage: WithdrawStage | undefined, skipValidation = false) {
|
||||
if (!skipValidation && !canProceed.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const detailsStages: WithdrawStage[] = [
|
||||
'tremendous-details',
|
||||
'muralpay-details',
|
||||
'paypal-details',
|
||||
]
|
||||
const isLeavingDetails = currentStage.value && detailsStages.includes(currentStage.value)
|
||||
const isGoingToMethodSelection = stage === 'method-selection'
|
||||
|
||||
if (isLeavingDetails && isGoingToMethodSelection) {
|
||||
withdrawData.value.calculation.amount = 0
|
||||
withdrawData.value.calculation.fee = null
|
||||
withdrawData.value.calculation.exchangeRate = null
|
||||
withdrawData.value.agreedTerms = false
|
||||
withdrawData.value.stageValidation = {}
|
||||
}
|
||||
|
||||
currentStage.value = stage
|
||||
}
|
||||
|
||||
function resetData() {
|
||||
withdrawData.value = {
|
||||
selection: {
|
||||
country: null,
|
||||
provider: null,
|
||||
method: null,
|
||||
methodId: null,
|
||||
},
|
||||
tax: {
|
||||
skipped: false,
|
||||
},
|
||||
calculation: {
|
||||
amount: 0,
|
||||
fee: null,
|
||||
exchangeRate: null,
|
||||
},
|
||||
providerData: {
|
||||
type: null,
|
||||
},
|
||||
result: null,
|
||||
agreedTerms: false,
|
||||
stageValidation: {},
|
||||
}
|
||||
currentStage.value = undefined
|
||||
availableMethods.value = []
|
||||
clearSavedState()
|
||||
}
|
||||
|
||||
async function calculateFees(): Promise<{ fee: number | null; exchange_rate: number | null }> {
|
||||
const payload = buildPayoutPayload(withdrawData.value)
|
||||
|
||||
const response = (await useBaseFetch('payout/fees', {
|
||||
apiVersion: 3,
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
})) as { fee: number | string | null; exchange_rate: number | string | null }
|
||||
|
||||
const parsedFee = response.fee ? Number.parseFloat(String(response.fee)) : 0
|
||||
const parsedExchangeRate = response.exchange_rate
|
||||
? Number.parseFloat(String(response.exchange_rate))
|
||||
: null
|
||||
|
||||
withdrawData.value.calculation.fee = parsedFee
|
||||
withdrawData.value.calculation.exchangeRate = parsedExchangeRate
|
||||
|
||||
return {
|
||||
fee: parsedFee,
|
||||
exchange_rate: parsedExchangeRate,
|
||||
}
|
||||
}
|
||||
|
||||
async function submitWithdrawal(): Promise<void> {
|
||||
const payload = buildPayoutPayload(withdrawData.value)
|
||||
|
||||
debug('Withdrawal payload:', payload)
|
||||
|
||||
await useBaseFetch('payout', {
|
||||
apiVersion: 3,
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
})
|
||||
|
||||
withdrawData.value.result = {
|
||||
created: new Date(),
|
||||
amount: withdrawData.value.calculation.amount,
|
||||
fee: withdrawData.value.calculation.fee || 0,
|
||||
netAmount: withdrawData.value.calculation.amount - (withdrawData.value.calculation.fee || 0),
|
||||
methodType: getMethodDisplayName(withdrawData.value.selection.method),
|
||||
recipientDisplay: getRecipientDisplay(withdrawData.value),
|
||||
}
|
||||
|
||||
debug('Withdrawal submitted successfully', withdrawData.value.result)
|
||||
}
|
||||
|
||||
function saveStateToStorage(): void {
|
||||
const state: SavedWithdrawState = {
|
||||
timestamp: Date.now(),
|
||||
stage: currentStage.value || 'method-selection',
|
||||
data: withdrawData.value,
|
||||
}
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to save withdraw state:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function restoreStateFromStorage(): SavedWithdrawState | null {
|
||||
try {
|
||||
if (typeof localStorage === 'undefined') return null
|
||||
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
if (!saved) return null
|
||||
|
||||
const state: SavedWithdrawState = JSON.parse(saved)
|
||||
const age = Date.now() - state.timestamp
|
||||
|
||||
if (age > STATE_EXPIRY_MS) {
|
||||
clearSavedState()
|
||||
return null
|
||||
}
|
||||
|
||||
return state
|
||||
} catch (e) {
|
||||
console.warn('Failed to restore withdraw state:', e)
|
||||
clearSavedState()
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function clearSavedState(): void {
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to clear withdraw state:', e)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
currentStage,
|
||||
stages,
|
||||
canProceed,
|
||||
nextStep,
|
||||
previousStep,
|
||||
currentStepIndex,
|
||||
withdrawData,
|
||||
balance: balanceRef,
|
||||
maxWithdrawAmount,
|
||||
availableMethods,
|
||||
paymentOptions,
|
||||
preloadedCountry,
|
||||
paymentMethodsCache,
|
||||
setStage,
|
||||
validateCurrentStage,
|
||||
resetData,
|
||||
calculateFees,
|
||||
submitWithdrawal,
|
||||
saveStateToStorage,
|
||||
restoreStateFromStorage,
|
||||
clearSavedState,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user