Skip to content

Commit

Permalink
[PAY-2045] Wire up content price and purchase limits (#6417)
Browse files Browse the repository at this point in the history
  • Loading branch information
schottra authored Oct 23, 2023
1 parent 5c4aba5 commit 8afa18c
Show file tree
Hide file tree
Showing 20 changed files with 387 additions and 125 deletions.
2 changes: 2 additions & 0 deletions packages/common/src/hooks/purchaseContent/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export * from './usePurchaseContentFormConfiguration'
export * from './useChallengeCooldownSchedule'
export * from './useUSDCPurchaseConfig'
export * from './usePurchaseContentErrorMessage'
export * from './usePayExtraPresets'
export * from './utils'
export * from './types'
Expand Down
7 changes: 7 additions & 0 deletions packages/common/src/hooks/purchaseContent/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,10 @@ export enum PayExtraPreset {
export type PurchasableTrackMetadata = UserTrackMetadata & {
premium_conditions: PremiumConditionsUSDCPurchase
}

export type USDCPurchaseConfig = {
minContentPriceCents: number
maxContentPriceCents: number
minUSDCPurchaseAmountCents: number
maxUSDCPurchaseAmountCents: number
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,12 @@ import { useMemo } from 'react'
import { StringKeys } from 'services/remote-config'
import { parseIntList } from 'utils/stringUtils'

import { createUseRemoteVarHook } from '../useRemoteVar'
import { RemoteVarHook } from '../useRemoteVar'

import { PayExtraAmountPresetValues, PayExtraPreset } from './types'

/** Extracts and parses the Pay Extra presets from remote config */
export const usePayExtraPresets = (
useRemoteVar: ReturnType<typeof createUseRemoteVarHook>
) => {
export const usePayExtraPresets = (useRemoteVar: RemoteVarHook) => {
const configValue = useRemoteVar(StringKeys.PAY_EXTRA_PRESET_CENT_AMOUNTS)
return useMemo<PayExtraAmountPresetValues>(() => {
const [low, medium, high] = parseIntList(configValue)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { BuyUSDCErrorCode } from 'store/index'
import {
PurchaseContentErrorCode,
PurchaseErrorCode
} from 'store/purchase-content'
import { formatPrice } from 'utils/formatUtil'

import { RemoteVarHook } from '../useRemoteVar'

import { useUSDCPurchaseConfig } from './useUSDCPurchaseConfig'

const messages = {
generic: 'Your purchase was unsuccessful.',
minimumPurchase: (minAmount: number) =>
`Total purchase amount must be at least $${formatPrice(minAmount)}.`,
maximumPurchase: (maxAmount: number) =>
`Total purchase amount may not exceed $${formatPrice(maxAmount)}.`
}

export const usePurchaseContentErrorMessage = (
errorCode: PurchaseContentErrorCode,
useRemoteVar: RemoteVarHook
) => {
const { minUSDCPurchaseAmountCents, maxUSDCPurchaseAmountCents } =
useUSDCPurchaseConfig(useRemoteVar)

switch (errorCode) {
case BuyUSDCErrorCode.MinAmountNotMet:
return messages.minimumPurchase(minUSDCPurchaseAmountCents)
case BuyUSDCErrorCode.MaxAmountExceeded:
return messages.maximumPurchase(maxUSDCPurchaseAmountCents)
case BuyUSDCErrorCode.OnrampError:
case PurchaseErrorCode.Unknown:
return messages.generic
}
}
36 changes: 36 additions & 0 deletions packages/common/src/hooks/purchaseContent/useUSDCPurchaseConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { useMemo } from 'react'

import { IntKeys } from 'services/remote-config'

import { RemoteVarHook } from '../useRemoteVar'

import { USDCPurchaseConfig } from './types'

/** Fetches the USDC/purchase content remote config values */
export const useUSDCPurchaseConfig = (
useRemoteVar: RemoteVarHook
): USDCPurchaseConfig => {
const minContentPriceCents = useRemoteVar(IntKeys.MIN_CONTENT_PRICE_CENTS)
const maxContentPriceCents = useRemoteVar(IntKeys.MAX_CONTENT_PRICE_CENTS)
const minUSDCPurchaseAmountCents = useRemoteVar(
IntKeys.MIN_USDC_PURCHASE_AMOUNT_CENTS
)
const maxUSDCPurchaseAmountCents = useRemoteVar(
IntKeys.MAX_USDC_PURCHASE_AMOUNT_CENTS
)

return useMemo(
() => ({
minContentPriceCents,
maxContentPriceCents,
minUSDCPurchaseAmountCents,
maxUSDCPurchaseAmountCents
}),
[
minContentPriceCents,
maxContentPriceCents,
minUSDCPurchaseAmountCents,
maxUSDCPurchaseAmountCents
]
)
}
2 changes: 2 additions & 0 deletions packages/common/src/hooks/useRemoteVar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,5 @@ export const createUseRemoteVarHook = ({

return useRemoteVar
}

export type RemoteVarHook = ReturnType<typeof createUseRemoteVarHook>
9 changes: 9 additions & 0 deletions packages/common/src/services/remote-config/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ const ETH_PROVIDER_URLS = process.env.REACT_APP_ETH_PROVIDER_URL || ''
const DEFAULT_ENTRY_TTL = 1 /* min */ * 60 /* seconds */ * 1000 /* ms */
const DEFAULT_HANDLE_VERIFICATION_TIMEOUT_MILLIS = 5_000

export const MIN_USDC_PURCHASE_AMOUNT_CENTS = 100
export const MAX_USDC_PURCHASE_AMOUNT_CENTS = 150000
export const MIN_CONTENT_PRICE_CENTS = 100
export const MAX_CONTENT_PRICE_CENTS = 150000

export const remoteConfigIntDefaults: { [key in IntKeys]: number | null } = {
[IntKeys.IMAGE_QUICK_FETCH_TIMEOUT_MS]: 5000,
[IntKeys.IMAGE_QUICK_FETCH_PERFORMANCE_BATCH_SIZE]: 20,
Expand All @@ -29,6 +34,10 @@ export const remoteConfigIntDefaults: { [key in IntKeys]: number | null } = {
[IntKeys.CHALLENGE_CLAIM_COMPLETION_POLL_TIMEOUT_MS]: 10000,
[IntKeys.MIN_AUDIO_PURCHASE_AMOUNT]: 5,
[IntKeys.MAX_AUDIO_PURCHASE_AMOUNT]: 999,
[IntKeys.MIN_USDC_PURCHASE_AMOUNT_CENTS]: MIN_USDC_PURCHASE_AMOUNT_CENTS,
[IntKeys.MAX_USDC_PURCHASE_AMOUNT_CENTS]: MAX_USDC_PURCHASE_AMOUNT_CENTS,
[IntKeys.MIN_CONTENT_PRICE_CENTS]: MIN_CONTENT_PRICE_CENTS,
[IntKeys.MAX_CONTENT_PRICE_CENTS]: MAX_CONTENT_PRICE_CENTS,
[IntKeys.BUY_TOKEN_WALLET_POLL_DELAY_MS]: 1000,
[IntKeys.BUY_TOKEN_WALLET_POLL_MAX_RETRIES]: 120,
[IntKeys.BUY_AUDIO_SLIPPAGE]: 3,
Expand Down
20 changes: 20 additions & 0 deletions packages/common/src/services/remote-config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,26 @@ export enum IntKeys {
*/
MAX_AUDIO_PURCHASE_AMOUNT = 'MAX_AUDIO_PURCHASE_AMOUNT',

/**
* Minimum price for purchasable content in cents
*/
MIN_CONTENT_PRICE_CENTS = 'MIN_CONTENT_PRICE_CENTS',

/**
* Maximum price for purchasable content in cents
*/
MAX_CONTENT_PRICE_CENTS = 'MAX_CONTENT_PRICE_CENTS',

/**
* Minimum USDC (in cents) required to purchase in the BuyUSDC modal
*/
MIN_USDC_PURCHASE_AMOUNT_CENTS = 'MIN_USDC_PURCHASE_AMOUNT_CENTS',

/**
* Maximum USDC (in cents) required to purchase in the BuyUSDC modal
*/
MAX_USDC_PURCHASE_AMOUNT_CENTS = 'MAX_USDC_PURCHASE_AMOUNT_CENTS',

/**
* The time to delay between polls of the user wallet when performing a purchase of $AUDIO/$USDC
*/
Expand Down
57 changes: 30 additions & 27 deletions packages/common/src/store/buy-usdc/sagas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
pollForBalanceChange,
relayTransaction
} from 'services/audius-backend/solana'
import { IntKeys } from 'services/remote-config'
import { getAccountUser } from 'store/account/selectors'
import { getContext } from 'store/effects'
import { getFeePayer } from 'store/solana/selectors'
Expand All @@ -33,25 +32,8 @@ import {
startRecoveryIfNecessary,
recoveryStatusChanged
} from './slice'
import { USDCOnRampProvider } from './types'
import { getUSDCUserBank } from './utils'

// TODO: Configurable min/max usdc purchase amounts?
function* getBuyUSDCRemoteConfig() {
const remoteConfigInstance = yield* getContext('remoteConfigInstance')
yield* call([remoteConfigInstance, remoteConfigInstance.waitForRemoteConfig])
const retryDelayMs =
remoteConfigInstance.getRemoteVar(IntKeys.BUY_TOKEN_WALLET_POLL_DELAY_MS) ??
undefined
const maxRetryCount =
remoteConfigInstance.getRemoteVar(
IntKeys.BUY_TOKEN_WALLET_POLL_MAX_RETRIES
) ?? undefined
return {
maxRetryCount,
retryDelayMs
}
}
import { BuyUSDCError, BuyUSDCErrorCode, USDCOnRampProvider } from './types'
import { getBuyUSDCRemoteConfig, getUSDCUserBank } from './utils'

type PurchaseStepParams = {
desiredAmount: number
Expand Down Expand Up @@ -104,20 +86,20 @@ function* purchaseStep({
)
return {}
} else if (result.failure) {
const error = result.failure.payload?.error
? result.failure.payload.error
: new Error('Unknown error')
const errorString = result.failure.payload?.error
? result.failure.payload.error.message
: 'Unknown error'

yield* call(
track,
make({
eventName: Name.BUY_USDC_ON_RAMP_FAILURE,
provider,
error: error.message
error: errorString
})
)
// Throw up to the flow above this
throw error
throw new BuyUSDCError(BuyUSDCErrorCode.OnrampError, errorString)
}
yield* call(
track,
Expand Down Expand Up @@ -224,13 +206,31 @@ function* doBuyUSDC({
const reportToSentry = yield* getContext('reportToSentry')
const { track, make } = yield* getContext('analytics')
const audiusBackendInstance = yield* getContext('audiusBackendInstance')
const config = yield* call(getBuyUSDCRemoteConfig)

const userBank = yield* getUSDCUserBank()
const rootAccount = yield* call(getRootSolanaAccount, audiusBackendInstance)

try {
if (provider !== USDCOnRampProvider.STRIPE) {
throw new Error('USDC Purchase is only supported via Stripe')
throw new BuyUSDCError(
BuyUSDCErrorCode.OnrampError,
'USDC Purchase is only supported via Stripe'
)
}

if (desiredAmount < config.minUSDCPurchaseAmountCents) {
throw new BuyUSDCError(
BuyUSDCErrorCode.MinAmountNotMet,
`Minimum USDC purchase amount is ${config.minUSDCPurchaseAmountCents} cents`
)
}

if (desiredAmount > config.maxUSDCPurchaseAmountCents) {
throw new BuyUSDCError(
BuyUSDCErrorCode.MaxAmountExceeded,
`Maximum USDC purchase amount is ${config.maxUSDCPurchaseAmountCents} cents`
)
}

yield* put(
Expand Down Expand Up @@ -291,7 +291,10 @@ function* doBuyUSDC({
})
)
} catch (e) {
const error = e as Error
const error =
e instanceof BuyUSDCError
? e
: new BuyUSDCError(BuyUSDCErrorCode.OnrampError, `${e}`)
yield* call(reportToSentry, {
level: ErrorLevel.Error,
error,
Expand Down
18 changes: 13 additions & 5 deletions packages/common/src/store/buy-usdc/slice.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { Action, createSlice, PayloadAction } from '@reduxjs/toolkit'

import { BuyUSDCStage, USDCOnRampProvider, PurchaseInfo } from './types'
import {
BuyUSDCStage,
USDCOnRampProvider,
PurchaseInfo,
BuyUSDCError
} from './types'

type StripeSessionStatus =
| 'initialized'
Expand All @@ -18,7 +23,7 @@ type RecoveryStatus = 'idle' | 'in-progress' | 'success' | 'failure'

type BuyUSDCState = {
stage: BuyUSDCStage
error?: Error
error?: BuyUSDCError
provider: USDCOnRampProvider
onSuccess?: OnSuccess
stripeSessionStatus?: StripeSessionStatus
Expand Down Expand Up @@ -59,10 +64,13 @@ const slice = createSlice({
onrampSucceeded: (state) => {
state.stage = BuyUSDCStage.CONFIRMING_PURCHASE
},
onrampFailed: (state, action: PayloadAction<{ error: Error }>) => {
state.error = new Error(`Stripe onramp failed: ${action.payload}`)
onrampFailed: (_state, _action: PayloadAction<{ error: Error }>) => {
// handled by saga
},
buyUSDCFlowFailed: (state, action: PayloadAction<{ error: Error }>) => {
buyUSDCFlowFailed: (
state,
action: PayloadAction<{ error: BuyUSDCError }>
) => {
state.error = action.payload.error
},
buyUSDCFlowSucceeded: (state) => {
Expand Down
12 changes: 12 additions & 0 deletions packages/common/src/store/buy-usdc/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,15 @@ export enum BuyUSDCStage {
CANCELED = 'CANCELED',
FINISH = 'FINISH'
}

export enum BuyUSDCErrorCode {
MinAmountNotMet = 'MinAmountNotMet',
MaxAmountExceeded = 'MaxAmountExceeded',
OnrampError = 'OnrampError'
}

export class BuyUSDCError extends Error {
constructor(public code: BuyUSDCErrorCode, message: string) {
super(message)
}
}
42 changes: 42 additions & 0 deletions packages/common/src/store/buy-usdc/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { call, select } from 'typed-redux-saga'

import { createUserBankIfNeeded } from 'services/audius-backend/solana'
import { IntKeys } from 'services/index'
import {
MAX_CONTENT_PRICE_CENTS,
MAX_USDC_PURCHASE_AMOUNT_CENTS,
MIN_CONTENT_PRICE_CENTS,
MIN_USDC_PURCHASE_AMOUNT_CENTS
} from 'services/remote-config/defaults'
import { getContext } from 'store/effects'
import { getFeePayer } from 'store/solana/selectors'

Expand All @@ -22,3 +29,38 @@ export function* getUSDCUserBank(ethAddress?: string) {
recordAnalytics: track
})
}

export function* getBuyUSDCRemoteConfig() {
const remoteConfigInstance = yield* getContext('remoteConfigInstance')
yield* call([remoteConfigInstance, remoteConfigInstance.waitForRemoteConfig])

const minContentPriceCents =
remoteConfigInstance.getRemoteVar(IntKeys.MIN_CONTENT_PRICE_CENTS) ??
MIN_CONTENT_PRICE_CENTS
const maxContentPriceCents =
remoteConfigInstance.getRemoteVar(IntKeys.MAX_CONTENT_PRICE_CENTS) ??
MAX_CONTENT_PRICE_CENTS
const minUSDCPurchaseAmountCents =
remoteConfigInstance.getRemoteVar(IntKeys.MIN_USDC_PURCHASE_AMOUNT_CENTS) ??
MIN_USDC_PURCHASE_AMOUNT_CENTS
const maxUSDCPurchaseAmountCents =
remoteConfigInstance.getRemoteVar(IntKeys.MAX_USDC_PURCHASE_AMOUNT_CENTS) ??
MAX_USDC_PURCHASE_AMOUNT_CENTS

const retryDelayMs =
remoteConfigInstance.getRemoteVar(IntKeys.BUY_TOKEN_WALLET_POLL_DELAY_MS) ??
undefined
const maxRetryCount =
remoteConfigInstance.getRemoteVar(
IntKeys.BUY_TOKEN_WALLET_POLL_MAX_RETRIES
) ?? undefined

return {
minContentPriceCents,
maxContentPriceCents,
minUSDCPurchaseAmountCents,
maxUSDCPurchaseAmountCents,
maxRetryCount,
retryDelayMs
}
}
Loading

0 comments on commit 8afa18c

Please sign in to comment.