Loans2: Bets return you some capital every week (#588)
* Remove some old loan code * Almost complete implementation of updateLoans cloud function * Merge fixes * Use invested instead of sale value, check if eligible, perform payouts * Run monday 12am * Implement loan income notification * Fix imports * Loan update fixes / debug * Handle NaN and negative loan calcs * Working loan notification * Loan modal! * Move loans calculation to /common * Better layout * Pay back loan on sell shares * Pay back fraction of loan on redeem * Sell bet loan: negate buy bet's loan * Modal tweaks * Compute and store nextLoanCached for all users * lint * Update loans with newest portfolio * Filter loans to only unresolved contracts * Tweak spacing * Increase memory
This commit is contained in:
parent
3158740ea3
commit
8b7cd20b6f
|
@ -61,5 +61,3 @@ export type fill = {
|
||||||
// I.e. -fill.shares === matchedBet.shares
|
// I.e. -fill.shares === matchedBet.shares
|
||||||
isSale?: boolean
|
isSale?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MAX_LOAN_PER_CONTRACT = 20
|
|
||||||
|
|
138
common/loans.ts
Normal file
138
common/loans.ts
Normal file
|
@ -0,0 +1,138 @@
|
||||||
|
import { Dictionary, groupBy, sumBy, minBy } from 'lodash'
|
||||||
|
import { Bet } from './bet'
|
||||||
|
import { getContractBetMetrics } from './calculate'
|
||||||
|
import {
|
||||||
|
Contract,
|
||||||
|
CPMMContract,
|
||||||
|
FreeResponseContract,
|
||||||
|
MultipleChoiceContract,
|
||||||
|
} from './contract'
|
||||||
|
import { PortfolioMetrics, User } from './user'
|
||||||
|
import { filterDefined } from './util/array'
|
||||||
|
|
||||||
|
const LOAN_WEEKLY_RATE = 0.05
|
||||||
|
|
||||||
|
const calculateNewLoan = (investedValue: number, loanTotal: number) => {
|
||||||
|
const netValue = investedValue - loanTotal
|
||||||
|
return netValue * LOAN_WEEKLY_RATE
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getLoanUpdates = (
|
||||||
|
users: User[],
|
||||||
|
contractsById: { [contractId: string]: Contract },
|
||||||
|
portfolioByUser: { [userId: string]: PortfolioMetrics | undefined },
|
||||||
|
betsByUser: { [userId: string]: Bet[] }
|
||||||
|
) => {
|
||||||
|
const eligibleUsers = filterDefined(
|
||||||
|
users.map((user) =>
|
||||||
|
isUserEligibleForLoan(portfolioByUser[user.id]) ? user : undefined
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const betUpdates = eligibleUsers
|
||||||
|
.map((user) => {
|
||||||
|
const updates = calculateLoanBetUpdates(
|
||||||
|
betsByUser[user.id] ?? [],
|
||||||
|
contractsById
|
||||||
|
).betUpdates
|
||||||
|
return updates.map((update) => ({ ...update, user }))
|
||||||
|
})
|
||||||
|
.flat()
|
||||||
|
|
||||||
|
const updatesByUser = groupBy(betUpdates, (update) => update.userId)
|
||||||
|
const userPayouts = Object.values(updatesByUser).map((updates) => {
|
||||||
|
return {
|
||||||
|
user: updates[0].user,
|
||||||
|
payout: sumBy(updates, (update) => update.newLoan),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
betUpdates,
|
||||||
|
userPayouts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUserEligibleForLoan = (portfolio: PortfolioMetrics | undefined) => {
|
||||||
|
if (!portfolio) return true
|
||||||
|
|
||||||
|
const { balance, investmentValue } = portfolio
|
||||||
|
return balance + investmentValue > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateLoanBetUpdates = (
|
||||||
|
bets: Bet[],
|
||||||
|
contractsById: Dictionary<Contract>
|
||||||
|
) => {
|
||||||
|
const betsByContract = groupBy(bets, (bet) => bet.contractId)
|
||||||
|
const contracts = filterDefined(
|
||||||
|
Object.keys(betsByContract).map((contractId) => contractsById[contractId])
|
||||||
|
).filter((c) => !c.isResolved)
|
||||||
|
|
||||||
|
const betUpdates = filterDefined(
|
||||||
|
contracts
|
||||||
|
.map((c) => {
|
||||||
|
if (c.mechanism === 'cpmm-1') {
|
||||||
|
return getBinaryContractLoanUpdate(c, betsByContract[c.id])
|
||||||
|
} else if (
|
||||||
|
c.outcomeType === 'FREE_RESPONSE' ||
|
||||||
|
c.outcomeType === 'MULTIPLE_CHOICE'
|
||||||
|
)
|
||||||
|
return getFreeResponseContractLoanUpdate(c, betsByContract[c.id])
|
||||||
|
else {
|
||||||
|
// Unsupported contract / mechanism for loans.
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.flat()
|
||||||
|
)
|
||||||
|
|
||||||
|
const totalNewLoan = sumBy(betUpdates, (loanUpdate) => loanUpdate.loanTotal)
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalNewLoan,
|
||||||
|
betUpdates,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getBinaryContractLoanUpdate = (contract: CPMMContract, bets: Bet[]) => {
|
||||||
|
const { invested } = getContractBetMetrics(contract, bets)
|
||||||
|
const loanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0)
|
||||||
|
const oldestBet = minBy(bets, (bet) => bet.createdTime)
|
||||||
|
|
||||||
|
const newLoan = calculateNewLoan(invested, loanAmount)
|
||||||
|
if (isNaN(newLoan) || newLoan <= 0 || !oldestBet) return undefined
|
||||||
|
|
||||||
|
const loanTotal = (oldestBet.loanAmount ?? 0) + newLoan
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: oldestBet.userId,
|
||||||
|
contractId: contract.id,
|
||||||
|
betId: oldestBet.id,
|
||||||
|
newLoan,
|
||||||
|
loanTotal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFreeResponseContractLoanUpdate = (
|
||||||
|
contract: FreeResponseContract | MultipleChoiceContract,
|
||||||
|
bets: Bet[]
|
||||||
|
) => {
|
||||||
|
const openBets = bets.filter((bet) => bet.isSold || bet.sale)
|
||||||
|
|
||||||
|
return openBets.map((bet) => {
|
||||||
|
const loanAmount = bet.loanAmount ?? 0
|
||||||
|
const newLoan = calculateNewLoan(bet.amount, loanAmount)
|
||||||
|
const loanTotal = loanAmount + newLoan
|
||||||
|
|
||||||
|
if (isNaN(newLoan) || newLoan <= 0) return undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: bet.userId,
|
||||||
|
contractId: contract.id,
|
||||||
|
betId: bet.id,
|
||||||
|
newLoan,
|
||||||
|
loanTotal,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
import { sortBy, sum, sumBy } from 'lodash'
|
import { sortBy, sum, sumBy } from 'lodash'
|
||||||
|
|
||||||
import { Bet, fill, LimitBet, MAX_LOAN_PER_CONTRACT, NumericBet } from './bet'
|
import { Bet, fill, LimitBet, NumericBet } from './bet'
|
||||||
import {
|
import {
|
||||||
calculateDpmShares,
|
calculateDpmShares,
|
||||||
getDpmProbability,
|
getDpmProbability,
|
||||||
|
@ -276,8 +276,7 @@ export const getBinaryBetStats = (
|
||||||
export const getNewBinaryDpmBetInfo = (
|
export const getNewBinaryDpmBetInfo = (
|
||||||
outcome: 'YES' | 'NO',
|
outcome: 'YES' | 'NO',
|
||||||
amount: number,
|
amount: number,
|
||||||
contract: DPMBinaryContract,
|
contract: DPMBinaryContract
|
||||||
loanAmount: number
|
|
||||||
) => {
|
) => {
|
||||||
const { YES: yesPool, NO: noPool } = contract.pool
|
const { YES: yesPool, NO: noPool } = contract.pool
|
||||||
|
|
||||||
|
@ -308,7 +307,7 @@ export const getNewBinaryDpmBetInfo = (
|
||||||
const newBet: CandidateBet = {
|
const newBet: CandidateBet = {
|
||||||
contractId: contract.id,
|
contractId: contract.id,
|
||||||
amount,
|
amount,
|
||||||
loanAmount,
|
loanAmount: 0,
|
||||||
shares,
|
shares,
|
||||||
outcome,
|
outcome,
|
||||||
probBefore,
|
probBefore,
|
||||||
|
@ -324,7 +323,6 @@ export const getNewMultiBetInfo = (
|
||||||
outcome: string,
|
outcome: string,
|
||||||
amount: number,
|
amount: number,
|
||||||
contract: FreeResponseContract | MultipleChoiceContract,
|
contract: FreeResponseContract | MultipleChoiceContract,
|
||||||
loanAmount: number
|
|
||||||
) => {
|
) => {
|
||||||
const { pool, totalShares, totalBets } = contract
|
const { pool, totalShares, totalBets } = contract
|
||||||
|
|
||||||
|
@ -345,7 +343,7 @@ export const getNewMultiBetInfo = (
|
||||||
const newBet: CandidateBet = {
|
const newBet: CandidateBet = {
|
||||||
contractId: contract.id,
|
contractId: contract.id,
|
||||||
amount,
|
amount,
|
||||||
loanAmount,
|
loanAmount: 0,
|
||||||
shares,
|
shares,
|
||||||
outcome,
|
outcome,
|
||||||
probBefore,
|
probBefore,
|
||||||
|
@ -399,13 +397,3 @@ export const getNumericBetsInfo = (
|
||||||
|
|
||||||
return { newBet, newPool, newTotalShares, newTotalBets }
|
return { newBet, newPool, newTotalShares, newTotalBets }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getLoanAmount = (yourBets: Bet[], newBetAmount: number) => {
|
|
||||||
const openBets = yourBets.filter((bet) => !bet.isSold && !bet.sale)
|
|
||||||
const prevLoanAmount = sumBy(openBets, (bet) => bet.loanAmount ?? 0)
|
|
||||||
const loanAmount = Math.min(
|
|
||||||
newBetAmount,
|
|
||||||
MAX_LOAN_PER_CONTRACT - prevLoanAmount
|
|
||||||
)
|
|
||||||
return loanAmount
|
|
||||||
}
|
|
||||||
|
|
|
@ -39,6 +39,7 @@ export type notification_source_types =
|
||||||
| 'bonus'
|
| 'bonus'
|
||||||
| 'challenge'
|
| 'challenge'
|
||||||
| 'betting_streak_bonus'
|
| 'betting_streak_bonus'
|
||||||
|
| 'loan'
|
||||||
|
|
||||||
export type notification_source_update_types =
|
export type notification_source_update_types =
|
||||||
| 'created'
|
| 'created'
|
||||||
|
@ -68,3 +69,4 @@ export type notification_reason_types =
|
||||||
| 'user_joined_from_your_group_invite'
|
| 'user_joined_from_your_group_invite'
|
||||||
| 'challenge_accepted'
|
| 'challenge_accepted'
|
||||||
| 'betting_streak_incremented'
|
| 'betting_streak_incremented'
|
||||||
|
| 'loan_income'
|
||||||
|
|
|
@ -13,8 +13,9 @@ export const getRedeemableAmount = (bets: RedeemableBet[]) => {
|
||||||
const yesShares = sumBy(yesBets, (b) => b.shares)
|
const yesShares = sumBy(yesBets, (b) => b.shares)
|
||||||
const noShares = sumBy(noBets, (b) => b.shares)
|
const noShares = sumBy(noBets, (b) => b.shares)
|
||||||
const shares = Math.max(Math.min(yesShares, noShares), 0)
|
const shares = Math.max(Math.min(yesShares, noShares), 0)
|
||||||
|
const soldFrac = shares > 0 ? Math.min(yesShares, noShares) / shares : 0
|
||||||
const loanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0)
|
const loanAmount = sumBy(bets, (bet) => bet.loanAmount ?? 0)
|
||||||
const loanPayment = Math.min(loanAmount, shares)
|
const loanPayment = loanAmount * soldFrac
|
||||||
const netAmount = shares - loanPayment
|
const netAmount = shares - loanPayment
|
||||||
return { shares, loanPayment, netAmount }
|
return { shares, loanPayment, netAmount }
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,7 +13,7 @@ export type CandidateBet<T extends Bet> = Omit<T, 'id' | 'userId'>
|
||||||
|
|
||||||
export const getSellBetInfo = (bet: Bet, contract: DPMContract) => {
|
export const getSellBetInfo = (bet: Bet, contract: DPMContract) => {
|
||||||
const { pool, totalShares, totalBets } = contract
|
const { pool, totalShares, totalBets } = contract
|
||||||
const { id: betId, amount, shares, outcome } = bet
|
const { id: betId, amount, shares, outcome, loanAmount } = bet
|
||||||
|
|
||||||
const adjShareValue = calculateDpmShareValue(contract, bet)
|
const adjShareValue = calculateDpmShareValue(contract, bet)
|
||||||
|
|
||||||
|
@ -64,6 +64,7 @@ export const getSellBetInfo = (bet: Bet, contract: DPMContract) => {
|
||||||
betId,
|
betId,
|
||||||
},
|
},
|
||||||
fees,
|
fees,
|
||||||
|
loanAmount: -(loanAmount ?? 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -79,8 +80,8 @@ export const getCpmmSellBetInfo = (
|
||||||
shares: number,
|
shares: number,
|
||||||
outcome: 'YES' | 'NO',
|
outcome: 'YES' | 'NO',
|
||||||
contract: CPMMContract,
|
contract: CPMMContract,
|
||||||
prevLoanAmount: number,
|
unfilledBets: LimitBet[],
|
||||||
unfilledBets: LimitBet[]
|
loanPaid: number
|
||||||
) => {
|
) => {
|
||||||
const { pool, p } = contract
|
const { pool, p } = contract
|
||||||
|
|
||||||
|
@ -91,7 +92,6 @@ export const getCpmmSellBetInfo = (
|
||||||
unfilledBets
|
unfilledBets
|
||||||
)
|
)
|
||||||
|
|
||||||
const loanPaid = Math.min(prevLoanAmount, saleValue)
|
|
||||||
const probBefore = getCpmmProbability(pool, p)
|
const probBefore = getCpmmProbability(pool, p)
|
||||||
const probAfter = getCpmmProbability(cpmmState.pool, cpmmState.p)
|
const probAfter = getCpmmProbability(cpmmState.pool, cpmmState.p)
|
||||||
|
|
||||||
|
|
|
@ -32,6 +32,7 @@ export type User = {
|
||||||
allTime: number
|
allTime: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nextLoanCached: number
|
||||||
followerCountCached: number
|
followerCountCached: number
|
||||||
|
|
||||||
followedCategories?: string[]
|
followedCategories?: string[]
|
||||||
|
|
|
@ -75,10 +75,8 @@ export const createanswer = newEndpoint(opts, async (req, auth) => {
|
||||||
}
|
}
|
||||||
transaction.create(newAnswerDoc, answer)
|
transaction.create(newAnswerDoc, answer)
|
||||||
|
|
||||||
const loanAmount = 0
|
|
||||||
|
|
||||||
const { newBet, newPool, newTotalShares, newTotalBets } =
|
const { newBet, newPool, newTotalShares, newTotalBets } =
|
||||||
getNewMultiBetInfo(answerId, amount, contract, loanAmount)
|
getNewMultiBetInfo(answerId, amount, contract)
|
||||||
|
|
||||||
const newBalance = user.balance - amount
|
const newBalance = user.balance - amount
|
||||||
const betDoc = firestore.collection(`contracts/${contractId}/bets`).doc()
|
const betDoc = firestore.collection(`contracts/${contractId}/bets`).doc()
|
||||||
|
|
|
@ -471,6 +471,32 @@ export const createReferralNotification = async (
|
||||||
await notificationRef.set(removeUndefinedProps(notification))
|
await notificationRef.set(removeUndefinedProps(notification))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const createLoanIncomeNotification = async (
|
||||||
|
toUser: User,
|
||||||
|
idempotencyKey: string,
|
||||||
|
income: number
|
||||||
|
) => {
|
||||||
|
const notificationRef = firestore
|
||||||
|
.collection(`/users/${toUser.id}/notifications`)
|
||||||
|
.doc(idempotencyKey)
|
||||||
|
const notification: Notification = {
|
||||||
|
id: idempotencyKey,
|
||||||
|
userId: toUser.id,
|
||||||
|
reason: 'loan_income',
|
||||||
|
createdTime: Date.now(),
|
||||||
|
isSeen: false,
|
||||||
|
sourceId: idempotencyKey,
|
||||||
|
sourceType: 'loan',
|
||||||
|
sourceUpdateType: 'updated',
|
||||||
|
sourceUserName: toUser.name,
|
||||||
|
sourceUserUsername: toUser.username,
|
||||||
|
sourceUserAvatarUrl: toUser.avatarUrl,
|
||||||
|
sourceText: income.toString(),
|
||||||
|
sourceTitle: 'Loan',
|
||||||
|
}
|
||||||
|
await notificationRef.set(removeUndefinedProps(notification))
|
||||||
|
}
|
||||||
|
|
||||||
const groupPath = (groupSlug: string) => `/group/${groupSlug}`
|
const groupPath = (groupSlug: string) => `/group/${groupSlug}`
|
||||||
|
|
||||||
export const createChallengeAcceptedNotification = async (
|
export const createChallengeAcceptedNotification = async (
|
||||||
|
|
|
@ -75,6 +75,7 @@ export const createuser = newEndpoint(opts, async (req, auth) => {
|
||||||
createdTime: Date.now(),
|
createdTime: Date.now(),
|
||||||
profitCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 },
|
profitCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 },
|
||||||
creatorVolumeCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 },
|
creatorVolumeCached: { daily: 0, weekly: 0, monthly: 0, allTime: 0 },
|
||||||
|
nextLoanCached: 0,
|
||||||
followerCountCached: 0,
|
followerCountCached: 0,
|
||||||
followedCategories: DEFAULT_CATEGORIES,
|
followedCategories: DEFAULT_CATEGORIES,
|
||||||
shouldShowWelcome: true,
|
shouldShowWelcome: true,
|
||||||
|
|
|
@ -11,6 +11,7 @@ export * from './on-create-comment-on-contract'
|
||||||
export * from './on-view'
|
export * from './on-view'
|
||||||
export * from './update-metrics'
|
export * from './update-metrics'
|
||||||
export * from './update-stats'
|
export * from './update-stats'
|
||||||
|
export * from './update-loans'
|
||||||
export * from './backup-db'
|
export * from './backup-db'
|
||||||
export * from './market-close-notifications'
|
export * from './market-close-notifications'
|
||||||
export * from './on-create-answer'
|
export * from './on-create-answer'
|
||||||
|
|
|
@ -59,7 +59,6 @@ export const placebet = newEndpoint({}, async (req, auth) => {
|
||||||
const user = userSnap.data() as User
|
const user = userSnap.data() as User
|
||||||
if (user.balance < amount) throw new APIError(400, 'Insufficient balance.')
|
if (user.balance < amount) throw new APIError(400, 'Insufficient balance.')
|
||||||
|
|
||||||
const loanAmount = 0
|
|
||||||
const { closeTime, outcomeType, mechanism, collectedFees, volume } =
|
const { closeTime, outcomeType, mechanism, collectedFees, volume } =
|
||||||
contract
|
contract
|
||||||
if (closeTime && Date.now() > closeTime)
|
if (closeTime && Date.now() > closeTime)
|
||||||
|
@ -119,7 +118,7 @@ export const placebet = newEndpoint({}, async (req, auth) => {
|
||||||
const answerDoc = contractDoc.collection('answers').doc(outcome)
|
const answerDoc = contractDoc.collection('answers').doc(outcome)
|
||||||
const answerSnap = await trans.get(answerDoc)
|
const answerSnap = await trans.get(answerDoc)
|
||||||
if (!answerSnap.exists) throw new APIError(400, 'Invalid answer')
|
if (!answerSnap.exists) throw new APIError(400, 'Invalid answer')
|
||||||
return getNewMultiBetInfo(outcome, amount, contract, loanAmount)
|
return getNewMultiBetInfo(outcome, amount, contract)
|
||||||
} else if (outcomeType == 'NUMERIC' && mechanism == 'dpm-2') {
|
} else if (outcomeType == 'NUMERIC' && mechanism == 'dpm-2') {
|
||||||
const { outcome, value } = validate(numericSchema, req.body)
|
const { outcome, value } = validate(numericSchema, req.body)
|
||||||
return getNumericBetsInfo(value, outcome, amount, contract)
|
return getNumericBetsInfo(value, outcome, amount, contract)
|
||||||
|
|
|
@ -50,11 +50,12 @@ export const sellbet = newEndpoint({}, async (req, auth) => {
|
||||||
|
|
||||||
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
|
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
|
||||||
const saleAmount = newBet.sale!.amount
|
const saleAmount = newBet.sale!.amount
|
||||||
const newBalance = user.balance + saleAmount - (bet.loanAmount ?? 0)
|
const newBalance = user.balance + saleAmount + (newBet.loanAmount ?? 0)
|
||||||
const newBetDoc = firestore.collection(`contracts/${contractId}/bets`).doc()
|
const newBetDoc = firestore.collection(`contracts/${contractId}/bets`).doc()
|
||||||
|
|
||||||
transaction.update(userDoc, { balance: newBalance })
|
transaction.update(userDoc, { balance: newBalance })
|
||||||
transaction.update(betDoc, { isSold: true })
|
transaction.update(betDoc, { isSold: true })
|
||||||
|
// Note: id should have been newBetDoc.id! But leaving it for now so it's consistent.
|
||||||
transaction.create(newBetDoc, { id: betDoc.id, userId: user.id, ...newBet })
|
transaction.create(newBetDoc, { id: betDoc.id, userId: user.id, ...newBet })
|
||||||
transaction.update(
|
transaction.update(
|
||||||
contractDoc,
|
contractDoc,
|
||||||
|
|
|
@ -7,7 +7,7 @@ import { Contract, CPMM_MIN_POOL_QTY } from '../../common/contract'
|
||||||
import { User } from '../../common/user'
|
import { User } from '../../common/user'
|
||||||
import { getCpmmSellBetInfo } from '../../common/sell-bet'
|
import { getCpmmSellBetInfo } from '../../common/sell-bet'
|
||||||
import { addObjects, removeUndefinedProps } from '../../common/util/object'
|
import { addObjects, removeUndefinedProps } from '../../common/util/object'
|
||||||
import { getValues, log } from './utils'
|
import { log } from './utils'
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
import { floatingEqual, floatingLesserEqual } from '../../common/util/math'
|
import { floatingEqual, floatingLesserEqual } from '../../common/util/math'
|
||||||
import { getUnfilledBetsQuery, updateMakers } from './place-bet'
|
import { getUnfilledBetsQuery, updateMakers } from './place-bet'
|
||||||
|
@ -28,12 +28,16 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
|
||||||
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
const contractDoc = firestore.doc(`contracts/${contractId}`)
|
||||||
const userDoc = firestore.doc(`users/${auth.uid}`)
|
const userDoc = firestore.doc(`users/${auth.uid}`)
|
||||||
const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid)
|
const betsQ = contractDoc.collection('bets').where('userId', '==', auth.uid)
|
||||||
const [[contractSnap, userSnap], userBets] = await Promise.all([
|
const [[contractSnap, userSnap], userBetsSnap, unfilledBetsSnap] =
|
||||||
|
await Promise.all([
|
||||||
transaction.getAll(contractDoc, userDoc),
|
transaction.getAll(contractDoc, userDoc),
|
||||||
getValues<Bet>(betsQ), // TODO: why is this not in the transaction??
|
transaction.get(betsQ),
|
||||||
|
transaction.get(getUnfilledBetsQuery(contractDoc)),
|
||||||
])
|
])
|
||||||
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
|
if (!contractSnap.exists) throw new APIError(400, 'Contract not found.')
|
||||||
if (!userSnap.exists) throw new APIError(400, 'User not found.')
|
if (!userSnap.exists) throw new APIError(400, 'User not found.')
|
||||||
|
const userBets = userBetsSnap.docs.map((doc) => doc.data() as Bet)
|
||||||
|
const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data())
|
||||||
|
|
||||||
const contract = contractSnap.data() as Contract
|
const contract = contractSnap.data() as Contract
|
||||||
const user = userSnap.data() as User
|
const user = userSnap.data() as User
|
||||||
|
@ -45,7 +49,7 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
|
||||||
if (closeTime && Date.now() > closeTime)
|
if (closeTime && Date.now() > closeTime)
|
||||||
throw new APIError(400, 'Trading is closed.')
|
throw new APIError(400, 'Trading is closed.')
|
||||||
|
|
||||||
const prevLoanAmount = sumBy(userBets, (bet) => bet.loanAmount ?? 0)
|
const loanAmount = sumBy(userBets, (bet) => bet.loanAmount ?? 0)
|
||||||
const betsByOutcome = groupBy(userBets, (bet) => bet.outcome)
|
const betsByOutcome = groupBy(userBets, (bet) => bet.outcome)
|
||||||
const sharesByOutcome = mapValues(betsByOutcome, (bets) =>
|
const sharesByOutcome = mapValues(betsByOutcome, (bets) =>
|
||||||
sumBy(bets, (b) => b.shares)
|
sumBy(bets, (b) => b.shares)
|
||||||
|
@ -77,18 +81,16 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
|
||||||
throw new APIError(400, `You can only sell up to ${maxShares} shares.`)
|
throw new APIError(400, `You can only sell up to ${maxShares} shares.`)
|
||||||
|
|
||||||
const soldShares = Math.min(sharesToSell, maxShares)
|
const soldShares = Math.min(sharesToSell, maxShares)
|
||||||
|
const saleFrac = soldShares / maxShares
|
||||||
const unfilledBetsSnap = await transaction.get(
|
let loanPaid = saleFrac * loanAmount
|
||||||
getUnfilledBetsQuery(contractDoc)
|
if (!isFinite(loanPaid)) loanPaid = 0
|
||||||
)
|
|
||||||
const unfilledBets = unfilledBetsSnap.docs.map((doc) => doc.data())
|
|
||||||
|
|
||||||
const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo(
|
const { newBet, newPool, newP, fees, makers } = getCpmmSellBetInfo(
|
||||||
soldShares,
|
soldShares,
|
||||||
chosenOutcome,
|
chosenOutcome,
|
||||||
contract,
|
contract,
|
||||||
prevLoanAmount,
|
unfilledBets,
|
||||||
unfilledBets
|
loanPaid
|
||||||
)
|
)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
@ -104,7 +106,7 @@ export const sellshares = newEndpoint({}, async (req, auth) => {
|
||||||
updateMakers(makers, newBetDoc.id, contractDoc, transaction)
|
updateMakers(makers, newBetDoc.id, contractDoc, transaction)
|
||||||
|
|
||||||
transaction.update(userDoc, {
|
transaction.update(userDoc, {
|
||||||
balance: FieldValue.increment(-newBet.amount),
|
balance: FieldValue.increment(-newBet.amount + (newBet.loanAmount ?? 0)),
|
||||||
})
|
})
|
||||||
transaction.create(newBetDoc, {
|
transaction.create(newBetDoc, {
|
||||||
id: newBetDoc.id,
|
id: newBetDoc.id,
|
||||||
|
|
88
functions/src/update-loans.ts
Normal file
88
functions/src/update-loans.ts
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
import * as functions from 'firebase-functions'
|
||||||
|
import * as admin from 'firebase-admin'
|
||||||
|
import { groupBy, keyBy } from 'lodash'
|
||||||
|
import { getValues, log, payUser, writeAsync } from './utils'
|
||||||
|
import { Bet } from '../../common/bet'
|
||||||
|
import { Contract } from '../../common/contract'
|
||||||
|
import { PortfolioMetrics, User } from '../../common/user'
|
||||||
|
import { getLoanUpdates } from '../../common/loans'
|
||||||
|
import { createLoanIncomeNotification } from './create-notification'
|
||||||
|
|
||||||
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
|
export const updateLoans = functions
|
||||||
|
.runWith({ memory: '2GB', timeoutSeconds: 540 })
|
||||||
|
// Run every Monday.
|
||||||
|
.pubsub.schedule('0 0 * * 1')
|
||||||
|
.timeZone('America/Los_Angeles')
|
||||||
|
.onRun(updateLoansCore)
|
||||||
|
|
||||||
|
async function updateLoansCore() {
|
||||||
|
log('Updating loans...')
|
||||||
|
|
||||||
|
const [users, contracts, bets] = await Promise.all([
|
||||||
|
getValues<User>(firestore.collection('users')),
|
||||||
|
getValues<Contract>(
|
||||||
|
firestore.collection('contracts').where('isResolved', '==', false)
|
||||||
|
),
|
||||||
|
getValues<Bet>(firestore.collectionGroup('bets')),
|
||||||
|
])
|
||||||
|
log(
|
||||||
|
`Loaded ${users.length} users, ${contracts.length} contracts, and ${bets.length} bets.`
|
||||||
|
)
|
||||||
|
const userPortfolios = await Promise.all(
|
||||||
|
users.map(async (user) => {
|
||||||
|
const portfolio = await getValues<PortfolioMetrics>(
|
||||||
|
firestore
|
||||||
|
.collection(`users/${user.id}/portfolioHistory`)
|
||||||
|
.orderBy('timestamp', 'desc')
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
return portfolio[0]
|
||||||
|
})
|
||||||
|
)
|
||||||
|
log(`Loaded ${userPortfolios.length} portfolios`)
|
||||||
|
const portfolioByUser = keyBy(userPortfolios, (portfolio) => portfolio.userId)
|
||||||
|
|
||||||
|
const contractsById = Object.fromEntries(
|
||||||
|
contracts.map((contract) => [contract.id, contract])
|
||||||
|
)
|
||||||
|
const betsByUser = groupBy(bets, (bet) => bet.userId)
|
||||||
|
const { betUpdates, userPayouts } = getLoanUpdates(
|
||||||
|
users,
|
||||||
|
contractsById,
|
||||||
|
portfolioByUser,
|
||||||
|
betsByUser
|
||||||
|
)
|
||||||
|
|
||||||
|
log(`${betUpdates.length} bet updates.`)
|
||||||
|
|
||||||
|
const betDocUpdates = betUpdates.map((update) => ({
|
||||||
|
doc: firestore
|
||||||
|
.collection('contracts')
|
||||||
|
.doc(update.contractId)
|
||||||
|
.collection('bets')
|
||||||
|
.doc(update.betId),
|
||||||
|
fields: {
|
||||||
|
loanAmount: update.loanTotal,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
await writeAsync(firestore, betDocUpdates)
|
||||||
|
|
||||||
|
log(`${userPayouts.length} user payouts`)
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
userPayouts.map(({ user, payout }) => payUser(user.id, payout))
|
||||||
|
)
|
||||||
|
|
||||||
|
const today = new Date().toDateString().replace(' ', '-')
|
||||||
|
const key = `loan-notifications-${today}`
|
||||||
|
await Promise.all(
|
||||||
|
userPayouts.map(({ user, payout }) =>
|
||||||
|
createLoanIncomeNotification(user, key, payout)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
log('Notifications sent!')
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
import * as functions from 'firebase-functions'
|
import * as functions from 'firebase-functions'
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { groupBy, isEmpty, sum, sumBy } from 'lodash'
|
import { groupBy, isEmpty, keyBy, sum, sumBy } from 'lodash'
|
||||||
import { getValues, log, logMemory, writeAsync } from './utils'
|
import { getValues, log, logMemory, writeAsync } from './utils'
|
||||||
import { Bet } from '../../common/bet'
|
import { Bet } from '../../common/bet'
|
||||||
import { Contract } from '../../common/contract'
|
import { Contract } from '../../common/contract'
|
||||||
|
@ -8,6 +8,7 @@ import { PortfolioMetrics, User } from '../../common/user'
|
||||||
import { calculatePayout } from '../../common/calculate'
|
import { calculatePayout } from '../../common/calculate'
|
||||||
import { DAY_MS } from '../../common/util/time'
|
import { DAY_MS } from '../../common/util/time'
|
||||||
import { last } from 'lodash'
|
import { last } from 'lodash'
|
||||||
|
import { getLoanUpdates } from '../../common/loans'
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
|
@ -21,7 +22,9 @@ const computeInvestmentValue = (
|
||||||
if (bet.sale || bet.isSold) return 0
|
if (bet.sale || bet.isSold) return 0
|
||||||
|
|
||||||
const payout = calculatePayout(contract, bet, 'MKT')
|
const payout = calculatePayout(contract, bet, 'MKT')
|
||||||
return payout - (bet.loanAmount ?? 0)
|
const value = payout - (bet.loanAmount ?? 0)
|
||||||
|
if (isNaN(value)) return 0
|
||||||
|
return value
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -71,7 +74,8 @@ export const updateMetricsCore = async () => {
|
||||||
const contractsByUser = groupBy(contracts, (contract) => contract.creatorId)
|
const contractsByUser = groupBy(contracts, (contract) => contract.creatorId)
|
||||||
const betsByUser = groupBy(bets, (bet) => bet.userId)
|
const betsByUser = groupBy(bets, (bet) => bet.userId)
|
||||||
const portfolioHistoryByUser = groupBy(allPortfolioHistories, (p) => p.userId)
|
const portfolioHistoryByUser = groupBy(allPortfolioHistories, (p) => p.userId)
|
||||||
const userUpdates = users.map((user) => {
|
|
||||||
|
const userMetrics = users.map((user) => {
|
||||||
const currentBets = betsByUser[user.id] ?? []
|
const currentBets = betsByUser[user.id] ?? []
|
||||||
const portfolioHistory = portfolioHistoryByUser[user.id] ?? []
|
const portfolioHistory = portfolioHistoryByUser[user.id] ?? []
|
||||||
const userContracts = contractsByUser[user.id] ?? []
|
const userContracts = contractsByUser[user.id] ?? []
|
||||||
|
@ -93,7 +97,29 @@ export const updateMetricsCore = async () => {
|
||||||
newPortfolio,
|
newPortfolio,
|
||||||
didProfitChange
|
didProfitChange
|
||||||
)
|
)
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
newCreatorVolume,
|
||||||
|
newPortfolio,
|
||||||
|
newProfit,
|
||||||
|
didProfitChange,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const portfolioByUser = Object.fromEntries(
|
||||||
|
userMetrics.map(({ user, newPortfolio }) => [user.id, newPortfolio])
|
||||||
|
)
|
||||||
|
const { userPayouts } = getLoanUpdates(
|
||||||
|
users,
|
||||||
|
contractsById,
|
||||||
|
portfolioByUser,
|
||||||
|
betsByUser
|
||||||
|
)
|
||||||
|
const nextLoanByUser = keyBy(userPayouts, (payout) => payout.user.id)
|
||||||
|
|
||||||
|
const userUpdates = userMetrics.map(
|
||||||
|
({ user, newCreatorVolume, newPortfolio, newProfit, didProfitChange }) => {
|
||||||
|
const nextLoanCached = nextLoanByUser[user.id]?.payout ?? 0
|
||||||
return {
|
return {
|
||||||
fieldUpdates: {
|
fieldUpdates: {
|
||||||
doc: firestore.collection('users').doc(user.id),
|
doc: firestore.collection('users').doc(user.id),
|
||||||
|
@ -102,6 +128,7 @@ export const updateMetricsCore = async () => {
|
||||||
...(didProfitChange && {
|
...(didProfitChange && {
|
||||||
profitCached: newProfit,
|
profitCached: newProfit,
|
||||||
}),
|
}),
|
||||||
|
nextLoanCached,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -118,7 +145,8 @@ export const updateMetricsCore = async () => {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
)
|
||||||
await writeAsync(
|
await writeAsync(
|
||||||
firestore,
|
firestore,
|
||||||
userUpdates.map((u) => u.fieldUpdates)
|
userUpdates.map((u) => u.fieldUpdates)
|
||||||
|
@ -234,6 +262,6 @@ const calculateNewProfit = (
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateMetrics = functions
|
export const updateMetrics = functions
|
||||||
.runWith({ memory: '1GB', timeoutSeconds: 540 })
|
.runWith({ memory: '2GB', timeoutSeconds: 540 })
|
||||||
.pubsub.schedule('every 15 minutes')
|
.pubsub.schedule('every 15 minutes')
|
||||||
.onRun(updateMetricsCore)
|
.onRun(updateMetricsCore)
|
||||||
|
|
|
@ -16,7 +16,7 @@ export function BettingStreakModal(props: {
|
||||||
<Modal open={isOpen} setOpen={setOpen}>
|
<Modal open={isOpen} setOpen={setOpen}>
|
||||||
<Col className="items-center gap-4 rounded-md bg-white px-8 py-6">
|
<Col className="items-center gap-4 rounded-md bg-white px-8 py-6">
|
||||||
<span className={'text-8xl'}>🔥</span>
|
<span className={'text-8xl'}>🔥</span>
|
||||||
<span>Daily betting streaks</span>
|
<span className="text-xl">Daily betting streaks</span>
|
||||||
<Col className={'gap-2'}>
|
<Col className={'gap-2'}>
|
||||||
<span className={'text-indigo-700'}>• What are they?</span>
|
<span className={'text-indigo-700'}>• What are they?</span>
|
||||||
<span className={'ml-2'}>
|
<span className={'ml-2'}>
|
||||||
|
|
47
web/components/profile/loans-modal.tsx
Normal file
47
web/components/profile/loans-modal.tsx
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
import { Modal } from 'web/components/layout/modal'
|
||||||
|
import { Col } from 'web/components/layout/col'
|
||||||
|
|
||||||
|
export function LoansModal(props: {
|
||||||
|
isOpen: boolean
|
||||||
|
setOpen: (open: boolean) => void
|
||||||
|
}) {
|
||||||
|
const { isOpen, setOpen } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={isOpen} setOpen={setOpen}>
|
||||||
|
<Col className="items-center gap-4 rounded-md bg-white px-8 py-6">
|
||||||
|
<span className={'text-8xl'}>🏦</span>
|
||||||
|
<span className="text-xl">Loans on your bets</span>
|
||||||
|
<Col className={'gap-2'}>
|
||||||
|
<span className={'text-indigo-700'}>• What are loans?</span>
|
||||||
|
<span className={'ml-2'}>
|
||||||
|
Every Monday, get 5% of your total bet amount back as a loan.
|
||||||
|
</span>
|
||||||
|
<span className={'text-indigo-700'}>
|
||||||
|
• Do I have to pay back a loan?
|
||||||
|
</span>
|
||||||
|
<span className={'ml-2'}>
|
||||||
|
Yes, don't worry! You will automatically pay back loans when the
|
||||||
|
market resolves or you sell your bet.
|
||||||
|
</span>
|
||||||
|
<span className={'text-indigo-700'}>
|
||||||
|
• What is the purpose of loans?
|
||||||
|
</span>
|
||||||
|
<span className={'ml-2'}>
|
||||||
|
Loans make it worthwhile to bet on markets that won't resolve for
|
||||||
|
months or years, because your investment won't be locked up as long.
|
||||||
|
</span>
|
||||||
|
<span className={'text-indigo-700'}>• What is an example?</span>
|
||||||
|
<span className={'ml-2'}>
|
||||||
|
For example, if you bet M$100 on "Will I become a millionare?" on
|
||||||
|
Sunday, you will get M$5 back on Monday.
|
||||||
|
</span>
|
||||||
|
<span className={'ml-2'}>
|
||||||
|
Previous loans count against your total bet amount. So, the next
|
||||||
|
week, you would get back 5% of M$95 = M$4.75.
|
||||||
|
</span>
|
||||||
|
</Col>
|
||||||
|
</Col>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
|
@ -29,6 +29,7 @@ import { formatMoney } from 'common/util/format'
|
||||||
import { ShareIconButton } from 'web/components/share-icon-button'
|
import { ShareIconButton } from 'web/components/share-icon-button'
|
||||||
import { ENV_CONFIG } from 'common/envs/constants'
|
import { ENV_CONFIG } from 'common/envs/constants'
|
||||||
import { BettingStreakModal } from 'web/components/profile/betting-streak-modal'
|
import { BettingStreakModal } from 'web/components/profile/betting-streak-modal'
|
||||||
|
import { LoansModal } from './profile/loans-modal'
|
||||||
import { REFERRAL_AMOUNT } from 'common/user'
|
import { REFERRAL_AMOUNT } from 'common/user'
|
||||||
|
|
||||||
export function UserLink(props: {
|
export function UserLink(props: {
|
||||||
|
@ -68,6 +69,7 @@ export function UserPage(props: { user: User }) {
|
||||||
const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id)
|
const bannerUrl = user.bannerUrl ?? defaultBannerUrl(user.id)
|
||||||
const [showConfetti, setShowConfetti] = useState(false)
|
const [showConfetti, setShowConfetti] = useState(false)
|
||||||
const [showBettingStreakModal, setShowBettingStreakModal] = useState(false)
|
const [showBettingStreakModal, setShowBettingStreakModal] = useState(false)
|
||||||
|
const [showLoansModal, setShowLoansModal] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const claimedMana = router.query['claimed-mana'] === 'yes'
|
const claimedMana = router.query['claimed-mana'] === 'yes'
|
||||||
|
@ -75,6 +77,9 @@ export function UserPage(props: { user: User }) {
|
||||||
setShowBettingStreakModal(showBettingStreak)
|
setShowBettingStreakModal(showBettingStreak)
|
||||||
setShowConfetti(claimedMana || showBettingStreak)
|
setShowConfetti(claimedMana || showBettingStreak)
|
||||||
|
|
||||||
|
const showLoansModel = router.query['show'] === 'loans'
|
||||||
|
setShowLoansModal(showLoansModel)
|
||||||
|
|
||||||
const query = { ...router.query }
|
const query = { ...router.query }
|
||||||
if (query.claimedMana || query.show) {
|
if (query.claimedMana || query.show) {
|
||||||
delete query['claimed-mana']
|
delete query['claimed-mana']
|
||||||
|
@ -107,6 +112,9 @@ export function UserPage(props: { user: User }) {
|
||||||
isOpen={showBettingStreakModal}
|
isOpen={showBettingStreakModal}
|
||||||
setOpen={setShowBettingStreakModal}
|
setOpen={setShowBettingStreakModal}
|
||||||
/>
|
/>
|
||||||
|
{showLoansModal && (
|
||||||
|
<LoansModal isOpen={showLoansModal} setOpen={setShowLoansModal} />
|
||||||
|
)}
|
||||||
{/* Banner image up top, with an circle avatar overlaid */}
|
{/* Banner image up top, with an circle avatar overlaid */}
|
||||||
<div
|
<div
|
||||||
className="h-32 w-full bg-cover bg-center sm:h-40"
|
className="h-32 w-full bg-cover bg-center sm:h-40"
|
||||||
|
@ -128,7 +136,7 @@ export function UserPage(props: { user: User }) {
|
||||||
<div className="absolute right-0 top-0 mt-2 mr-4">
|
<div className="absolute right-0 top-0 mt-2 mr-4">
|
||||||
{!isCurrentUser && <UserFollowButton userId={user.id} />}
|
{!isCurrentUser && <UserFollowButton userId={user.id} />}
|
||||||
{isCurrentUser && (
|
{isCurrentUser && (
|
||||||
<SiteLink className="sm:btn-md btn-sm btn" href="/profile">
|
<SiteLink className="btn-sm btn" href="/profile">
|
||||||
<PencilIcon className="h-5 w-5" />{' '}
|
<PencilIcon className="h-5 w-5" />{' '}
|
||||||
<div className="ml-2">Edit</div>
|
<div className="ml-2">Edit</div>
|
||||||
</SiteLink>
|
</SiteLink>
|
||||||
|
@ -138,9 +146,14 @@ export function UserPage(props: { user: User }) {
|
||||||
|
|
||||||
{/* Profile details: name, username, bio, and link to twitter/discord */}
|
{/* Profile details: name, username, bio, and link to twitter/discord */}
|
||||||
<Col className="mx-4 -mt-6">
|
<Col className="mx-4 -mt-6">
|
||||||
<Row className={'justify-between'}>
|
<Row className={'flex-wrap justify-between gap-y-2'}>
|
||||||
<Col>
|
<Col>
|
||||||
<span className="text-2xl font-bold">{user.name}</span>
|
<span
|
||||||
|
className="text-2xl font-bold"
|
||||||
|
style={{ wordBreak: 'break-word' }}
|
||||||
|
>
|
||||||
|
{user.name}
|
||||||
|
</span>
|
||||||
<span className="text-gray-500">@{user.username}</span>
|
<span className="text-gray-500">@{user.username}</span>
|
||||||
</Col>
|
</Col>
|
||||||
<Col className={'justify-center'}>
|
<Col className={'justify-center'}>
|
||||||
|
@ -163,6 +176,17 @@ export function UserPage(props: { user: User }) {
|
||||||
<span>🔥 {user.currentBettingStreak ?? 0}</span>
|
<span>🔥 {user.currentBettingStreak ?? 0}</span>
|
||||||
<span>streak</span>
|
<span>streak</span>
|
||||||
</Col>
|
</Col>
|
||||||
|
<Col
|
||||||
|
className={
|
||||||
|
'flex-shrink-0 cursor-pointer items-center text-gray-500'
|
||||||
|
}
|
||||||
|
onClick={() => setShowLoansModal(true)}
|
||||||
|
>
|
||||||
|
<span className="text-green-600">
|
||||||
|
🏦 {formatMoney(user.nextLoanCached ?? 0)}
|
||||||
|
</span>
|
||||||
|
<span>next loan</span>
|
||||||
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
|
@ -5,7 +5,7 @@ import {
|
||||||
getNotificationsQuery,
|
getNotificationsQuery,
|
||||||
listenForNotifications,
|
listenForNotifications,
|
||||||
} from 'web/lib/firebase/notifications'
|
} from 'web/lib/firebase/notifications'
|
||||||
import { groupBy, map } from 'lodash'
|
import { groupBy, map, partition } from 'lodash'
|
||||||
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
|
import { useFirestoreQueryData } from '@react-query-firebase/firestore'
|
||||||
import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications'
|
import { NOTIFICATIONS_PER_PAGE } from 'web/pages/notifications'
|
||||||
|
|
||||||
|
@ -67,19 +67,14 @@ export function groupNotifications(notifications: Notification[]) {
|
||||||
const notificationGroupsByDay = groupBy(notifications, (notification) =>
|
const notificationGroupsByDay = groupBy(notifications, (notification) =>
|
||||||
new Date(notification.createdTime).toDateString()
|
new Date(notification.createdTime).toDateString()
|
||||||
)
|
)
|
||||||
|
const incomeSourceTypes = ['bonus', 'tip', 'loan', 'betting_streak_bonus']
|
||||||
|
|
||||||
Object.keys(notificationGroupsByDay).forEach((day) => {
|
Object.keys(notificationGroupsByDay).forEach((day) => {
|
||||||
const notificationsGroupedByDay = notificationGroupsByDay[day]
|
const notificationsGroupedByDay = notificationGroupsByDay[day]
|
||||||
const incomeNotifications = notificationsGroupedByDay.filter(
|
const [incomeNotifications, normalNotificationsGroupedByDay] = partition(
|
||||||
|
notificationsGroupedByDay,
|
||||||
(notification) =>
|
(notification) =>
|
||||||
notification.sourceType === 'bonus' ||
|
incomeSourceTypes.includes(notification.sourceType ?? '')
|
||||||
notification.sourceType === 'tip' ||
|
|
||||||
notification.sourceType === 'betting_streak_bonus'
|
|
||||||
)
|
|
||||||
const normalNotificationsGroupedByDay = notificationsGroupedByDay.filter(
|
|
||||||
(notification) =>
|
|
||||||
notification.sourceType !== 'bonus' &&
|
|
||||||
notification.sourceType !== 'tip' &&
|
|
||||||
notification.sourceType !== 'betting_streak_bonus'
|
|
||||||
)
|
)
|
||||||
if (incomeNotifications.length > 0) {
|
if (incomeNotifications.length > 0) {
|
||||||
notificationGroups = notificationGroups.concat({
|
notificationGroups = notificationGroups.concat({
|
||||||
|
|
|
@ -388,6 +388,8 @@ function IncomeNotificationItem(props: {
|
||||||
reasonText = !simple ? `tipped you on` : `in tips on`
|
reasonText = !simple ? `tipped you on` : `in tips on`
|
||||||
} else if (sourceType === 'betting_streak_bonus') {
|
} else if (sourceType === 'betting_streak_bonus') {
|
||||||
reasonText = 'for your'
|
reasonText = 'for your'
|
||||||
|
} else if (sourceType === 'loan' && sourceText) {
|
||||||
|
reasonText = `of your invested bets returned as`
|
||||||
}
|
}
|
||||||
|
|
||||||
const bettingStreakText =
|
const bettingStreakText =
|
||||||
|
@ -401,7 +403,15 @@ function IncomeNotificationItem(props: {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{reasonText}
|
{reasonText}
|
||||||
{sourceType === 'betting_streak_bonus' ? (
|
{sourceType === 'loan' ? (
|
||||||
|
simple ? (
|
||||||
|
<span className={'ml-1 font-bold'}>Loan</span>
|
||||||
|
) : (
|
||||||
|
<SiteLink className={'ml-1 font-bold'} href={'/loans'}>
|
||||||
|
Loan
|
||||||
|
</SiteLink>
|
||||||
|
)
|
||||||
|
) : sourceType === 'betting_streak_bonus' ? (
|
||||||
simple ? (
|
simple ? (
|
||||||
<span className={'ml-1 font-bold'}>{bettingStreakText}</span>
|
<span className={'ml-1 font-bold'}>{bettingStreakText}</span>
|
||||||
) : (
|
) : (
|
||||||
|
@ -445,6 +455,7 @@ function IncomeNotificationItem(props: {
|
||||||
if (sourceType === 'challenge') return `${sourceSlug}`
|
if (sourceType === 'challenge') return `${sourceSlug}`
|
||||||
if (sourceType === 'betting_streak_bonus')
|
if (sourceType === 'betting_streak_bonus')
|
||||||
return `/${sourceUserUsername}/?show=betting-streak`
|
return `/${sourceUserUsername}/?show=betting-streak`
|
||||||
|
if (sourceType === 'loan') return `/${sourceUserUsername}/?show=loans`
|
||||||
if (sourceContractCreatorUsername && sourceContractSlug)
|
if (sourceContractCreatorUsername && sourceContractSlug)
|
||||||
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent(
|
return `/${sourceContractCreatorUsername}/${sourceContractSlug}#${getSourceIdForLinkComponent(
|
||||||
sourceId ?? '',
|
sourceId ?? '',
|
||||||
|
|
Loading…
Reference in New Issue
Block a user