diff --git a/backend/src/auth/RIGHTS.ts b/backend/src/auth/RIGHTS.ts index 55b5761be..a2e92a2cc 100644 --- a/backend/src/auth/RIGHTS.ts +++ b/backend/src/auth/RIGHTS.ts @@ -21,6 +21,7 @@ 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', diff --git a/backend/src/auth/ROLES.ts b/backend/src/auth/ROLES.ts index 56d9d695c..82c689848 100644 --- a/backend/src/auth/ROLES.ts +++ b/backend/src/auth/ROLES.ts @@ -20,6 +20,7 @@ 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/config/index.ts b/backend/src/config/index.ts index b1fb8e397..79101856c 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -10,7 +10,7 @@ Decimal.set({ }) const constants = { - DB_VERSION: '0031-remove_sendEmail_from_transaction_link', + DB_VERSION: '0032-add-transaction-link-to-transaction', DECAY_START_TIME: new Date('2021-05-13 17:46:31'), // GMT+0 } diff --git a/backend/src/graphql/model/Transaction.ts b/backend/src/graphql/model/Transaction.ts index 29a8a4bbd..684224175 100644 --- a/backend/src/graphql/model/Transaction.ts +++ b/backend/src/graphql/model/Transaction.ts @@ -30,6 +30,7 @@ export class Transaction { this.creationDate = transaction.creationDate this.linkedUser = linkedUser this.linkedTransactionId = transaction.linkedTransactionId + this.transactionLinkId = transaction.transactionLinkId } @Field(() => Number) @@ -67,4 +68,8 @@ export class Transaction { @Field(() => Number, { nullable: true }) linkedTransactionId?: number | null + + // Links to the TransactionLink when transaction was created by a link + @Field(() => Number, { nullable: true }) + transactionLinkId?: number | null } diff --git a/backend/src/graphql/resolver/TransactionLinkResolver.ts b/backend/src/graphql/resolver/TransactionLinkResolver.ts index 8501da675..9e2af6111 100644 --- a/backend/src/graphql/resolver/TransactionLinkResolver.ts +++ b/backend/src/graphql/resolver/TransactionLinkResolver.ts @@ -14,6 +14,7 @@ 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 @@ -132,4 +133,37 @@ export class TransactionLinkResolver { }) 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, + transactionLink, + ) + + return true + } } diff --git a/backend/src/graphql/resolver/TransactionResolver.ts b/backend/src/graphql/resolver/TransactionResolver.ts index 97bb2d49c..d2bfd7f28 100644 --- a/backend/src/graphql/resolver/TransactionResolver.ts +++ b/backend/src/graphql/resolver/TransactionResolver.ts @@ -23,6 +23,7 @@ import { TransactionLinkRepository } from '@repository/TransactionLink' import { User as dbUser } from '@entity/User' import { Transaction as dbTransaction } from '@entity/Transaction' +import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink' import { apiPost } from '@/apis/HttpRequest' import { TransactionTypeId } from '@enum/TransactionTypeId' @@ -34,6 +35,96 @@ import { virtualLinkTransaction, virtualDecayTransaction } from '@/util/virtualT import Decimal from 'decimal.js-light' import { calculateDecay } from '@/util/decay' +export const executeTransaction = async ( + amount: Decimal, + memo: string, + sender: dbUser, + recipient: dbUser, + transactionLink?: dbTransactionLink | null, +): 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 + transactionSend.transactionLinkId = transactionLink ? transactionLink.id : null + 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 + transactionReceive.transactionLinkId = transactionLink ? transactionLink.id : null + 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) + + if (transactionLink) { + transactionLink.redeemedAt = receivedCallDate + transactionLink.redeemedBy = recipient.id + await queryRunner.manager.update( + dbTransactionLink, + { id: transactionLink.id }, + transactionLink, + ) + } + + 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]) @@ -169,12 +260,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 }) @@ -188,62 +273,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/database/entity/0032-add-transaction-link-to-transaction/Transaction.ts b/database/entity/0032-add-transaction-link-to-transaction/Transaction.ts new file mode 100644 index 000000000..398fa03db --- /dev/null +++ b/database/entity/0032-add-transaction-link-to-transaction/Transaction.ts @@ -0,0 +1,94 @@ +import Decimal from 'decimal.js-light' +import { BaseEntity, Entity, PrimaryGeneratedColumn, Column } from 'typeorm' +import { DecimalTransformer } from '../../src/typeorm/DecimalTransformer' + +@Entity('transactions') +export class Transaction extends BaseEntity { + @PrimaryGeneratedColumn('increment', { unsigned: true }) + id: number + + @Column({ name: 'user_id', unsigned: true, nullable: false }) + userId: number + + @Column({ type: 'int', unsigned: true, nullable: true, default: null }) + previous: number | null + + @Column({ name: 'type_id', unsigned: true, nullable: false }) + typeId: number + + @Column({ + type: 'decimal', + precision: 40, + scale: 20, + nullable: false, + transformer: DecimalTransformer, + }) + amount: Decimal + + @Column({ + type: 'decimal', + precision: 40, + scale: 20, + nullable: false, + transformer: DecimalTransformer, + }) + balance: Decimal + + @Column({ + name: 'balance_date', + type: 'datetime', + default: () => 'CURRENT_TIMESTAMP', + nullable: false, + }) + balanceDate: Date + + @Column({ + type: 'decimal', + precision: 40, + scale: 20, + nullable: false, + transformer: DecimalTransformer, + }) + decay: Decimal + + @Column({ + name: 'decay_start', + type: 'datetime', + nullable: true, + default: null, + }) + decayStart: Date | null + + @Column({ length: 255, nullable: false, collation: 'utf8mb4_unicode_ci' }) + memo: string + + @Column({ name: 'creation_date', type: 'datetime', nullable: true, default: null }) + creationDate: Date | null + + @Column({ + name: 'linked_user_id', + type: 'int', + unsigned: true, + nullable: true, + default: null, + }) + linkedUserId?: number | null + + @Column({ + name: 'linked_transaction_id', + type: 'int', + unsigned: true, + nullable: true, + default: null, + }) + linkedTransactionId?: number | null + + @Column({ + name: 'transaction_link_id', + type: 'int', + unsigned: true, + nullable: true, + default: null, + }) + transactionLinkId?: number | null +} diff --git a/database/entity/Transaction.ts b/database/entity/Transaction.ts index 56c77c557..3515202d0 100644 --- a/database/entity/Transaction.ts +++ b/database/entity/Transaction.ts @@ -1 +1 @@ -export { Transaction } from './0029-clean_transaction_table/Transaction' +export { Transaction } from './0032-add-transaction-link-to-transaction/Transaction' diff --git a/database/migrations/0032-add-transaction-link-to-transaction.ts b/database/migrations/0032-add-transaction-link-to-transaction.ts new file mode 100644 index 000000000..79cdf195a --- /dev/null +++ b/database/migrations/0032-add-transaction-link-to-transaction.ts @@ -0,0 +1,14 @@ +/* MIGRATION TO ADD transactionLinkId FIELDTO TRANSACTION */ + +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export async function upgrade(queryFn: (query: string, values?: any[]) => Promise>) { + await queryFn( + 'ALTER TABLE `transactions` ADD COLUMN `transaction_link_id` int UNSIGNED DEFAULT NULL AFTER `linked_transaction_id`;', + ) +} + +export async function downgrade(queryFn: (query: string, values?: any[]) => Promise>) { + await queryFn('ALTER TABLE `transactions` DROP COLUMN `transaction_link_id`;') +} diff --git a/frontend/src/components/DecayInformations/DecayInformation.vue b/frontend/src/components/DecayInformations/DecayInformation.vue new file mode 100644 index 000000000..ae76a5bb4 --- /dev/null +++ b/frontend/src/components/DecayInformations/DecayInformation.vue @@ -0,0 +1,48 @@ + + diff --git a/frontend/src/components/GddTransactionList.spec.js b/frontend/src/components/GddTransactionList.spec.js index b4d61c4a6..ab8f3658c 100644 --- a/frontend/src/components/GddTransactionList.spec.js +++ b/frontend/src/components/GddTransactionList.spec.js @@ -286,18 +286,23 @@ describe('GddTransactionList', () => { it('has a bi-gift icon', () => { expect(transaction.findAll('svg').at(1).classes()).toEqual([ 'bi-gift', - 'gradido-global-color-accent', 'm-mb-1', 'font2em', 'b-icon', 'bi', + 'gradido-global-color-accent', ]) }) it('has gradido-global-color-accent color', () => { - expect(transaction.findAll('svg').at(1).classes()).toContain( + expect(transaction.findAll('svg').at(1).classes()).toEqual([ + 'bi-gift', + 'm-mb-1', + 'font2em', + 'b-icon', + 'bi', 'gradido-global-color-accent', - ) + ]) }) // operators are renderd by GDD filter @@ -348,11 +353,11 @@ describe('GddTransactionList', () => { it('has gradido-global-color-accent color', () => { expect(transaction.findAll('svg').at(1).classes()).toEqual([ 'bi-arrow-right-circle', - 'gradido-global-color-accent', 'm-mb-1', 'font2em', 'b-icon', 'bi', + 'gradido-global-color-accent', ]) }) @@ -400,6 +405,7 @@ describe('GddTransactionList', () => { return { amount: '3.14', balanceDate: '2021-04-29T17:26:40+00:00', + decay: {}, memo: 'Kreiszahl PI', linkedUser: { firstName: 'Bibi', @@ -418,6 +424,7 @@ describe('GddTransactionList', () => { transactions, transactionCount: 42, showPagination: true, + decayStartBlock: new Date(), }) paginationButtons = wrapper.find('div.pagination-buttons') }) diff --git a/frontend/src/components/GddTransactionList.vue b/frontend/src/components/GddTransactionList.vue index c3f3d4d7e..3b4cd961d 100644 --- a/frontend/src/components/GddTransactionList.vue +++ b/frontend/src/components/GddTransactionList.vue @@ -108,6 +108,10 @@ export default { }