diff --git a/backend/src/auth/RIGHTS.ts b/backend/src/auth/RIGHTS.ts index 3b3f7580c..a18f0132a 100644 --- a/backend/src/auth/RIGHTS.ts +++ b/backend/src/auth/RIGHTS.ts @@ -18,6 +18,7 @@ export enum RIGHTS { SET_PASSWORD = 'SET_PASSWORD', UPDATE_USER_INFOS = 'UPDATE_USER_INFOS', HAS_ELOPAGE = 'HAS_ELOPAGE', + CREATE_TRANSACTION_LINK = 'CREATE_TRANSACTION_LINK', // 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 ada6a2cef..37a4e3a67 100644 --- a/backend/src/auth/ROLES.ts +++ b/backend/src/auth/ROLES.ts @@ -18,6 +18,7 @@ export const ROLE_USER = new Role('user', [ RIGHTS.LOGOUT, RIGHTS.UPDATE_USER_INFOS, RIGHTS.HAS_ELOPAGE, + RIGHTS.CREATE_TRANSACTION_LINK, ]) export const ROLE_ADMIN = new Role('admin', Object.values(RIGHTS)) // all rights diff --git a/backend/src/graphql/arg/TransactionLinkArgs.ts b/backend/src/graphql/arg/TransactionLinkArgs.ts new file mode 100644 index 000000000..5ccb967d3 --- /dev/null +++ b/backend/src/graphql/arg/TransactionLinkArgs.ts @@ -0,0 +1,14 @@ +import { ArgsType, Field } from 'type-graphql' +import Decimal from 'decimal.js-light' + +@ArgsType() +export default class TransactionLinkArgs { + @Field(() => Decimal) + amount: Decimal + + @Field(() => String) + memo: string + + @Field(() => Boolean, { nullable: true }) + showEmail?: boolean +} diff --git a/backend/src/graphql/model/TransactionLink.ts b/backend/src/graphql/model/TransactionLink.ts index e90d4efd9..e8e045d92 100644 --- a/backend/src/graphql/model/TransactionLink.ts +++ b/backend/src/graphql/model/TransactionLink.ts @@ -1,9 +1,25 @@ import { ObjectType, Field } from 'type-graphql' import Decimal from 'decimal.js-light' +import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink' import { User } from './User' @ObjectType() export class TransactionLink { + constructor(transactionLink: dbTransactionLink, user: User, redeemedBy: User | null = null) { + this.id = transactionLink.id + this.user = user + this.amount = transactionLink.amount + this.holdAvailableAmount = transactionLink.holdAvailableAmount + this.memo = transactionLink.memo + this.code = transactionLink.code + this.createdAt = transactionLink.createdAt + this.validUntil = transactionLink.validUntil + this.showEmail = transactionLink.showEmail + this.deletedAt = transactionLink.deletedAt + this.redeemedAt = transactionLink.redeemedAt + this.redeemedBy = redeemedBy + } + @Field(() => Number) id: number diff --git a/backend/src/graphql/resolver/TransactionLinkResolver.test.ts b/backend/src/graphql/resolver/TransactionLinkResolver.test.ts new file mode 100644 index 000000000..5a1a39dca --- /dev/null +++ b/backend/src/graphql/resolver/TransactionLinkResolver.test.ts @@ -0,0 +1,14 @@ +import { transactionLinkCode } from './TransactionLinkResolver' + +describe('transactionLinkCode', () => { + const date = new Date() + + it('returns a string of length 24', () => { + expect(transactionLinkCode(date)).toHaveLength(24) + }) + + it('returns a string that ends with the hex value of date', () => { + const regexp = new RegExp(date.getTime().toString(16) + '$') + expect(transactionLinkCode(date)).toEqual(expect.stringMatching(regexp)) + }) +}) diff --git a/backend/src/graphql/resolver/TransactionLinkResolver.ts b/backend/src/graphql/resolver/TransactionLinkResolver.ts new file mode 100644 index 000000000..d60146096 --- /dev/null +++ b/backend/src/graphql/resolver/TransactionLinkResolver.ts @@ -0,0 +1,70 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ + +import { Resolver, Args, Authorized, Ctx, Mutation } from 'type-graphql' +import { getCustomRepository } from '@dbTools/typeorm' +import { TransactionLink } from '@model/TransactionLink' +import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink' +import TransactionLinkArgs from '@arg/TransactionLinkArgs' +import { UserRepository } from '@repository/User' +import { calculateBalance } from '@/util/validate' +import { RIGHTS } from '@/auth/RIGHTS' +import { randomBytes } from 'crypto' +import { User } from '@model/User' +import { calculateDecay } from '@/util/decay' + +// TODO: do not export, test it inside the resolver +export const transactionLinkCode = (date: Date): string => { + const time = date.getTime().toString(16) + return ( + randomBytes(12) + .toString('hex') + .substring(0, 24 - time.length) + time + ) +} + +const CODE_VALID_DAYS_DURATION = 14 + +const transactionLinkExpireDate = (date: Date): Date => { + const validUntil = new Date(date) + return new Date(validUntil.setDate(date.getDate() + CODE_VALID_DAYS_DURATION)) +} + +@Resolver() +export class TransactionLinkResolver { + @Authorized([RIGHTS.CREATE_TRANSACTION_LINK]) + @Mutation(() => TransactionLink) + async createTransactionLink( + @Args() { amount, memo, showEmail = false }: TransactionLinkArgs, + @Ctx() context: any, + ): Promise { + const userRepository = getCustomRepository(UserRepository) + const user = await userRepository.findByPubkeyHex(context.pubKey) + + const createdDate = new Date() + const validUntil = transactionLinkExpireDate(createdDate) + + const holdAvailableAmount = amount.minus(calculateDecay(amount, createdDate, validUntil).decay) + + // validate amount + const sendBalance = await calculateBalance(user.id, holdAvailableAmount.mul(-1), createdDate) + if (!sendBalance) { + throw new Error("user hasn't enough GDD or amount is < 0") + } + + const transactionLink = dbTransactionLink.create() + transactionLink.userId = user.id + transactionLink.amount = amount + transactionLink.memo = memo + transactionLink.holdAvailableAmount = holdAvailableAmount + transactionLink.code = transactionLinkCode(createdDate) + transactionLink.createdAt = createdDate + transactionLink.validUntil = validUntil + transactionLink.showEmail = showEmail + await dbTransactionLink.save(transactionLink).catch(() => { + throw new Error('Unable to save transaction link') + }) + + return new TransactionLink(transactionLink, new User(user)) + } +} diff --git a/backend/src/graphql/resolver/TransactionResolver.ts b/backend/src/graphql/resolver/TransactionResolver.ts index d674fc69e..1d1a00c39 100644 --- a/backend/src/graphql/resolver/TransactionResolver.ts +++ b/backend/src/graphql/resolver/TransactionResolver.ts @@ -19,6 +19,7 @@ import { Order } from '@enum/Order' import { UserRepository } from '@repository/User' import { TransactionRepository } from '@repository/Transaction' +import { TransactionLinkRepository } from '@repository/TransactionLink' import { User as dbUser } from '@entity/User' import { Transaction as dbTransaction } from '@entity/Transaction' @@ -127,9 +128,14 @@ 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, + calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, now).balance.minus( + toHoldAvailable.toString(), + ), transactions, userTransactionsCount, balanceGDT, diff --git a/backend/src/typeorm/repository/TransactionLink.ts b/backend/src/typeorm/repository/TransactionLink.ts new file mode 100644 index 000000000..5f7531bc8 --- /dev/null +++ b/backend/src/typeorm/repository/TransactionLink.ts @@ -0,0 +1,16 @@ +import { Repository, EntityRepository } from '@dbTools/typeorm' +import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink' +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) + } +} diff --git a/backend/src/util/validate.ts b/backend/src/util/validate.ts index 3699fe511..0858ea50f 100644 --- a/backend/src/util/validate.ts +++ b/backend/src/util/validate.ts @@ -2,6 +2,8 @@ import { calculateDecay } from './decay' import Decimal from 'decimal.js-light' import { Transaction } from '@entity/Transaction' import { Decay } from '@model/Decay' +import { getCustomRepository } from '@dbTools/typeorm' +import { TransactionLinkRepository } from '@repository/TransactionLink' function isStringBoolean(value: string): boolean { const lowerValue = value.toLowerCase() @@ -24,9 +26,13 @@ async function calculateBalance( if (!lastTransaction) return null const decay = calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, time) + // TODO why we have to use toString() here? const balance = decay.balance.add(amount.toString()) - if (balance.lessThan(0)) { + const transactionLinkRepository = getCustomRepository(TransactionLinkRepository) + const toHoldAvailable = await transactionLinkRepository.sumAmountToHoldAvailable(userId, time) + + if (balance.minus(toHoldAvailable.toString()).lessThan(0)) { return null } return { balance, lastTransactionId: lastTransaction.id, decay } diff --git a/database/entity/0030-transaction_link/TransactionLink.ts b/database/entity/0030-transaction_link/TransactionLink.ts index 6ea708547..177f23561 100644 --- a/database/entity/0030-transaction_link/TransactionLink.ts +++ b/database/entity/0030-transaction_link/TransactionLink.ts @@ -42,7 +42,7 @@ export class TransactionLink extends BaseEntity { createdAt: Date @DeleteDateColumn() - deletedAt?: Date | null + deletedAt: Date | null @Column({ type: 'datetime', @@ -61,8 +61,8 @@ export class TransactionLink extends BaseEntity { type: 'datetime', nullable: true, }) - redeemedAt?: Date | null + redeemedAt: Date | null @Column({ type: 'int', unsigned: true, nullable: true }) - redeemedBy?: number | null + redeemedBy: number | null }