Skip to content

Commit dfc6c60

Browse files
authored
[PAY-1707] Implements usage of existing balance during content purchases (#3883)
1 parent 5b4242f commit dfc6c60

File tree

11 files changed

+226
-34
lines changed

11 files changed

+226
-34
lines changed

packages/common/src/hooks/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,4 @@ export * from './useDebouncedCallback'
1919
export * from './useSavedCollections'
2020
export * from './chats'
2121
export * from './useGeneratePlaylistArtwork'
22+
export * from './useUSDCBalance'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { useEffect, useState } from 'react'
2+
3+
import BN from 'bn.js'
4+
5+
import { Status } from 'models/Status'
6+
import { BNUSDC } from 'models/Wallet'
7+
import { getUserbankAccountInfo } from 'services/index'
8+
import { useAppContext } from 'src/context/appContext'
9+
10+
/**
11+
* On mount, fetches the USDC balance for the current user
12+
*/
13+
export const useUSDCBalance = () => {
14+
const { audiusBackend } = useAppContext()
15+
const [status, setStatus] = useState(Status.IDLE)
16+
const [data, setData] = useState<BNUSDC>()
17+
18+
useEffect(() => {
19+
const fetch = async () => {
20+
setStatus(Status.LOADING)
21+
try {
22+
const account = await getUserbankAccountInfo(audiusBackend, {
23+
mint: 'usdc'
24+
})
25+
const balance = (account?.amount ?? new BN(0)) as BNUSDC
26+
setData(balance)
27+
setStatus(Status.SUCCESS)
28+
} catch (e) {
29+
setStatus(Status.ERROR)
30+
}
31+
}
32+
fetch()
33+
}, [audiusBackend])
34+
35+
return { status, data }
36+
}

packages/common/src/models/Status.ts

+16
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,19 @@ export enum Status {
99
export function statusIsNotFinalized(status: Status) {
1010
return [Status.IDLE, Status.LOADING].includes(status)
1111
}
12+
13+
/**
14+
* Reduces an array of `Status` values to the least-complete status or `ERROR` if present.
15+
*/
16+
export function combineStatuses(statuses: Status[]) {
17+
if (statuses.includes(Status.ERROR)) {
18+
return Status.ERROR
19+
}
20+
if (statuses.includes(Status.LOADING)) {
21+
return Status.LOADING
22+
}
23+
if (statuses.includes(Status.IDLE)) {
24+
return Status.IDLE
25+
}
26+
return Status.SUCCESS
27+
}

packages/common/src/services/audius-backend/solana.ts

+31-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { AudiusLibs } from '@audius/sdk'
2-
import { u64 } from '@solana/spl-token'
2+
import { AccountInfo, u64 } from '@solana/spl-token'
33
import { PublicKey } from '@solana/web3.js'
44
import BN from 'bn.js'
55

@@ -55,7 +55,7 @@ export const getTokenAccountInfo = async (
5555
mint?: MintName
5656
tokenAccount: PublicKey
5757
}
58-
) => {
58+
): Promise<AccountInfo | null> => {
5959
return (
6060
await audiusBackendInstance.getAudiusLibs()
6161
).solanaWeb3Manager!.getTokenAccountInfo(tokenAccount.toString(), mint)
@@ -106,6 +106,35 @@ function isCreateUserBankIfNeededError(
106106
return 'error' in res
107107
}
108108

109+
/**
110+
* Returns the userbank account info for the given address and mint. If the
111+
* userbank does not exist, returns null.
112+
*/
113+
export const getUserbankAccountInfo = async (
114+
audiusBackendInstance: AudiusBackend,
115+
{ ethAddress: sourceEthAddress, mint = DEFAULT_MINT }: UserBankConfig = {}
116+
): Promise<AccountInfo | null> => {
117+
const audiusLibs: AudiusLibs = await audiusBackendInstance.getAudiusLibs()
118+
const ethAddress =
119+
sourceEthAddress ?? audiusLibs.Account!.getCurrentUser()?.wallet
120+
121+
if (!ethAddress) {
122+
throw new Error(
123+
`getUserbankAccountInfo: unexpected error getting eth address`
124+
)
125+
}
126+
127+
const tokenAccount = await deriveUserBankPubkey(audiusBackendInstance, {
128+
ethAddress,
129+
mint
130+
})
131+
132+
return getTokenAccountInfo(audiusBackendInstance, {
133+
tokenAccount,
134+
mint
135+
})
136+
}
137+
109138
/**
110139
* Attempts to create a userbank if one does not exist.
111140
* Defaults to AUDIO mint and the current user's wallet.

packages/common/src/store/buy-usdc/sagas.ts

+3
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ function* purchaseStep({
6767
tokenAccount
6868
}
6969
)
70+
if (!initialAccountInfo) {
71+
throw new Error('Could not get userbank account info')
72+
}
7073
const initialBalance = initialAccountInfo.amount
7174

7275
yield* put(purchaseStarted())

packages/common/src/store/purchase-content/sagas.ts

+16-4
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Name } from 'models/Analytics'
66
import { ErrorLevel } from 'models/ErrorReporting'
77
import { ID } from 'models/Identifiers'
88
import { isPremiumContentUSDCPurchaseGated } from 'models/Track'
9+
import { BNUSDC } from 'models/Wallet'
910
import {
1011
getTokenAccountInfo,
1112
purchaseContent
@@ -23,7 +24,7 @@ import { getTrack } from 'store/cache/tracks/selectors'
2324
import { getUser } from 'store/cache/users/selectors'
2425
import { getContext } from 'store/effects'
2526
import { setVisibility } from 'store/ui/modals/slice'
26-
import { BN_USDC_CENT_WEI } from 'utils/wallet'
27+
import { BN_USDC_CENT_WEI, ceilingBNUSDCToNearestCent } from 'utils/wallet'
2728

2829
import { pollPremiumTrack } from '../premium-content/sagas'
2930
import { updatePremiumTrackStatus } from '../premium-content/slice'
@@ -149,23 +150,34 @@ function* doStartPurchaseContentFlow({
149150
// get user bank
150151
const userBank = yield* call(getUSDCUserBank)
151152

152-
const { amount: initialBalance } = yield* call(
153+
const tokenAccountInfo = yield* call(
153154
getTokenAccountInfo,
154155
audiusBackendInstance,
155156
{
156157
mint: 'usdc',
157158
tokenAccount: userBank
158159
}
159160
)
161+
if (!tokenAccountInfo) {
162+
throw new Error('Failed to fetch USDC token account info')
163+
}
164+
165+
const { amount: initialBalance } = tokenAccountInfo
166+
167+
const priceBN = new BN(price).mul(BN_USDC_CENT_WEI)
168+
const balanceNeeded: BNUSDC = priceBN.sub(initialBalance) as BNUSDC
160169

161170
// buy USDC if necessary
162-
if (initialBalance.lt(new BN(price).mul(BN_USDC_CENT_WEI))) {
171+
if (balanceNeeded.gtn(0)) {
172+
const balanceNeededCents = ceilingBNUSDCToNearestCent(balanceNeeded)
173+
.div(BN_USDC_CENT_WEI)
174+
.toNumber()
163175
yield* put(buyUSDC())
164176
yield* put(
165177
onrampOpened({
166178
provider: USDCOnRampProvider.STRIPE,
167179
purchaseInfo: {
168-
desiredAmount: price
180+
desiredAmount: balanceNeededCents
169181
}
170182
})
171183
)

packages/web/src/components/premium-content-purchase-modal/PremiumContentPurchaseModal.tsx

+21-6
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import { useCallback } from 'react'
22

33
import {
4+
combineStatuses,
45
premiumContentSelectors,
56
purchaseContentActions,
6-
useGetTrackById
7+
statusIsNotFinalized,
8+
useGetTrackById,
9+
useUSDCBalance
710
} from '@audius/common'
811
import { IconCart, Modal, ModalContentPages, ModalHeader } from '@audius/stems'
912
import { useDispatch, useSelector } from 'react-redux'
@@ -27,21 +30,33 @@ enum PurchaseSteps {
2730
DETAILS = 1
2831
}
2932

30-
export const PremiumContentPurchaseModal = () => {
31-
const [isOpen, setIsOpen] = useModalState('PremiumContentPurchase')
33+
const usePremiumContentPurchaseModalState = () => {
3234
const trackId = useSelector(getPurchaseContentId)
3335
const dispatch = useDispatch()
34-
const { data: track } = useGetTrackById(
36+
const [isOpen, setIsOpen] = useModalState('PremiumContentPurchase')
37+
const { data: balance, status: balanceStatus } = useUSDCBalance()
38+
const { data: track, status: trackStatus } = useGetTrackById(
3539
{ id: trackId! },
3640
{ disabled: !trackId }
3741
)
3842

43+
const status = combineStatuses([balanceStatus, trackStatus])
44+
3945
const handleClose = useCallback(() => {
4046
setIsOpen(false)
4147
dispatch(purchaseContentActions.cleanup())
4248
}, [setIsOpen, dispatch])
4349

44-
const currentStep = !track ? PurchaseSteps.LOADING : PurchaseSteps.DETAILS
50+
const currentStep = statusIsNotFinalized(status)
51+
? PurchaseSteps.LOADING
52+
: PurchaseSteps.DETAILS
53+
54+
return { isOpen, handleClose, currentStep, track, balance, status }
55+
}
56+
57+
export const PremiumContentPurchaseModal = () => {
58+
const { balance, isOpen, handleClose, currentStep, track } =
59+
usePremiumContentPurchaseModalState()
4560

4661
return (
4762
<Modal
@@ -65,7 +80,7 @@ export const PremiumContentPurchaseModal = () => {
6580
{track ? (
6681
<ModalContentPages currentPage={currentStep}>
6782
<LoadingPage />
68-
<PurchaseDetailsPage track={track} />
83+
<PurchaseDetailsPage track={track} currentBalance={balance} />
6984
</ModalContentPages>
7085
) : null}
7186
</Modal>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
.container {
2+
display: inline-flex;
3+
align-items: center;
4+
gap: var(--unit-2);
5+
}
6+
7+
.existingBalance {
8+
text-decoration: none;
9+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { formatPrice } from '@audius/common'
2+
import cn from 'classnames'
3+
4+
import styles from './FormatPrice.module.css'
5+
6+
type FormatPriceProps = {
7+
amountDue: number
8+
basePrice: number
9+
className?: string
10+
}
11+
12+
export const FormatPrice = ({
13+
amountDue,
14+
basePrice,
15+
className
16+
}: FormatPriceProps) => {
17+
if (basePrice === amountDue)
18+
return (
19+
<span className={cn(styles.container, className)}>{`$${formatPrice(
20+
amountDue
21+
)}`}</span>
22+
)
23+
return (
24+
<span className={cn(styles.container, className)}>
25+
<del>{`$${formatPrice(basePrice)}`}</del>
26+
<ins className={styles.existingBalance}>{`$${
27+
amountDue === 0 ? '0' : formatPrice(amountDue)
28+
}`}</ins>
29+
</span>
30+
)
31+
}

0 commit comments

Comments
 (0)