Support backend for claiming manalinks
This commit is contained in:
parent
69bd2b62db
commit
7fff5a6f75
|
@ -17,11 +17,9 @@ export type Manalink = {
|
|||
maxUses: number
|
||||
|
||||
// Used for simpler caching
|
||||
successUserIds: string[]
|
||||
claimedUserIds: string[]
|
||||
// Successful redemptions of the link
|
||||
successes: Claim[]
|
||||
// Failed redemptions of the link
|
||||
failures: Claim[]
|
||||
claims: Claim[]
|
||||
}
|
||||
|
||||
type Claim = {
|
||||
|
|
|
@ -13,7 +13,7 @@ export type Txn = {
|
|||
amount: number
|
||||
token: 'M$' // | 'USD' | MarketOutcome
|
||||
|
||||
category: 'CHARITY' // | 'BET' | 'TIP'
|
||||
category: 'CHARITY' | 'MANALINK' // | 'BET' | 'TIP'
|
||||
// Human-readable description
|
||||
description?: string
|
||||
}
|
||||
|
|
81
functions/src/claim-manalink.ts
Normal file
81
functions/src/claim-manalink.ts
Normal file
|
@ -0,0 +1,81 @@
|
|||
import * as functions from 'firebase-functions'
|
||||
import * as admin from 'firebase-admin'
|
||||
|
||||
import { User } from 'common/user'
|
||||
import { Manalink } from 'common/manalink'
|
||||
import { runTxn, TxnData } from './transact'
|
||||
|
||||
export const claimManalink = functions
|
||||
.runWith({ minInstances: 1 })
|
||||
.https.onCall(async (slug: string, context) => {
|
||||
const userId = context?.auth?.uid
|
||||
if (!userId) return { status: 'error', message: 'Not authorized' }
|
||||
|
||||
// Run as transaction to prevent race conditions.
|
||||
return await firestore.runTransaction(async (transaction) => {
|
||||
// Look up the manalink
|
||||
const manalinkDoc = firestore.doc(`manalinks/${slug}`)
|
||||
const manalinkSnap = await transaction.get(manalinkDoc)
|
||||
if (!manalinkSnap.exists) {
|
||||
return { status: 'error', message: 'Link not found' }
|
||||
}
|
||||
const manalink = manalinkSnap.data() as Manalink
|
||||
|
||||
const { amount, fromId, claimedUserIds } = manalink
|
||||
|
||||
if (amount <= 0 || isNaN(amount) || !isFinite(amount))
|
||||
return { status: 'error', message: 'Invalid amount' }
|
||||
|
||||
// Only permit one redemption per user per link
|
||||
if (claimedUserIds.includes(userId)) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: `${userId} already redeemed link ${slug}`,
|
||||
}
|
||||
}
|
||||
|
||||
const fromDoc = firestore.doc(`users/${fromId}`)
|
||||
const fromSnap = await transaction.get(fromDoc)
|
||||
if (!fromSnap.exists) {
|
||||
return { status: 'error', message: `User ${fromId} not found` }
|
||||
}
|
||||
const fromUser = fromSnap.data() as User
|
||||
|
||||
if (fromUser.balance < amount) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: `Insufficient balance: ${fromUser.username} needed ${amount} for this manalink but only had ${fromUser.balance} `,
|
||||
}
|
||||
}
|
||||
|
||||
// Actually execute the txn
|
||||
const data: TxnData = {
|
||||
fromId,
|
||||
fromType: 'USER',
|
||||
toId: userId,
|
||||
toType: 'USER',
|
||||
amount,
|
||||
token: 'M$',
|
||||
category: 'MANALINK',
|
||||
description: `Manalink ${slug} claimed: ${amount} from ${fromUser.username} to ${userId}`,
|
||||
}
|
||||
const result = await runTxn(transaction, data)
|
||||
const txnId = result.txn?.id
|
||||
if (!txnId) {
|
||||
return { status: 'error', message: result.message }
|
||||
}
|
||||
|
||||
// Update the manalink object with this info
|
||||
const claim = {
|
||||
toId: userId,
|
||||
txnId,
|
||||
claimedTime: Date.now(),
|
||||
}
|
||||
transaction.update(manalinkDoc, {
|
||||
claimedUserIds: [...claimedUserIds, userId],
|
||||
claims: [...manalink.claims, claim],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const firestore = admin.firestore()
|
|
@ -5,84 +5,79 @@ import { User } from 'common/user'
|
|||
import { Txn } from 'common/txn'
|
||||
import { removeUndefinedProps } from 'common/util/object'
|
||||
|
||||
export type TxnData = Omit<Txn, 'id' | 'createdTime'>
|
||||
|
||||
export const transact = functions
|
||||
.runWith({ minInstances: 1 })
|
||||
.https.onCall(async (data: Omit<Txn, 'id' | 'createdTime'>, context) => {
|
||||
.https.onCall(async (data: TxnData, context) => {
|
||||
const userId = context?.auth?.uid
|
||||
if (!userId) return { status: 'error', message: 'Not authorized' }
|
||||
|
||||
const { amount, fromType, fromId, toId, toType, description } = data
|
||||
|
||||
if (fromType !== 'USER')
|
||||
return {
|
||||
status: 'error',
|
||||
message: "From type is only implemented for type 'user'.",
|
||||
}
|
||||
|
||||
if (fromId !== userId)
|
||||
if (userId !== data.fromId) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: 'Must be authenticated with userId equal to specified fromId.',
|
||||
}
|
||||
|
||||
if (amount <= 0 || isNaN(amount) || !isFinite(amount))
|
||||
return { status: 'error', message: 'Invalid amount' }
|
||||
}
|
||||
|
||||
// Run as transaction to prevent race conditions.
|
||||
return await firestore.runTransaction(async (transaction) => {
|
||||
const fromDoc = firestore.doc(`users/${userId}`)
|
||||
const fromSnap = await transaction.get(fromDoc)
|
||||
if (!fromSnap.exists) {
|
||||
return { status: 'error', message: 'User not found' }
|
||||
}
|
||||
const fromUser = fromSnap.data() as User
|
||||
|
||||
if (fromUser.balance < amount) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: `Insufficient balance: ${fromUser.username} needed ${amount} but only had ${fromUser.balance} `,
|
||||
}
|
||||
}
|
||||
|
||||
if (toType === 'USER') {
|
||||
const toDoc = firestore.doc(`users/${toId}`)
|
||||
const toSnap = await transaction.get(toDoc)
|
||||
if (!toSnap.exists) {
|
||||
return { status: 'error', message: 'User not found' }
|
||||
}
|
||||
const toUser = toSnap.data() as User
|
||||
transaction.update(toDoc, {
|
||||
balance: toUser.balance + amount,
|
||||
totalDeposits: toUser.totalDeposits + amount,
|
||||
})
|
||||
}
|
||||
|
||||
const newTxnDoc = firestore.collection(`txns/`).doc()
|
||||
|
||||
const txn: Txn = removeUndefinedProps({
|
||||
id: newTxnDoc.id,
|
||||
createdTime: Date.now(),
|
||||
|
||||
fromId,
|
||||
fromType,
|
||||
toId,
|
||||
toType,
|
||||
|
||||
amount,
|
||||
// TODO: Unhardcode once we have non-donation txns
|
||||
token: 'M$',
|
||||
category: 'CHARITY',
|
||||
description,
|
||||
})
|
||||
|
||||
transaction.create(newTxnDoc, txn)
|
||||
transaction.update(fromDoc, {
|
||||
balance: fromUser.balance - amount,
|
||||
totalDeposits: fromUser.totalDeposits - amount,
|
||||
})
|
||||
|
||||
return { status: 'success', txn }
|
||||
await runTxn(transaction, data)
|
||||
})
|
||||
})
|
||||
|
||||
export async function runTxn(
|
||||
fbTransaction: admin.firestore.Transaction,
|
||||
data: TxnData
|
||||
) {
|
||||
const { amount, fromType, fromId, toId, toType, description } = data
|
||||
|
||||
if (fromType !== 'USER')
|
||||
return {
|
||||
status: 'error',
|
||||
message: "From type is only implemented for type 'user'.",
|
||||
}
|
||||
|
||||
if (amount <= 0 || isNaN(amount) || !isFinite(amount))
|
||||
return { status: 'error', message: 'Invalid amount' }
|
||||
|
||||
const fromDoc = firestore.doc(`users/${fromId}`)
|
||||
const fromSnap = await fbTransaction.get(fromDoc)
|
||||
if (!fromSnap.exists) {
|
||||
return { status: 'error', message: 'User not found' }
|
||||
}
|
||||
const fromUser = fromSnap.data() as User
|
||||
|
||||
if (fromUser.balance < amount) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: `Insufficient balance: ${fromUser.username} needed ${amount} but only had ${fromUser.balance} `,
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Track payments received by charities, bank, contracts too.
|
||||
if (toType === 'USER') {
|
||||
const toDoc = firestore.doc(`users/${toId}`)
|
||||
const toSnap = await fbTransaction.get(toDoc)
|
||||
if (!toSnap.exists) {
|
||||
return { status: 'error', message: 'User not found' }
|
||||
}
|
||||
const toUser = toSnap.data() as User
|
||||
fbTransaction.update(toDoc, {
|
||||
balance: toUser.balance + amount,
|
||||
totalDeposits: toUser.totalDeposits + amount,
|
||||
})
|
||||
}
|
||||
|
||||
const newTxnDoc = firestore.collection(`txns/`).doc()
|
||||
const txn: Txn = { id: newTxnDoc.id, createdTime: Date.now(), ...data }
|
||||
fbTransaction.create(newTxnDoc, removeUndefinedProps(txn))
|
||||
fbTransaction.update(fromDoc, {
|
||||
balance: fromUser.balance - amount,
|
||||
totalDeposits: fromUser.totalDeposits - amount,
|
||||
})
|
||||
|
||||
return { status: 'success', txn }
|
||||
}
|
||||
|
||||
const firestore = admin.firestore()
|
||||
|
|
|
@ -34,7 +34,7 @@ export async function createManalink(data: {
|
|||
createdTime: Date.now(),
|
||||
expiresTime,
|
||||
maxUses,
|
||||
successUserIds: [],
|
||||
claimedUserIds: [],
|
||||
successes: [],
|
||||
failures: [],
|
||||
}
|
||||
|
|
|
@ -138,7 +138,7 @@ function LinksTable(props: { links: Manalink[] }) {
|
|||
{`http://manifold.markets/send/${manalink.slug}`}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
{manalink.successUserIds.length}
|
||||
{manalink.claimedUserIds.length}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
|
||||
{manalink.maxUses === Infinity ? '∞' : manalink.maxUses}
|
||||
|
|
Loading…
Reference in New Issue
Block a user