diff --git a/backend/src/auth/RIGHTS.ts b/backend/src/auth/RIGHTS.ts index 4b9036eed..a2e92a2cc 100644 --- a/backend/src/auth/RIGHTS.ts +++ b/backend/src/auth/RIGHTS.ts @@ -21,6 +21,8 @@ export enum RIGHTS { CREATE_TRANSACTION_LINK = 'CREATE_TRANSACTION_LINK', DELETE_TRANSACTION_LINK = 'DELETE_TRANSACTION_LINK', QUERY_TRANSACTION_LINK = 'QUERY_TRANSACTION_LINK', + REDEEM_TRANSACTION_LINK = 'REDEEM_TRANSACTION_LINK', + LIST_TRANSACTION_LINKS = 'LIST_TRANSACTION_LINKS', // Admin SEARCH_USERS = 'SEARCH_USERS', CREATE_PENDING_CREATION = 'CREATE_PENDING_CREATION', diff --git a/backend/src/auth/ROLES.ts b/backend/src/auth/ROLES.ts index 2a86b5bab..82c689848 100644 --- a/backend/src/auth/ROLES.ts +++ b/backend/src/auth/ROLES.ts @@ -20,6 +20,8 @@ export const ROLE_USER = new Role('user', [ RIGHTS.HAS_ELOPAGE, RIGHTS.CREATE_TRANSACTION_LINK, RIGHTS.DELETE_TRANSACTION_LINK, + RIGHTS.REDEEM_TRANSACTION_LINK, + RIGHTS.LIST_TRANSACTION_LINKS, ]) export const ROLE_ADMIN = new Role('admin', Object.values(RIGHTS)) // all rights diff --git a/backend/src/graphql/arg/QueryTransactionLinkArgs.ts b/backend/src/graphql/arg/QueryTransactionLinkArgs.ts deleted file mode 100644 index 2dcd29572..000000000 --- a/backend/src/graphql/arg/QueryTransactionLinkArgs.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ArgsType, Field, Int } from 'type-graphql' - -@ArgsType() -export default class QueryTransactionLinkArgs { - @Field(() => String) - code: string - - @Field(() => Int, { nullable: true }) - redeemUserId?: number -} diff --git a/backend/src/graphql/enum/TransactionTypeId.ts b/backend/src/graphql/enum/TransactionTypeId.ts index 497ad5055..ff64f3158 100644 --- a/backend/src/graphql/enum/TransactionTypeId.ts +++ b/backend/src/graphql/enum/TransactionTypeId.ts @@ -6,6 +6,7 @@ export enum TransactionTypeId { RECEIVE = 3, // This is a virtual property, never occurring on the database DECAY = 4, + TRANSACTION_LINK = 5, } registerEnumType(TransactionTypeId, { diff --git a/backend/src/graphql/resolver/TransactionLinkResolver.ts b/backend/src/graphql/resolver/TransactionLinkResolver.ts index 071444de9..835b84cbf 100644 --- a/backend/src/graphql/resolver/TransactionLinkResolver.ts +++ b/backend/src/graphql/resolver/TransactionLinkResolver.ts @@ -5,14 +5,17 @@ import { Resolver, Args, Arg, Authorized, Ctx, Mutation, Query } from 'type-grap import { getCustomRepository } from '@dbTools/typeorm' import { TransactionLink } from '@model/TransactionLink' import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink' -import TransactionLinkArgs from '@arg/TransactionLinkArgs' -import QueryTransactionLinkArgs from '@arg/QueryTransactionLinkArgs' +import { User as dbUser } from '@entity/User' import { UserRepository } from '@repository/User' +import TransactionLinkArgs from '@arg/TransactionLinkArgs' +import Paginated from '@arg/Paginated' import { calculateBalance } from '@/util/validate' import { RIGHTS } from '@/auth/RIGHTS' import { randomBytes } from 'crypto' import { User } from '@model/User' import { calculateDecay } from '@/util/decay' +import { executeTransaction } from './TransactionResolver' +import { Order } from '@enum/Order' // TODO: do not export, test it inside the resolver export const transactionLinkCode = (date: Date): string => { @@ -96,30 +99,72 @@ export class TransactionLinkResolver { @Authorized([RIGHTS.QUERY_TRANSACTION_LINK]) @Query(() => TransactionLink) - async queryTransactionLink( - @Args() { code, redeemUserId }: QueryTransactionLinkArgs, - ): Promise { + async queryTransactionLink(@Arg('code') code: string): Promise { const transactionLink = await dbTransactionLink.findOneOrFail({ code }) - const userRepository = getCustomRepository(UserRepository) - const user = await userRepository.findOneOrFail({ id: transactionLink.userId }) - let userRedeem = null - if (redeemUserId && !transactionLink.redeemedBy) { - const redeemedByUser = await userRepository.findOne({ id: redeemUserId }) - if (!redeemedByUser) { - throw new Error('Unable to find user that redeem the link') - } - userRedeem = new User(redeemedByUser) - transactionLink.redeemedBy = userRedeem.id - await dbTransactionLink.save(transactionLink).catch(() => { - throw new Error('Unable to save transaction link') - }) - } else if (transactionLink.redeemedBy) { - const redeemedByUser = await userRepository.findOne({ id: redeemUserId }) - if (!redeemedByUser) { - throw new Error('Unable to find user that has redeemed the link') - } - userRedeem = new User(redeemedByUser) + const user = await dbUser.findOneOrFail({ id: transactionLink.userId }) + let redeemedBy: User | null = null + if (transactionLink && transactionLink.redeemedBy) { + redeemedBy = new User(await dbUser.findOneOrFail({ id: transactionLink.redeemedBy })) } - return new TransactionLink(transactionLink, new User(user), userRedeem) + return new TransactionLink(transactionLink, new User(user), redeemedBy) + } + + @Authorized([RIGHTS.LIST_TRANSACTION_LINKS]) + @Query(() => [TransactionLink]) + async listTransactionLinks( + @Args() + { currentPage = 1, pageSize = 5, order = Order.DESC }: Paginated, + @Ctx() context: any, + ): Promise { + const userRepository = getCustomRepository(UserRepository) + const user = await userRepository.findByPubkeyHex(context.pubKey) + // const now = new Date() + const transactionLinks = await dbTransactionLink.find({ + where: { + userId: user.id, + redeemedBy: null, + // validUntil: MoreThan(now), + }, + order: { + createdAt: order, + }, + skip: (currentPage - 1) * pageSize, + take: pageSize, + }) + return transactionLinks.map((tl) => new TransactionLink(tl, new User(user))) + } + + @Authorized([RIGHTS.REDEEM_TRANSACTION_LINK]) + @Mutation(() => Boolean) + async redeemTransactionLink(@Arg('id') id: number, @Ctx() context: any): Promise { + const userRepository = getCustomRepository(UserRepository) + const user = await userRepository.findByPubkeyHex(context.pubKey) + const transactionLink = await dbTransactionLink.findOneOrFail({ id }) + const linkedUser = await dbUser.findOneOrFail({ id: transactionLink.userId }) + + const now = new Date() + + if (user.id === linkedUser.id) { + throw new Error('Cannot redeem own transaction link.') + } + + if (transactionLink.validUntil.getTime() < now.getTime()) { + throw new Error('Transaction Link is not valid anymore.') + } + + if (transactionLink.redeemedBy) { + throw new Error('Transaction Link already redeemed.') + } + + await executeTransaction(transactionLink.amount, transactionLink.memo, linkedUser, user) + + // TODO: Rollback transaction when updating links fails + transactionLink.redeemedAt = now + transactionLink.redeemedBy = user.id + transactionLink.save().catch(() => { + throw new Error('Could not update transaction link.') + }) + + return true } } diff --git a/backend/src/graphql/resolver/TransactionResolver.ts b/backend/src/graphql/resolver/TransactionResolver.ts index 1d1a00c39..20b56af7e 100644 --- a/backend/src/graphql/resolver/TransactionResolver.ts +++ b/backend/src/graphql/resolver/TransactionResolver.ts @@ -30,10 +30,87 @@ import { calculateBalance, isHexPublicKey } from '@/util/validate' import { RIGHTS } from '@/auth/RIGHTS' import { User } from '@model/User' import { communityUser } from '@/util/communityUser' -import { virtualDecayTransaction } from '@/util/virtualDecayTransaction' +import { virtualLinkTransaction, virtualDecayTransaction } from '@/util/virtualTransactions' import Decimal from 'decimal.js-light' import { calculateDecay } from '@/util/decay' +export const executeTransaction = async ( + amount: Decimal, + memo: string, + sender: dbUser, + recipient: dbUser, +): Promise => { + if (sender.id === recipient.id) { + throw new Error('Sender and Recipient are the same.') + } + + // validate amount + const receivedCallDate = new Date() + const sendBalance = await calculateBalance(sender.id, amount.mul(-1), receivedCallDate) + if (!sendBalance) { + throw new Error("user hasn't enough GDD or amount is < 0") + } + + const queryRunner = getConnection().createQueryRunner() + await queryRunner.connect() + await queryRunner.startTransaction('READ UNCOMMITTED') + try { + // transaction + const transactionSend = new dbTransaction() + transactionSend.typeId = TransactionTypeId.SEND + transactionSend.memo = memo + transactionSend.userId = sender.id + transactionSend.linkedUserId = recipient.id + transactionSend.amount = amount.mul(-1) + transactionSend.balance = sendBalance.balance + transactionSend.balanceDate = receivedCallDate + transactionSend.decay = sendBalance.decay.decay + transactionSend.decayStart = sendBalance.decay.start + transactionSend.previous = sendBalance.lastTransactionId + await queryRunner.manager.insert(dbTransaction, transactionSend) + + const transactionReceive = new dbTransaction() + transactionReceive.typeId = TransactionTypeId.RECEIVE + transactionReceive.memo = memo + transactionReceive.userId = recipient.id + transactionReceive.linkedUserId = sender.id + transactionReceive.amount = amount + const receiveBalance = await calculateBalance(recipient.id, amount, receivedCallDate) + transactionReceive.balance = receiveBalance ? receiveBalance.balance : amount + transactionReceive.balanceDate = receivedCallDate + transactionReceive.decay = receiveBalance ? receiveBalance.decay.decay : new Decimal(0) + transactionReceive.decayStart = receiveBalance ? receiveBalance.decay.start : null + transactionReceive.previous = receiveBalance ? receiveBalance.lastTransactionId : null + transactionReceive.linkedTransactionId = transactionSend.id + await queryRunner.manager.insert(dbTransaction, transactionReceive) + + // Save linked transaction id for send + transactionSend.linkedTransactionId = transactionReceive.id + await queryRunner.manager.update(dbTransaction, { id: transactionSend.id }, transactionSend) + + await queryRunner.commitTransaction() + } catch (e) { + await queryRunner.rollbackTransaction() + throw new Error(`Transaction was not successful: ${e}`) + } finally { + await queryRunner.release() + } + + // send notification email + // TODO: translate + await sendTransactionReceivedEmail({ + senderFirstName: sender.firstName, + senderLastName: sender.lastName, + recipientFirstName: recipient.firstName, + recipientLastName: recipient.lastName, + email: recipient.email, + amount, + memo, + }) + + return true +} + @Resolver() export class TransactionResolver { @Authorized([RIGHTS.TRANSACTION_LIST]) @@ -112,11 +189,29 @@ export class TransactionResolver { const self = new User(user) const transactions: Transaction[] = [] - // decay transaction + const transactionLinkRepository = getCustomRepository(TransactionLinkRepository) + const { sumHoldAvailableAmount, sumAmount, lastDate, firstDate } = + await transactionLinkRepository.summary(user.id, now) + + // decay & link transactions if (!onlyCreations && currentPage === 1 && order === Order.DESC) { transactions.push( virtualDecayTransaction(lastTransaction.balance, lastTransaction.balanceDate, now, self), ) + // virtual transaction for pending transaction-links sum + if (sumHoldAvailableAmount.greaterThan(0)) { + transactions.push( + virtualLinkTransaction( + lastTransaction.balance.minus(sumHoldAvailableAmount.toString()), + sumAmount, + sumHoldAvailableAmount, + sumHoldAvailableAmount.minus(sumAmount.toString()), + firstDate || now, + lastDate || now, + self, + ), + ) + } } // transactions @@ -128,13 +223,10 @@ export class TransactionResolver { transactions.push(new Transaction(userTransaction, self, linkedUser)) }) - const transactionLinkRepository = getCustomRepository(TransactionLinkRepository) - const toHoldAvailable = await transactionLinkRepository.sumAmountToHoldAvailable(user.id, now) - // Construct Result return new TransactionList( calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, now).balance.minus( - toHoldAvailable.toString(), + sumHoldAvailableAmount.toString(), ), transactions, userTransactionsCount, @@ -154,12 +246,6 @@ export class TransactionResolver { if (senderUser.pubKey.length !== 32) { throw new Error('invalid sender public key') } - // validate amount - const receivedCallDate = new Date() - const sendBalance = await calculateBalance(senderUser.id, amount.mul(-1), receivedCallDate) - if (!sendBalance) { - throw new Error("user hasn't enough GDD or amount is < 0") - } // validate recipient user const recipientUser = await dbUser.findOne({ email: email }, { withDeleted: true }) @@ -173,62 +259,7 @@ export class TransactionResolver { throw new Error('invalid recipient public key') } - const queryRunner = getConnection().createQueryRunner() - await queryRunner.connect() - await queryRunner.startTransaction('READ UNCOMMITTED') - try { - // transaction - const transactionSend = new dbTransaction() - transactionSend.typeId = TransactionTypeId.SEND - transactionSend.memo = memo - transactionSend.userId = senderUser.id - transactionSend.linkedUserId = recipientUser.id - transactionSend.amount = amount.mul(-1) - transactionSend.balance = sendBalance.balance - transactionSend.balanceDate = receivedCallDate - transactionSend.decay = sendBalance.decay.decay - transactionSend.decayStart = sendBalance.decay.start - transactionSend.previous = sendBalance.lastTransactionId - await queryRunner.manager.insert(dbTransaction, transactionSend) - - const transactionReceive = new dbTransaction() - transactionReceive.typeId = TransactionTypeId.RECEIVE - transactionReceive.memo = memo - transactionReceive.userId = recipientUser.id - transactionReceive.linkedUserId = senderUser.id - transactionReceive.amount = amount - const receiveBalance = await calculateBalance(recipientUser.id, amount, receivedCallDate) - transactionReceive.balance = receiveBalance ? receiveBalance.balance : amount - transactionReceive.balanceDate = receivedCallDate - transactionReceive.decay = receiveBalance ? receiveBalance.decay.decay : new Decimal(0) - transactionReceive.decayStart = receiveBalance ? receiveBalance.decay.start : null - transactionReceive.previous = receiveBalance ? receiveBalance.lastTransactionId : null - transactionReceive.linkedTransactionId = transactionSend.id - await queryRunner.manager.insert(dbTransaction, transactionReceive) - - // Save linked transaction id for send - transactionSend.linkedTransactionId = transactionReceive.id - await queryRunner.manager.update(dbTransaction, { id: transactionSend.id }, transactionSend) - - await queryRunner.commitTransaction() - } catch (e) { - await queryRunner.rollbackTransaction() - throw new Error(`Transaction was not successful: ${e}`) - } finally { - await queryRunner.release() - } - - // send notification email - // TODO: translate - await sendTransactionReceivedEmail({ - senderFirstName: senderUser.firstName, - senderLastName: senderUser.lastName, - recipientFirstName: recipientUser.firstName, - recipientLastName: recipientUser.lastName, - email: recipientUser.email, - amount, - memo, - }) + await executeTransaction(amount, memo, senderUser, recipientUser) return true } diff --git a/backend/src/typeorm/repository/TransactionLink.ts b/backend/src/typeorm/repository/TransactionLink.ts index 5f7531bc8..2ce937d8d 100644 --- a/backend/src/typeorm/repository/TransactionLink.ts +++ b/backend/src/typeorm/repository/TransactionLink.ts @@ -4,13 +4,33 @@ import Decimal from 'decimal.js-light' @EntityRepository(dbTransactionLink) export class TransactionLinkRepository extends Repository { - async sumAmountToHoldAvailable(userId: number, date: Date): Promise { - const { sum } = await this.createQueryBuilder('transactionLinks') - .select('SUM(transactionLinks.holdAvailableAmount)', 'sum') - .where('transactionLinks.userId = :userId', { userId }) - .andWhere('transactionLinks.redeemedAt is NULL') - .andWhere('transactionLinks.validUntil > :date', { date }) - .getRawOne() - return sum ? new Decimal(sum) : new Decimal(0) + async summary( + userId: number, + date: Date, + ): Promise<{ + sumHoldAvailableAmount: Decimal + sumAmount: Decimal + lastDate: Date | null + firstDate: Date | null + }> { + const { sumHoldAvailableAmount, sumAmount, lastDate, firstDate } = + await this.createQueryBuilder('transactionLinks') + .select('SUM(transactionLinks.holdAvailableAmount)', 'sumHoldAvailableAmount') + .addSelect('SUM(transactionLinks.amount)', 'sumAmount') + .addSelect('MAX(transactionLinks.validUntil)', 'lastDate') + .addSelect('MIN(transactionLinks.createdAt)', 'firstDate') + .where('transactionLinks.userId = :userId', { userId }) + .andWhere('transactionLinks.redeemedAt is NULL') + .andWhere('transactionLinks.validUntil > :date', { date }) + .orderBy('transactionLinks.createdAt', 'DESC') + .getRawOne() + return { + sumHoldAvailableAmount: sumHoldAvailableAmount + ? new Decimal(sumHoldAvailableAmount) + : new Decimal(0), + sumAmount: sumAmount ? new Decimal(sumAmount) : new Decimal(0), + lastDate: lastDate || null, + firstDate: firstDate || null, + } } } diff --git a/backend/src/util/validate.ts b/backend/src/util/validate.ts index 0858ea50f..95e1bf699 100644 --- a/backend/src/util/validate.ts +++ b/backend/src/util/validate.ts @@ -30,9 +30,9 @@ async function calculateBalance( // TODO why we have to use toString() here? const balance = decay.balance.add(amount.toString()) const transactionLinkRepository = getCustomRepository(TransactionLinkRepository) - const toHoldAvailable = await transactionLinkRepository.sumAmountToHoldAvailable(userId, time) + const { sumHoldAvailableAmount } = await transactionLinkRepository.summary(userId, time) - if (balance.minus(toHoldAvailable.toString()).lessThan(0)) { + if (balance.minus(sumHoldAvailableAmount.toString()).lessThan(0)) { return null } return { balance, lastTransactionId: lastTransaction.id, decay } diff --git a/backend/src/util/virtualDecayTransaction.ts b/backend/src/util/virtualDecayTransaction.ts deleted file mode 100644 index ff51ea4f5..000000000 --- a/backend/src/util/virtualDecayTransaction.ts +++ /dev/null @@ -1,52 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ -import Decimal from 'decimal.js-light' -import { SaveOptions, RemoveOptions } from '@dbTools/typeorm' -import { Transaction as dbTransaction } from '@entity/Transaction' -import { calculateDecay } from './decay' -import { TransactionTypeId } from '@enum/TransactionTypeId' -import { Transaction } from '@model/Transaction' -import { User } from '@model/User' - -const virtualDecayTransaction = ( - balance: Decimal, - balanceDate: Date, - time: Date = new Date(), - user: User, -): Transaction => { - const decay = calculateDecay(balance, balanceDate, time) - // const balance = decay.balance.minus(lastTransaction.balance) - const decayDbTransaction: dbTransaction = { - id: -1, - userId: -1, - previous: -1, - typeId: TransactionTypeId.DECAY, - amount: decay.decay ? decay.decay : new Decimal(0), // new Decimal(0), // this kinda is wrong, but helps with the frontend query - balance: decay.balance, - balanceDate: time, - decay: decay.decay ? decay.decay : new Decimal(0), - decayStart: decay.start, - memo: '', - creationDate: null, - hasId: function (): boolean { - throw new Error('Function not implemented.') - }, - save: function (options?: SaveOptions): Promise { - throw new Error('Function not implemented.') - }, - remove: function (options?: RemoveOptions): Promise { - throw new Error('Function not implemented.') - }, - softRemove: function (options?: SaveOptions): Promise { - throw new Error('Function not implemented.') - }, - recover: function (options?: SaveOptions): Promise { - throw new Error('Function not implemented.') - }, - reload: function (): Promise { - throw new Error('Function not implemented.') - }, - } - return new Transaction(decayDbTransaction, user) -} - -export { virtualDecayTransaction } diff --git a/backend/src/util/virtualTransactions.ts b/backend/src/util/virtualTransactions.ts new file mode 100644 index 000000000..72c21b9e4 --- /dev/null +++ b/backend/src/util/virtualTransactions.ts @@ -0,0 +1,82 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { Transaction } from '@model/Transaction' +import { SaveOptions, RemoveOptions } from '@dbTools/typeorm' +import { Transaction as dbTransaction } from '@entity/Transaction' +import { TransactionTypeId } from '@enum/TransactionTypeId' +import { calculateDecay } from './decay' +import { User } from '@model/User' +import Decimal from 'decimal.js-light' + +const defaultModelFunctions = { + hasId: function (): boolean { + throw new Error('Function not implemented.') + }, + save: function (options?: SaveOptions): Promise { + throw new Error('Function not implemented.') + }, + remove: function (options?: RemoveOptions): Promise { + throw new Error('Function not implemented.') + }, + softRemove: function (options?: SaveOptions): Promise { + throw new Error('Function not implemented.') + }, + recover: function (options?: SaveOptions): Promise { + throw new Error('Function not implemented.') + }, + reload: function (): Promise { + throw new Error('Function not implemented.') + }, +} + +const virtualLinkTransaction = ( + balance: Decimal, + amount: Decimal, + holdAvailableAmount: Decimal, + decay: Decimal, + createdAt: Date, + validUntil: Date, + user: User, +): Transaction => { + const linkDbTransaction: dbTransaction = { + id: -2, + userId: -1, + previous: -1, + typeId: TransactionTypeId.TRANSACTION_LINK, + amount: amount, + balance: balance, + balanceDate: validUntil, + decayStart: createdAt, + decay: decay, + memo: '', + creationDate: null, + ...defaultModelFunctions, + } + return new Transaction(linkDbTransaction, user) +} + +const virtualDecayTransaction = ( + balance: Decimal, + balanceDate: Date, + time: Date = new Date(), + user: User, +): Transaction => { + const decay = calculateDecay(balance, balanceDate, time) + // const balance = decay.balance.minus(lastTransaction.balance) + const decayDbTransaction: dbTransaction = { + id: -1, + userId: -1, + previous: -1, + typeId: TransactionTypeId.DECAY, + amount: decay.decay ? decay.decay : new Decimal(0), // new Decimal(0), // this kinda is wrong, but helps with the frontend query + balance: decay.balance, + balanceDate: time, + decay: decay.decay ? decay.decay : new Decimal(0), + decayStart: decay.start, + memo: '', + creationDate: null, + ...defaultModelFunctions, + } + return new Transaction(decayDbTransaction, user) +} + +export { virtualLinkTransaction, virtualDecayTransaction }