From 5b4f0567bae6a8fa0f9b473f1e8acec37bc0511d Mon Sep 17 00:00:00 2001 From: Leo Kewitz Date: Thu, 23 Jan 2025 16:07:43 +0100 Subject: [PATCH 1/3] feat: add CONTRIBUTOR_INFO_TRESHOLDS policy --- server/constants/policies.ts | 7 +++++ server/graphql/v2/input/PoliciesInput.ts | 9 ++++++ server/graphql/v2/object/Policies.ts | 36 ++++++++++++++++++++++-- 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/server/constants/policies.ts b/server/constants/policies.ts index 45782659d65..fff42378476 100644 --- a/server/constants/policies.ts +++ b/server/constants/policies.ts @@ -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<{ @@ -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] } = { @@ -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; diff --git a/server/graphql/v2/input/PoliciesInput.ts b/server/graphql/v2/input/PoliciesInput.ts index 4fd93479d08..54f8b834ea8 100644 --- a/server/graphql/v2/input/PoliciesInput.ts +++ b/server/graphql/v2/input/PoliciesInput.ts @@ -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 }, + }), + }), + }, }), }); diff --git a/server/graphql/v2/object/Policies.ts b/server/graphql/v2/object/Policies.ts index b1ca2e8bf40..753dc5474cd 100644 --- a/server/graphql/v2/object/Policies.ts +++ b/server/graphql/v2/object/Policies.ts @@ -1,8 +1,9 @@ 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'; @@ -10,6 +11,11 @@ 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: () => ({ @@ -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); + } + return mapValues(thresholds, threshold => (isNil(threshold) ? null : Math.round(threshold * fxRate))); + }, + }, }), }); From dc4c29651937686868148ff7e68b6b17435c63f0 Mon Sep 17 00:00:00 2001 From: Leo Kewitz Date: Thu, 23 Jan 2025 16:16:53 +0100 Subject: [PATCH 2/3] feat: add ContributorProfile.totalContributedToHost resolver --- ...nsactions-fromCollective-hostCollective.js | 18 ++++++ server/graphql/loaders/contributors.ts | 58 ++++++++++++++++++- server/graphql/loaders/index.js | 1 + .../graphql/v2/object/ContributorProfile.ts | 45 +++++++++++++- server/graphql/v2/object/Individual.ts | 10 ++-- 5 files changed, 123 insertions(+), 9 deletions(-) create mode 100644 migrations/20250123143500-index-transactions-fromCollective-hostCollective.js diff --git a/migrations/20250123143500-index-transactions-fromCollective-hostCollective.js b/migrations/20250123143500-index-transactions-fromCollective-hostCollective.js new file mode 100644 index 00000000000..3299bb9a660 --- /dev/null +++ b/migrations/20250123143500-index-transactions-fromCollective-hostCollective.js @@ -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; + `); + }, +}; diff --git a/server/graphql/loaders/contributors.ts b/server/graphql/loaders/contributors.ts index 6173be37788..2da7c4f3444 100644 --- a/server/graphql/loaders/contributors.ts +++ b/server/graphql/loaders/contributors.ts @@ -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 => new DataLoader(async collectiveIds => { const uniqueIds = uniq(collectiveIds); @@ -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('-'); + 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; diff --git a/server/graphql/loaders/index.js b/server/graphql/loaders/index.js index 5376f63b7aa..ccf99107f43 100644 --- a/server/graphql/loaders/index.js +++ b/server/graphql/loaders/index.js @@ -74,6 +74,7 @@ export const loaders = req => { // Contributors context.loaders.Contributors = { forCollectiveId: contributorsLoaders.forCollectiveId(), + totalContributedToHost: contributorsLoaders.totalContributedToHost, }; // Expense diff --git a/server/graphql/v2/object/ContributorProfile.ts b/server/graphql/v2/object/ContributorProfile.ts index 070d3ed49a3..28557a61ff5 100644 --- a/server/graphql/v2/object/ContributorProfile.ts +++ b/server/graphql/v2/object/ContributorProfile.ts @@ -1,7 +1,13 @@ -import { GraphQLObjectType } from 'graphql'; +import { GraphQLBoolean, GraphQLObjectType } from 'graphql'; +import { GraphQLDateTime } from 'graphql-scalars'; +import moment from 'moment'; +import { getFxRate } from '../../../lib/currency'; +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', @@ -10,5 +16,42 @@ 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', + }, + }, + resolve: async ({ account, forAccount }, args, req) => { + const host = await req.loaders.Collective.byId.load(forAccount.HostCollectiveId); + const 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 fxRate = await getFxRate(stats.currency, currency); + return { value: Math.round(stats.amount * fxRate), currency }; + } + return { value: stats.amount, currency }; + }, + }, }), }); diff --git a/server/graphql/v2/object/Individual.ts b/server/graphql/v2/object/Individual.ts index c61c43d63b6..904737b3f48 100644 --- a/server/graphql/v2/object/Individual.ts +++ b/server/graphql/v2/object/Individual.ts @@ -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'; @@ -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, @@ -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'); From 6648f5ceac3592927d52ddd2e72033274884c61e Mon Sep 17 00:00:00 2001 From: Leo Kewitz Date: Thu, 6 Feb 2025 16:10:06 +0100 Subject: [PATCH 3/3] refact: use CurrencyExchangeRate loaders Co-authored-by: Benjamin Piouffle --- server/graphql/v2/object/ContributorProfile.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/server/graphql/v2/object/ContributorProfile.ts b/server/graphql/v2/object/ContributorProfile.ts index 28557a61ff5..cef7c382dec 100644 --- a/server/graphql/v2/object/ContributorProfile.ts +++ b/server/graphql/v2/object/ContributorProfile.ts @@ -2,7 +2,6 @@ import { GraphQLBoolean, GraphQLObjectType } from 'graphql'; import { GraphQLDateTime } from 'graphql-scalars'; import moment from 'moment'; -import { getFxRate } from '../../../lib/currency'; import type { ContributorsLoaders } from '../../loaders/contributors'; import { GraphQLAccount } from '../interface/Account'; @@ -31,12 +30,12 @@ export const GraphQLContributorProfile = new GraphQLObjectType({ }, since: { type: GraphQLDateTime, - description: 'The date since when to calculate the total', + 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 = moment(args.since).toISOString() || moment.utc().startOf('year').toISOString(); + 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); @@ -47,8 +46,11 @@ export const GraphQLContributorProfile = new GraphQLObjectType({ } if (args.inCollectiveCurrency && stats.currency !== currency) { - const fxRate = await getFxRate(stats.currency, currency); - return { value: Math.round(stats.amount * fxRate), 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 }; },