Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Transactions Page Follow-Up #4346

Merged
merged 5 commits into from
Aug 10, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
});
});
});