From 267ab8c7434c50684fa2217cabc2b2dddaa878a5 Mon Sep 17 00:00:00 2001 From: Leo Kewitz Date: Thu, 6 Aug 2020 17:24:02 +0000 Subject: [PATCH 1/5] feat: add refundTransaction() mutation to gqlV2 Co-Authored-By: Benjamin Piouffle --- server/graphql/v2/identifiers.js | 1 + .../v2/input/TransactionReferenceInput.ts | 51 +++++++++++++++++++ .../v2/mutation/TransactionMutations.ts | 28 ++++++++++ server/graphql/v2/mutation/index.js | 2 + 4 files changed, 82 insertions(+) create mode 100644 server/graphql/v2/input/TransactionReferenceInput.ts create mode 100644 server/graphql/v2/mutation/TransactionMutations.ts diff --git a/server/graphql/v2/identifiers.js b/server/graphql/v2/identifiers.js index 1a7785c8e62..c04ae4892cc 100644 --- a/server/graphql/v2/identifiers.js +++ b/server/graphql/v2/identifiers.js @@ -23,6 +23,7 @@ export const IDENTIFIER_TYPES = { CONNECTED_ACCOUNT: 'connected-account', EXPENSE_ATTACHED_FILE: 'expense-attached-file', EXPENSE_ITEM: 'expense-item', + TRANSACTION: 'transaction', }; const getDefaultInstance = type => { diff --git a/server/graphql/v2/input/TransactionReferenceInput.ts b/server/graphql/v2/input/TransactionReferenceInput.ts new file mode 100644 index 00000000000..81373bb95ee --- /dev/null +++ b/server/graphql/v2/input/TransactionReferenceInput.ts @@ -0,0 +1,51 @@ +import { GraphQLInputObjectType, GraphQLInt, GraphQLString } from 'graphql'; + +import models from '../../../models'; +import { NotFound } from '../../errors'; +import { idDecode, IDENTIFIER_TYPES } from '../identifiers'; + +const TransactionReferenceInput = new GraphQLInputObjectType({ + name: 'TransactionReferenceInput', + fields: { + id: { + type: GraphQLString, + description: 'The public id identifying the transaction (ie: dgm9bnk8-0437xqry-ejpvzeol-jdayw5re)', + }, + legacyId: { + type: GraphQLInt, + description: 'The internal id of the transaction (ie: 580)', + }, + }, +}); + +const getDatabaseIdFromTransactionReference = (input: object): number => { + if (input['id']) { + return idDecode(input['id'], IDENTIFIER_TYPES.TRANSACTION); + } else if (input['legacyId']) { + return input['legacyId']; + } else { + return null; + } +}; + +/** + * Retrieve an expense from an `ExpenseReferenceInput` + */ +const fetchTransactionWithReference = async ( + input: object, + { loaders = null, throwIfMissing = false } = {}, +): Promise => { + const dbId = getDatabaseIdFromTransactionReference(input); + let transaction = null; + if (dbId) { + transaction = await (loaders ? loaders.Transaction.byId.load(dbId) : models.Transaction.findByPk(dbId)); + } + + if (!transaction && throwIfMissing) { + throw new NotFound(); + } + + return transaction; +}; + +export { TransactionReferenceInput, fetchTransactionWithReference, getDatabaseIdFromTransactionReference }; diff --git a/server/graphql/v2/mutation/TransactionMutations.ts b/server/graphql/v2/mutation/TransactionMutations.ts new file mode 100644 index 00000000000..cbd8a35cf20 --- /dev/null +++ b/server/graphql/v2/mutation/TransactionMutations.ts @@ -0,0 +1,28 @@ +import { GraphQLNonNull } from 'graphql'; + +import { Unauthorized } from '../../errors'; +import { refundTransaction as legacyRefundTransaction } from '../../v1/mutations/orders'; +import { fetchTransactionWithReference, TransactionReferenceInput } from '../input/TransactionReferenceInput'; +import { Transaction } from '../interface/Transaction'; + +const transactionMutations = { + refundTransaction: { + type: new GraphQLNonNull(Transaction), + description: 'Refunds transaction', + args: { + transaction: { + type: new GraphQLNonNull(TransactionReferenceInput), + description: 'Reference of the transaction to refund', + }, + }, + async resolve(_, args, req): Promise { + if (!req.remoteUser) { + throw new Unauthorized(); + } + const transaction = await fetchTransactionWithReference(args.transaction); + return legacyRefundTransaction(undefined, { id: transaction.id }, req); + }, + }, +}; + +export default transactionMutations; diff --git a/server/graphql/v2/mutation/index.js b/server/graphql/v2/mutation/index.js index 7f57a592a6a..a8500d815a5 100644 --- a/server/graphql/v2/mutation/index.js +++ b/server/graphql/v2/mutation/index.js @@ -11,6 +11,7 @@ import expenseMutations from './ExpenseMutations'; import orderMutations from './OrderMutations'; import paymentMethodMutations from './PaymentMethodMutations'; import payoutMethodMutations from './PayoutMethodMutations'; +import transactionMutations from './TransactionMutations'; const mutation = { createCollective: createCollectiveMutation, @@ -26,6 +27,7 @@ const mutation = { ...payoutMethodMutations, ...orderMutations, ...paymentMethodMutations, + ...transactionMutations, }; export default mutation; From 96b0367ae41ed7a52e85bd1964451decb7a51c44 Mon Sep 17 00:00:00 2001 From: Leo Kewitz Date: Fri, 7 Aug 2020 22:24:53 -0300 Subject: [PATCH 2/5] feat: add TransactionPermission resolver Co-Authored-By: Benjamin Piouffle --- server/constants/index.js | 3 +- server/graphql/common/transactions.ts | 64 ++++++++++++++++++++++ server/graphql/v2/interface/Transaction.js | 35 ++++++++++-- 3 files changed, 95 insertions(+), 7 deletions(-) create mode 100644 server/graphql/common/transactions.ts diff --git a/server/constants/index.js b/server/constants/index.js index bb3a2d1f22a..abf542b9a57 100644 --- a/server/constants/index.js +++ b/server/constants/index.js @@ -3,5 +3,6 @@ import channels from './channels'; import expenseStatus from './expense_status'; import math from './math'; import roles from './roles'; +import * as transactions from './transactions'; -export { activities, expenseStatus, roles, math, channels }; +export { activities, expenseStatus, roles, math, channels, transactions }; diff --git a/server/graphql/common/transactions.ts b/server/graphql/common/transactions.ts new file mode 100644 index 00000000000..fb1d2bb5d61 --- /dev/null +++ b/server/graphql/common/transactions.ts @@ -0,0 +1,64 @@ +import { TransactionTypes } from '../../constants/transactions'; + +const isRoot = async (req): Promise => { + if (!req.remoteUser) { + return false; + } + + return req.remoteUser.isRoot(); +}; + +const isPayerCollectiveAdmin = async (req, transaction): Promise => { + if (!req.remoteUser) { + return false; + } + + const collectiveId = transaction.type === 'DEBIT' ? transaction.CollectiveId : transaction.FromCollectiveId; + + if (req.remoteUser.isAdmin(collectiveId)) { + return true; + } else { + const collective = await req.loaders.Collective.byId.load(collectiveId); + return req.remoteUser.isAdmin(collective.ParentCollectiveId); + } +}; + +const isPayeeHostAdmin = async (req, transaction): Promise => { + if (!req.remoteUser) { + return false; + } + const collective = await req.loaders.Collective.byId.load( + transaction.type === 'CREDIT' ? transaction.CollectiveId : transaction.FromCollectiveId, + ); + return req.remoteUser.isAdmin(collective.HostCollectiveId); +}; + +/** + * Returns true if the transaction meets at least one condition. + * Always returns false for unauthenticated requests. + */ +const remoteUserMeetsOneCondition = async (req, transaction, conditions): Promise => { + if (!req.remoteUser) { + return false; + } + + for (const condition of conditions) { + if (await condition(req, transaction)) { + return true; + } + } + + return false; +}; + +/** Checks if the user can see transaction's attachments (items URLs, attached files) */ +export const canRefund = async (transaction, _, req): Promise => { + if (transaction.type !== TransactionTypes.CREDIT || transaction.OrderId === null) { + return false; + } + return remoteUserMeetsOneCondition(req, transaction, [isRoot, isPayeeHostAdmin]); +}; + +export const canDownloadInvoice = async (transaction, _, req): Promise => { + return remoteUserMeetsOneCondition(req, transaction, [isPayerCollectiveAdmin, isPayeeHostAdmin]); +}; diff --git a/server/graphql/v2/interface/Transaction.js b/server/graphql/v2/interface/Transaction.js index 9995ae0952a..4f7eaf731ff 100644 --- a/server/graphql/v2/interface/Transaction.js +++ b/server/graphql/v2/interface/Transaction.js @@ -1,12 +1,8 @@ -import { - GraphQLBoolean, - GraphQLInterfaceType, - // GraphQLInt, - GraphQLString, -} from 'graphql'; +import { GraphQLBoolean, GraphQLInterfaceType, GraphQLNonNull, GraphQLObjectType, GraphQLString } from 'graphql'; import { GraphQLDateTime } from 'graphql-iso-date'; import models from '../../../models'; +import * as TransactionLib from '../../common/transactions'; import { TransactionType } from '../enum/TransactionType'; import { idEncode } from '../identifiers'; import { Amount } from '../object/Amount'; @@ -16,6 +12,23 @@ import { PaymentMethod } from '../object/PaymentMethod'; import { Account } from './Account'; +const TransactionPermissions = new GraphQLObjectType({ + name: 'TransactionPermissions', + description: 'Fields for the user permissions on an transaction', + fields: { + canRefund: { + type: new GraphQLNonNull(GraphQLBoolean), + description: 'Whether the current user can edit the transaction', + resolve: TransactionLib.canRefund, + }, + canDownloadInvoice: { + type: new GraphQLNonNull(GraphQLBoolean), + description: "Whether the current user can download this transaction's invoice", + resolve: TransactionLib.canDownloadInvoice, + }, + }, +}); + export const Transaction = new GraphQLInterfaceType({ name: 'Transaction', description: 'Transaction interface shared by all kind of transactions (Debit, Credit)', @@ -78,6 +91,9 @@ export const Transaction = new GraphQLInterfaceType({ paymentMethod: { type: PaymentMethod, }, + permissions: { + type: TransactionPermissions, + }, }; }, }); @@ -204,5 +220,12 @@ export const TransactionFields = () => { return models.PaymentMethod.findByPk(transaction.PaymentMethodId); }, }, + permissions: { + type: new GraphQLNonNull(TransactionPermissions), + description: 'The permissions given to current logged in user for this transaction', + async resolve(transaction) { + return transaction; // Individual fields are set by TransactionPermissions's resolvers + }, + }, }; }; From 05bc4cd33ea47cfbd6940c0660bfaadf86808e7b Mon Sep 17 00:00:00 2001 From: Leo Kewitz Date: Mon, 10 Aug 2020 16:05:50 -0300 Subject: [PATCH 3/5] test: add tests for graphql/common/transactions --- .../graphql/common/transactions.test.js | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 test/server/graphql/common/transactions.test.js diff --git a/test/server/graphql/common/transactions.test.js b/test/server/graphql/common/transactions.test.js new file mode 100644 index 00000000000..b0864f4e6f7 --- /dev/null +++ b/test/server/graphql/common/transactions.test.js @@ -0,0 +1,65 @@ +import { expect } from 'chai'; + +import { roles } from '../../../../server/constants'; +import { canDownloadInvoice, canRefund } from '../../../../server/graphql/common/transactions'; +import { fakeCollective, fakeOrder, fakeTransaction, fakeUser } from '../../../test-helpers/fake-data'; +import { makeRequest } from '../../../utils'; + +describe('server/graphql/common/transactions', () => { + let collective, collectiveAdmin, hostAdmin, contributor, randomUser, transaction; + let publicReq, randomUserReq, collectiveAdminReq, hostAdminReq, rootAdminReq, contributorReq; + + before(async () => { + randomUser = await fakeUser(); + collectiveAdmin = await fakeUser(); + hostAdmin = await fakeUser(); + const order = await fakeOrder(); + const rootAdmin = await fakeUser(); + contributor = await fakeUser(); + collective = await fakeCollective(); + transaction = await fakeTransaction({ + CollectiveId: collective.id, + FromCollectiveId: contributor.CollectiveId, + amount: 100000, + OrderId: order.id, + }); + console.log(transaction.OrderId); + await collective.addUserWithRole(collectiveAdmin, 'ADMIN'); + await collective.host.addUserWithRole(hostAdmin, 'ADMIN'); + + await collectiveAdmin.populateRoles(); + await hostAdmin.populateRoles(); + await rootAdmin.populateRoles(); + + rootAdmin.rolesByCollectiveId[1] = [roles.ADMIN]; + + publicReq = makeRequest(); + randomUserReq = makeRequest(randomUser); + collectiveAdminReq = makeRequest(collectiveAdmin); + hostAdminReq = makeRequest(hostAdmin); + rootAdminReq = makeRequest(rootAdmin); + contributorReq = makeRequest(contributor); + }); + + describe('canRefund', () => { + it('can refund if root or host admin of the collective receiving the contribution', async () => { + expect(await canRefund(transaction, undefined, publicReq)).to.be.false; + expect(await canRefund(transaction, undefined, randomUserReq)).to.be.false; + expect(await canRefund(transaction, undefined, collectiveAdminReq)).to.be.false; + expect(await canRefund(transaction, undefined, contributorReq)).to.be.false; + expect(await canRefund(transaction, undefined, hostAdminReq)).to.be.true; + expect(await canRefund(transaction, undefined, rootAdminReq)).to.be.true; + }); + }); + + describe('canDownloadInvoice', () => { + it('can download invoice if donator or host admin of the collective receiving the contribution', async () => { + expect(await canDownloadInvoice(transaction, undefined, publicReq)).to.be.false; + expect(await canDownloadInvoice(transaction, undefined, randomUserReq)).to.be.false; + expect(await canDownloadInvoice(transaction, undefined, collectiveAdminReq)).to.be.false; + expect(await canDownloadInvoice(transaction, undefined, contributorReq)).to.be.true; + expect(await canDownloadInvoice(transaction, undefined, hostAdminReq)).to.be.true; + expect(await canDownloadInvoice(transaction, undefined, rootAdminReq)).to.be.false; + }); + }); +}); From 2086205138f4fb8c653aa5b94691ef2b4bd17ea0 Mon Sep 17 00:00:00 2001 From: Leo Kewitz Date: Mon, 10 Aug 2020 17:10:38 -0300 Subject: [PATCH 4/5] feat: add platformFeePercent to AccountWithHost interface --- server/graphql/v2/interface/AccountWithHost.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/graphql/v2/interface/AccountWithHost.ts b/server/graphql/v2/interface/AccountWithHost.ts index 2bdd11ef97a..016d223ccbb 100644 --- a/server/graphql/v2/interface/AccountWithHost.ts +++ b/server/graphql/v2/interface/AccountWithHost.ts @@ -1,4 +1,4 @@ -import { GraphQLBoolean, GraphQLFloat, GraphQLInterfaceType, GraphQLNonNull } from 'graphql'; +import { GraphQLBoolean, GraphQLFloat, GraphQLInt, GraphQLInterfaceType, GraphQLNonNull } from 'graphql'; import { GraphQLDateTime } from 'graphql-iso-date'; import { HOST_FEE_STRUCTURE } from '../../../constants/host-fee-structure'; @@ -29,6 +29,10 @@ export const AccountWithHostFields = { description: 'Fees percentage that the host takes for this collective', type: GraphQLFloat, }, + platformFeePercent: { + description: 'Fees percentage that the platform takes for this collective', + type: GraphQLInt, + }, approvedAt: { description: 'Date of approval by the Fiscal Host.', type: GraphQLDateTime, From e3597c1f94a6a36a522e4d9b3c32486a02d469c8 Mon Sep 17 00:00:00 2001 From: Leo Kewitz Date: Mon, 10 Aug 2020 17:39:43 -0300 Subject: [PATCH 5/5] feat: add isIncognito to Account interface --- server/graphql/v2/interface/Account.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/graphql/v2/interface/Account.js b/server/graphql/v2/interface/Account.js index 19448f96db7..655d603e888 100644 --- a/server/graphql/v2/interface/Account.js +++ b/server/graphql/v2/interface/Account.js @@ -75,6 +75,10 @@ const accountFieldsDefinition = () => ({ expensePolicy: { type: GraphQLString, }, + isIncognito: { + type: new GraphQLNonNull(GraphQLBoolean), + description: 'Defines if the contributors wants to be incognito (name not displayed)', + }, imageUrl: { type: GraphQLString, args: {