Skip to content

Commit

Permalink
Merge pull request #4346 from opencollective/fix/transactions-followup-2
Browse files Browse the repository at this point in the history
Transactions Page Follow-Up
  • Loading branch information
kewitz committed Aug 10, 2020
2 parents a888b4b + e3597c1 commit bdb5b90
Show file tree
Hide file tree
Showing 10 changed files with 251 additions and 8 deletions.
3 changes: 2 additions & 1 deletion server/constants/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
64 changes: 64 additions & 0 deletions server/graphql/common/transactions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { TransactionTypes } from '../../constants/transactions';

const isRoot = async (req): Promise<boolean> => {
if (!req.remoteUser) {
return false;
}

return req.remoteUser.isRoot();
};

const isPayerCollectiveAdmin = async (req, transaction): Promise<boolean> => {
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<boolean> => {
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<boolean> => {
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<boolean> => {
if (transaction.type !== TransactionTypes.CREDIT || transaction.OrderId === null) {
return false;
}
return remoteUserMeetsOneCondition(req, transaction, [isRoot, isPayeeHostAdmin]);
};

export const canDownloadInvoice = async (transaction, _, req): Promise<boolean> => {
return remoteUserMeetsOneCondition(req, transaction, [isPayerCollectiveAdmin, isPayeeHostAdmin]);
};
1 change: 1 addition & 0 deletions server/graphql/v2/identifiers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
51 changes: 51 additions & 0 deletions server/graphql/v2/input/TransactionReferenceInput.ts
Original file line number Diff line number Diff line change
@@ -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<any> => {
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 };
4 changes: 4 additions & 0 deletions server/graphql/v2/interface/Account.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
6 changes: 5 additions & 1 deletion server/graphql/v2/interface/AccountWithHost.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
Expand Down
35 changes: 29 additions & 6 deletions server/graphql/v2/interface/Transaction.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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)',
Expand Down Expand Up @@ -78,6 +91,9 @@ export const Transaction = new GraphQLInterfaceType({
paymentMethod: {
type: PaymentMethod,
},
permissions: {
type: TransactionPermissions,
},
};
},
});
Expand Down Expand Up @@ -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
},
},
};
};
28 changes: 28 additions & 0 deletions server/graphql/v2/mutation/TransactionMutations.ts
Original file line number Diff line number Diff line change
@@ -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<typeof Transaction> {
if (!req.remoteUser) {
throw new Unauthorized();
}
const transaction = await fetchTransactionWithReference(args.transaction);
return legacyRefundTransaction(undefined, { id: transaction.id }, req);
},
},
};

export default transactionMutations;
2 changes: 2 additions & 0 deletions server/graphql/v2/mutation/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -26,6 +27,7 @@ const mutation = {
...payoutMethodMutations,
...orderMutations,
...paymentMethodMutations,
...transactionMutations,
};

export default mutation;
65 changes: 65 additions & 0 deletions test/server/graphql/common/transactions.test.js
Original file line number Diff line number Diff line change
@@ -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;
});
});
});

0 comments on commit bdb5b90

Please sign in to comment.