You've already forked AstralRinth
forked from didirus/AstralRinth
* chore: fix typo in status message * feat(labrinth): overhaul malware scanner report storage and routes * chore: address some review comments * feat: add Delphi to Docker Compose `with-delphi` profile * chore: fix unused import Clippy lint * feat(labrinth/delphi): use PAT token authorization with project read scopes * chore: expose file IDs in version queries * fix: accept null decompiled source payloads from Delphi * tweak(labrinth): expose base62 file IDs more consistently for Delphi * feat(labrinth/delphi): support new Delphi report severity field * chore(labrinth): run `cargo sqlx prepare` to fix Docker build errors * tweak: add route for fetching Delphi issue type schema, abstract Labrinth away from issue types * chore: run `cargo sqlx prepare` * chore: fix typo on frontend generated state file message * feat: update to use new Delphi issue schema * wip: tech review endpoints * wip: add ToSchema for dependent types * wip: report issues return * wip * wip: returning more data * wip * Fix up db query * Delphi configuration to talk to Labrinth * Get Delphi working with Labrinth * Add Delphi dummy fixture * Better Delphi logging * Improve utoipa for tech review routes * Add more sorting options for tech review queue * Oops join * New routes for fetching issues and reports * Fix which kind of ID is returned in tech review endpoints * Deduplicate tech review report rows * Reduce info sent for projects * Fetch more thread info * Address PR comments * fix ci * fix postgres version mismatch * fix version creation * Implement routes * fix up tech review * Allow adding a moderation comment to Delphi rejections * fix up rebase * exclude rejected projects from tech review * add status change msg to tech review thread * cargo sqlx prepare * also ignore withheld projects * More filtering on issue search * wip: report routes * Fix up for build * cargo sqlx prepare * fix thread message privacy * New tech review search route * submit route * details have statuses now * add default to drid status * dedup issue details * fix sqlx query on empty files * fixes * Dedupe issue detail statuses and message on entering tech rev * Fix qa issues * Fix qa issues * fix review comments * typos * fix ci * feat: tech review frontend (#4781) * chore: fix typo in status message * feat(labrinth): overhaul malware scanner report storage and routes * chore: address some review comments * feat: add Delphi to Docker Compose `with-delphi` profile * chore: fix unused import Clippy lint * feat(labrinth/delphi): use PAT token authorization with project read scopes * chore: expose file IDs in version queries * fix: accept null decompiled source payloads from Delphi * tweak(labrinth): expose base62 file IDs more consistently for Delphi * feat(labrinth/delphi): support new Delphi report severity field * chore(labrinth): run `cargo sqlx prepare` to fix Docker build errors * tweak: add route for fetching Delphi issue type schema, abstract Labrinth away from issue types * chore: run `cargo sqlx prepare` * chore: fix typo on frontend generated state file message * feat: update to use new Delphi issue schema * wip: tech review endpoints * wip: add ToSchema for dependent types * wip: report issues return * wip * wip: returning more data * wip * Fix up db query * Delphi configuration to talk to Labrinth * Get Delphi working with Labrinth * Add Delphi dummy fixture * Better Delphi logging * Improve utoipa for tech review routes * Add more sorting options for tech review queue * Oops join * New routes for fetching issues and reports * Fix which kind of ID is returned in tech review endpoints * Deduplicate tech review report rows * Reduce info sent for projects * Fetch more thread info * Address PR comments * fix ci * fix ci * fix postgres version mismatch * fix version creation * Implement routes * feat: batch scan alert * feat: layout * feat: introduce surface variables * fix: theme selector * feat: rough draft of tech review card * feat: tab switcher * feat: batch scan btn * feat: api-client module for tech review * draft: impl * feat: auto icons * fix: layout issues * feat: fixes to code blocks + flag labels * feat: temp remove mock data * fix: search sort types * fix: intl & lint * chore: re-enable mock data * fix: flag badges + auto open first issue in file tab * feat: update for new routes * fix: more qa issues * feat: lazy load sources * fix: re-enable auth middleware * feat: impl threads * fix: lint & severity * feat: download btn + switch to using NavTabs with new local mode option * feat: re-add toplevel btns * feat: reports page consistency * fix: consistency on project queue * fix: icons + sizing * fix: colors and gaps * fix: impl endpoints * feat: load all flags on file tab * feat: thread generics changes * feat: more qa * feat: fix collapse * fix: qa * feat: msg modal * fix: ISO import * feat: qa fixes * fix: empty state basic * fix: collapsible region * fix: collapse thread by default * feat: rough draft of new process/flow * fix labrinth build * fix thread message privacy * New tech review search route * feat: qa fixes * feat: QA changes * fix: verdict on detail not whole issue * fix: lint + intl * fix: lint * fix: thread message for tech rev verdict * feat: use anim frames * fix: exports + typecheck * polish: qa changes * feat: qa * feat: qa polish * feat: fix malic modal * fix: lint * fix: qa + lint * fix: pagination * fix: lint * fix: qa * intl extract * fix ci --------- Signed-off-by: Calum H. <contact@cal.engineer> Co-authored-by: Alejandro González <me@alegon.dev> Co-authored-by: aecsocket <aecsocket@tutanota.com> --------- Signed-off-by: Calum H. <contact@cal.engineer> Co-authored-by: Alejandro González <me@alegon.dev> Co-authored-by: Calum H. <contact@cal.engineer>
938 lines
24 KiB
TypeScript
938 lines
24 KiB
TypeScript
import {
|
|
BadgeDollarSignIcon,
|
|
GiftIcon,
|
|
HandHelpingIcon,
|
|
LandmarkIcon,
|
|
PayPalColorIcon,
|
|
VenmoColorIcon,
|
|
} from '@modrinth/assets'
|
|
import { createContext, getCurrencyIcon, 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[]
|
|
}
|
|
currency_code?: string | null
|
|
exchange_rate?: number | null
|
|
}
|
|
|
|
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 {
|
|
// Round amount to 2 decimal places for API
|
|
const amount = Math.round(data.calculation.amount * 100) / 100
|
|
|
|
if (data.selection.provider === 'paypal' || data.selection.provider === 'venmo') {
|
|
return {
|
|
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,
|
|
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,
|
|
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,
|
|
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 Math.max(0, availableBalance)
|
|
}
|
|
|
|
const usedLimit = balance?.withdrawn_ytd ?? 0
|
|
const remainingLimit = Math.max(0, TAX_THRESHOLD_ACTUAL - usedLimit)
|
|
return Math.max(0, 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: '≈ 3.84%, min $0.25',
|
|
type: 'tremendous',
|
|
})
|
|
}
|
|
|
|
const merchantMethods = tremendousMethods.filter(
|
|
(m) =>
|
|
m.category === 'merchant_card' ||
|
|
m.category === 'merchant_cards' ||
|
|
m.category === 'visa_card',
|
|
)
|
|
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) {
|
|
const userMax = Math.floor(maxWithdrawAmount.value * 100) / 100
|
|
if (selectedMethod.interval.standard) {
|
|
const { min, max } = selectedMethod.interval.standard
|
|
const effectiveMax = Math.min(userMax, max)
|
|
const effectiveMin = Math.min(min, effectiveMax)
|
|
if (amount < effectiveMin || amount > effectiveMax) return false
|
|
}
|
|
if (selectedMethod.interval.fixed) {
|
|
const validValues = selectedMethod.interval.fixed.values.filter((v) => v <= userMax)
|
|
if (!validValues.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
|
|
// Use effective limits that account for user's available balance
|
|
const userMax = Math.floor(maxWithdrawAmount.value * 100) / 100
|
|
const effectiveMax = Math.min(userMax, max)
|
|
const effectiveMin = Math.min(min, effectiveMax)
|
|
if (amount < effectiveMin || amount > effectiveMax) 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
|
|
// Use effective limits that account for user's available balance
|
|
const userMax = Math.floor(maxWithdrawAmount.value * 100) / 100
|
|
const effectiveMax = Math.min(userMax, max)
|
|
const effectiveMin = Math.min(min, effectiveMax)
|
|
if (amount < effectiveMin || amount > effectiveMax) 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,
|
|
}
|
|
}
|