From ec45dfa3118f95db78359d8cfca33d1210653910 Mon Sep 17 00:00:00 2001 From: Austin Chen Date: Thu, 21 Apr 2022 04:09:31 -0700 Subject: [PATCH] Set up cloud function for writing txns --- common/txn.ts | 31 +++++++++++++++ firestore.rules | 4 ++ functions/src/transact.ts | 82 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+) create mode 100644 common/txn.ts create mode 100644 functions/src/transact.ts diff --git a/common/txn.ts b/common/txn.ts new file mode 100644 index 00000000..c81bce08 --- /dev/null +++ b/common/txn.ts @@ -0,0 +1,31 @@ +// A txn (pronounced "texan") respresents a payment between two ids on Manifold +// Shortened from "transaction" to distinguish from Firebase transactions (and save chars) +export type Txn = { + id: string + createdTime: number + + fromId: string + fromName: string + fromUsername: string + fromAvatarUrl?: string + + toId: string + toName: string + toUsername: string + toAvatarUrl?: string + + amount: number + + category: TxnCategory + // Human-readable description + description?: string + // Structured metadata for different kinds of txns + data?: TxnData +} + +export type TxnCategory = 'TO_CHARITY' // | 'TIP' | 'BET' | ... +export type TxnData = CharityData // | TipData | BetData | ... + +export type CharityData = { + // TODO: Could fill this in +} diff --git a/firestore.rules b/firestore.rules index 09d65aac..03f1f488 100644 --- a/firestore.rules +++ b/firestore.rules @@ -64,5 +64,9 @@ service cloud.firestore { allow read; allow write: if request.auth.uid == userId; } + + match /txns/{txnId} { + allow read; + } } } \ No newline at end of file diff --git a/functions/src/transact.ts b/functions/src/transact.ts new file mode 100644 index 00000000..20c33e3c --- /dev/null +++ b/functions/src/transact.ts @@ -0,0 +1,82 @@ +import * as functions from 'firebase-functions' +import * as admin from 'firebase-admin' + +import { User } from '../../common/user' +import { Txn, TxnCategory, TxnData } from '../../common/txn' + +export const transact = functions.runWith({ minInstances: 1 }).https.onCall( + async ( + data: { + amount: number + toId: string + category: TxnCategory + description?: string + txnData?: TxnData + }, + context + ) => { + const fromId = context?.auth?.uid + if (!fromId) return { status: 'error', message: 'Not authorized' } + + const { amount, toId, category, description, txnData } = data + + 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/${fromId}`) + const fromSnap = await transaction.get(fromDoc) + if (!fromSnap.exists) { + return { status: 'error', message: 'User not found' } + } + const fromUser = fromSnap.data() as 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 + + if (fromUser.balance < amount) { + return { + status: 'error', + message: `Insufficient balance: ${fromUser.username} needed ${amount} but only had ${fromUser.balance} `, + } + } + + const newTxnDoc = firestore.collection(`txns/`).doc() + + const txn: Txn = { + id: newTxnDoc.id, + // @ts-ignore - this is a firestore doc + createdTime: admin.firestore.FieldValue.serverTimestamp(), + + fromId, + fromName: fromUser.name, + fromUsername: fromUser.username, + fromAvatarUrl: fromUser.avatarUrl, + + toId, + toName: toUser.name, + toUsername: toUser.username, + toAvatarUrl: toUser.avatarUrl, + + amount, + + category, + description, + data: txnData, + } + + transaction.create(newTxnDoc, txn) + transaction.update(fromDoc, { balance: fromUser.balance - amount }) + transaction.update(toDoc, { balance: toUser.balance + amount }) + + return { status: 'success', txnId: newTxnDoc.id } + }) + } +) + +const firestore = admin.firestore()