diff --git a/common/manalink.ts b/common/manalink.ts index 0e5e9daf..2032072f 100644 --- a/common/manalink.ts +++ b/common/manalink.ts @@ -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 = { diff --git a/common/txn.ts b/common/txn.ts index 8beea234..82f51f91 100644 --- a/common/txn.ts +++ b/common/txn.ts @@ -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 } diff --git a/functions/src/claim-manalink.ts b/functions/src/claim-manalink.ts new file mode 100644 index 00000000..6140b90a --- /dev/null +++ b/functions/src/claim-manalink.ts @@ -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() diff --git a/functions/src/transact.ts b/functions/src/transact.ts index 77323638..3d0c5a7c 100644 --- a/functions/src/transact.ts +++ b/functions/src/transact.ts @@ -5,84 +5,79 @@ import { User } from 'common/user' import { Txn } from 'common/txn' import { removeUndefinedProps } from 'common/util/object' +export type TxnData = Omit + export const transact = functions .runWith({ minInstances: 1 }) - .https.onCall(async (data: Omit, 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() diff --git a/web/lib/firebase/manalinks.ts b/web/lib/firebase/manalinks.ts index 8cd303f4..777a1e33 100644 --- a/web/lib/firebase/manalinks.ts +++ b/web/lib/firebase/manalinks.ts @@ -34,7 +34,7 @@ export async function createManalink(data: { createdTime: Date.now(), expiresTime, maxUses, - successUserIds: [], + claimedUserIds: [], successes: [], failures: [], } diff --git a/web/pages/send.tsx b/web/pages/send.tsx index 28d5cf08..2aaa768e 100644 --- a/web/pages/send.tsx +++ b/web/pages/send.tsx @@ -138,7 +138,7 @@ function LinksTable(props: { links: Manalink[] }) { {`http://manifold.markets/send/${manalink.slug}`} - {manalink.successUserIds.length} + {manalink.claimedUserIds.length} {manalink.maxUses === Infinity ? '∞' : manalink.maxUses}