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

ContributionFlow: Information requirement resolvers #10661

Merged
merged 3 commits into from
Feb 7, 2025
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
'use strict';

/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface) {
await queryInterface.sequelize.query(`
CREATE INDEX CONCURRENTLY transactions__contributions_fromcollective_to_host
ON "Transactions" ("HostCollectiveId", "FromCollectiveId", "createdAt")
WHERE ("deletedAt" IS NULL AND "kind" = 'CONTRIBUTION' AND "RefundTransactionId" IS NULL);
`);
},

async down(queryInterface) {
await queryInterface.sequelize.query(`
DROP INDEX CONCURRENTLY transactions__contributions_fromcollective_to_host;
`);
},
};
7 changes: 7 additions & 0 deletions server/constants/policies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ enum POLICIES {
// When enabled, admins of the collective are allowed to see the payout methods of expenses
COLLECTIVE_ADMINS_CAN_SEE_PAYOUT_METHODS = 'COLLECTIVE_ADMINS_CAN_SEE_PAYOUT_METHODS',
EXPENSE_POLICIES = 'EXPENSE_POLICIES',
/** Enforces contributor information requirements based on yearly contribution amount thresholds */
CONTRIBUTOR_INFO_THRESHOLDS = 'CONTRIBUTOR_INFO_THRESHOLDS',
}

export type Policies = Partial<{
Expand Down Expand Up @@ -51,6 +53,10 @@ export type Policies = Partial<{
};
[POLICIES.EXPENSE_PUBLIC_VENDORS]: boolean;
[POLICIES.COLLECTIVE_ADMINS_CAN_SEE_PAYOUT_METHODS]: boolean;
[POLICIES.CONTRIBUTOR_INFO_THRESHOLDS]: {
legalName?: number;
address?: number;
};
}>;

export const DEFAULT_POLICIES: { [T in POLICIES]: Policies[T] } = {
Expand Down Expand Up @@ -88,6 +94,7 @@ export const DEFAULT_POLICIES: { [T in POLICIES]: Policies[T] } = {
},
[POLICIES.EXPENSE_PUBLIC_VENDORS]: false,
[POLICIES.COLLECTIVE_ADMINS_CAN_SEE_PAYOUT_METHODS]: false,
[POLICIES.CONTRIBUTOR_INFO_THRESHOLDS]: undefined,
};

export default POLICIES;
58 changes: 55 additions & 3 deletions server/graphql/loaders/contributors.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import DataLoader from 'dataloader';
import { uniq, zipObject } from 'lodash';
import { compact, uniq, zipObject } from 'lodash';

import { SupportedCurrency } from '../../constants/currencies';
import { ContributorsCacheEntry, getContributorsForCollective } from '../../lib/contributors';
import models from '../../models';
import models, { sequelize } from '../../models';

export default {
import { sortResultsSimple } from './helpers';

const loaders = {
forCollectiveId: (): DataLoader<number, ContributorsCacheEntry> =>
new DataLoader(async collectiveIds => {
const uniqueIds = uniq(collectiveIds);
Expand All @@ -17,4 +20,53 @@ export default {
const result = collectiveIds.map(id => contributorsByIds[id]);
return result;
}),

totalContributedToHost: {
buildLoader: ({
since,
hostId,
}: {
hostId: number;
since: Date | string;
}): DataLoader<
number,
{ CollectiveId: number; amount: number; currency: SupportedCurrency; HostCollectiveId: number }
> => {
const key = compact([hostId, since]).join('-');
Comment on lines +24 to +35
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand the benefit of using buildLoader rather than just a regular loader here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if I'm tripping, but isn't this required for sharing the since and hostId parameters across multiple DataLoader calls.

if (!loaders.totalContributedToHost[key]) {
loaders.totalContributedToHost[key] = new DataLoader(async (collectiveIds: number[]) => {
const stats = await sequelize.query(
`
SELECT t."FromCollectiveId" as "CollectiveId", SUM (t."amountInHostCurrency") as amount, t."hostCurrency" as currency, t."HostCollectiveId"
FROM "Transactions" t
WHERE t."FromCollectiveId" IN (:collectiveIds)
AND t.kind = 'CONTRIBUTION'
AND t."HostCollectiveId" = :hostId
AND t."deletedAt" IS NULL
AND t."RefundTransactionId" IS NULL
AND t."createdAt" >= :since
GROUP BY t."FromCollectiveId", t."HostCollectiveId", t."hostCurrency"
`,
{
replacements: {
collectiveIds,
since,
hostId,
},
type: sequelize.QueryTypes.SELECT,
raw: true,
},
);

return sortResultsSimple(collectiveIds, stats, row => row.CollectiveId);
});
}

return loaders.totalContributedToHost[key];
},
},
};

export type ContributorsLoaders = typeof loaders;

export default loaders;
1 change: 1 addition & 0 deletions server/graphql/loaders/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export const loaders = req => {
// Contributors
context.loaders.Contributors = {
forCollectiveId: contributorsLoaders.forCollectiveId(),
totalContributedToHost: contributorsLoaders.totalContributedToHost,
};

// Expense
Expand Down
9 changes: 9 additions & 0 deletions server/graphql/v2/input/PoliciesInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,5 +58,14 @@ export const GraphQLPoliciesInput = new GraphQLInputObjectType({
[POLICIES.COLLECTIVE_ADMINS_CAN_SEE_PAYOUT_METHODS]: {
type: GraphQLBoolean,
},
[POLICIES.CONTRIBUTOR_INFO_THRESHOLDS]: {
type: new GraphQLInputObjectType({
name: 'PoliciesContributorInfoThresholdsInput',
fields: () => ({
legalName: { type: GraphQLInt },
address: { type: GraphQLInt },
}),
}),
},
}),
});
47 changes: 46 additions & 1 deletion server/graphql/v2/object/ContributorProfile.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { GraphQLObjectType } from 'graphql';
import { GraphQLBoolean, GraphQLObjectType } from 'graphql';
import { GraphQLDateTime } from 'graphql-scalars';
import moment from 'moment';

import type { ContributorsLoaders } from '../../loaders/contributors';
import { GraphQLAccount } from '../interface/Account';

import { GraphQLAmount } from './Amount';

export const GraphQLContributorProfile = new GraphQLObjectType({
name: 'ContributorProfile',
description: 'This represents a profile that can be use to create a contribution',
Expand All @@ -10,5 +15,45 @@ export const GraphQLContributorProfile = new GraphQLObjectType({
type: GraphQLAccount,
description: 'The account that will be used to create the contribution',
},
forAccount: {
type: GraphQLAccount,
description: 'The account that will receive the contribution',
},
totalContributedToHost: {
type: GraphQLAmount,
description: 'The total amount contributed to the host by this contributor',
args: {
inCollectiveCurrency: {
type: GraphQLBoolean,
defaultValue: false,
description: 'When true, the amount is converted to the currency of the collective',
},
since: {
type: GraphQLDateTime,
description: 'The date since when to calculate the total. Defaults to the start of the current year.',
},
},
resolve: async ({ account, forAccount }, args, req) => {
const host = await req.loaders.Collective.byId.load(forAccount.HostCollectiveId);
const since = args.since ? moment(args.since).toISOString() : moment.utc().startOf('year').toISOString();
const stats = await (req.loaders.Contributors as ContributorsLoaders).totalContributedToHost
.buildLoader({ hostId: host.id, since })
.load(account.id);

const currency = args.inCollectiveCurrency ? forAccount.currency : host.currency;
if (!stats) {
return { value: 0, currency };
}

if (args.inCollectiveCurrency && stats.currency !== currency) {
const convertParams = { amount: stats.amount, fromCurrency: stats.currency, toCurrency: currency };
return {
value: await req.loaders.CurrencyExchangeRate.convert.load(convertParams),
currency,
};
}
return { value: stats.amount, currency };
},
},
}),
});
10 changes: 5 additions & 5 deletions server/graphql/v2/object/Individual.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { uniqBy } from 'lodash';
import { roles } from '../../../constants';
import { CollectiveType } from '../../../constants/collectives';
import twoFactorAuthLib from '../../../lib/two-factor-authentication';
import models, { Op } from '../../../models';
import models, { Collective, Op } from '../../../models';
import UserTwoFactorMethod from '../../../models/UserTwoFactorMethod';
import { getContextPermission, PERMISSION_TYPE } from '../../common/context-permissions';
import { checkScope } from '../../common/scope-check';
Expand Down Expand Up @@ -238,7 +238,7 @@ export const GraphQLIndividual = new GraphQLObjectType({
type: new GraphQLNonNull(GraphQLAccountReferenceInput),
},
},
async resolve(userCollective, args, req: Request) {
async resolve(userCollective: Collective, args, req: Request) {
const loggedInUser = req.remoteUser;
const forAccount = await fetchAccountWithReference(args.forAccount, {
throwIfMissing: true,
Expand Down Expand Up @@ -283,11 +283,11 @@ export const GraphQLIndividual = new GraphQLObjectType({
],
});

const contributorProfiles = [{ account: userCollective }];
const contributorProfiles = [{ account: userCollective, forAccount }];
memberships.forEach(membership => {
contributorProfiles.push({ account: membership.collective });
contributorProfiles.push({ account: membership.collective, forAccount });
membership.collective.children?.forEach(children => {
contributorProfiles.push({ account: children });
contributorProfiles.push({ account: children, forAccount });
});
});
return uniqBy(contributorProfiles, 'account.id');
Expand Down
36 changes: 34 additions & 2 deletions server/graphql/v2/object/Policies.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import { GraphQLBoolean, GraphQLInt, GraphQLObjectType, GraphQLString } from 'graphql';
import { get } from 'lodash';
import { get, isEmpty, isNil, mapValues } from 'lodash';

import POLICIES from '../../../constants/policies';
import POLICIES, { Policies } from '../../../constants/policies';
import { VirtualCardLimitIntervals } from '../../../constants/virtual-cards';
import { getFxRate } from '../../../lib/currency';
import { getPolicy } from '../../../lib/policies';
import { checkScope } from '../../common/scope-check';
import { GraphQLPolicyApplication } from '../enum/PolicyApplication';
import { getIdEncodeResolver, IDENTIFIER_TYPES } from '../identifiers';

import { GraphQLAmount } from './Amount';

const DEFAULT_USD_THRESHOLDS: Policies[POLICIES.CONTRIBUTOR_INFO_THRESHOLDS] = {
address: 5000e2,
legalName: 250e2,
};

export const GraphQLPolicies = new GraphQLObjectType({
name: 'Policies',
fields: () => ({
Expand Down Expand Up @@ -133,5 +139,31 @@ export const GraphQLPolicies = new GraphQLObjectType({
}
},
},
[POLICIES.CONTRIBUTOR_INFO_THRESHOLDS]: {
type: new GraphQLObjectType({
name: POLICIES.CONTRIBUTOR_INFO_THRESHOLDS,
fields: () => ({
legalName: { type: GraphQLInt },
address: { type: GraphQLInt },
}),
}),
description:
'Contribution threshold to enforce contributor info. This resolver can be called from the collective or the host, when resolved through the collective the thresholds are returned in the collective currency',
async resolve(account, args, req) {
const host =
account.HostCollectiveId && account.HostCollectiveId !== account.id
? await req.loaders.Collective.byId.load(account.HostCollectiveId)
: account;
let thresholds = await getPolicy(host, POLICIES.CONTRIBUTOR_INFO_THRESHOLDS, req);
let fxRate = 1;
if (!thresholds || isEmpty(thresholds)) {
fxRate = await getFxRate('USD', account.currency);
thresholds = DEFAULT_USD_THRESHOLDS;
} else if (host.currency !== account.currency) {
fxRate = await getFxRate(host.currency, account.currency);
kewitz marked this conversation as resolved.
Show resolved Hide resolved
}
return mapValues(thresholds, threshold => (isNil(threshold) ? null : Math.round(threshold * fxRate)));
},
},
}),
});
Loading