From 2a96d5ba1b6428941b58089ba60eed9dc5001616 Mon Sep 17 00:00:00 2001 From: Leo Kewitz Date: Tue, 22 Oct 2024 14:30:41 +0200 Subject: [PATCH] Add some resolvers to Export Host Collectives CSV (#10399) * feat: add unhostedAt and unfrozenAt resolvers * feat: add HostedAccountSummary resolver * chore: update schemas * fix: hostedAccountSummary currency group --- server/graphql/loaders/index.js | 42 ++++ server/graphql/schemaV2.graphql | 228 ++++++++++++++++-- server/graphql/v2/interface/Account.ts | 24 ++ .../graphql/v2/interface/AccountWithHost.ts | 32 +++ .../graphql/v2/object/HostedAccountSummary.ts | 49 ++++ .../server/graphql/loaders/collective.test.ts | 112 ++++++++- 6 files changed, 468 insertions(+), 19 deletions(-) create mode 100644 server/graphql/v2/object/HostedAccountSummary.ts diff --git a/server/graphql/loaders/index.js b/server/graphql/loaders/index.js index 299960170fc..18986d7fb00 100644 --- a/server/graphql/loaders/index.js +++ b/server/graphql/loaders/index.js @@ -15,6 +15,7 @@ import { sumCollectivesTransactions, } from '../../lib/budget'; import { getFxRate } from '../../lib/currency'; +import { ifStr } from '../../lib/utils'; import models, { Op, sequelize } from '../../models'; import { generateTotalAccountHostAgreementsLoader } from './agreements'; @@ -466,6 +467,47 @@ export const loaders = req => { return sortResultsSimple(collectiveIds, stats, row => row.CollectiveId); }), + hostedAccountSummary: { + buildLoader: ({ dateFrom, dateTo } = {}) => + new DataLoader(async collectiveIds => { + const stats = await sequelize.query( + ` + SELECT + t."CollectiveId", + t."hostCurrency", + COUNT(t.id) FILTER (WHERE t.kind = 'EXPENSE' AND t.type = 'DEBIT') AS "expenseCount", + SUM(ABS(t."amountInHostCurrency")) FILTER (WHERE t.kind = 'EXPENSE' AND t.type = 'DEBIT') AS "expenseTotal", + MAX(ABS(t."amountInHostCurrency")) FILTER (WHERE t.kind = 'EXPENSE' AND t.type = 'DEBIT') AS "expenseMaxValue", + COUNT(DISTINCT t."FromCollectiveId") FILTER (WHERE t.kind = 'EXPENSE' AND t.type = 'DEBIT') AS "expenseDistinctPayee", + COUNT(t.id) FILTER (WHERE t.kind IN ('CONTRIBUTION', 'ADDED_FUNDS') AND t.type = 'CREDIT') AS "contributionCount", + SUM(t."amountInHostCurrency") FILTER (WHERE t.kind IN ('CONTRIBUTION', 'ADDED_FUNDS') AND t.type = 'CREDIT') AS "contributionTotal", + SUM(ABS(t."amountInHostCurrency")) FILTER (WHERE t.kind IN ('HOST_FEE') AND t.type = 'DEBIT') AS "hostFeeTotal", + SUM(ABS(t."amountInHostCurrency")) FILTER (WHERE t.kind IN ('CONTRIBUTION', 'EXPENSE') AND t.type = 'DEBIT') AS "spentTotal" + FROM + "Transactions" t + INNER JOIN "Collectives" c ON t."CollectiveId" = c.id + WHERE t."CollectiveId" IN (:collectiveIds) + AND t."deletedAt" IS NULL + ${ifStr(dateFrom, 'AND t."createdAt" > :dateFrom')} + ${ifStr(dateTo, 'AND t."createdAt" <= :dateTo')} + AND t."HostCollectiveId" = c."HostCollectiveId" + GROUP BY + t."CollectiveId", t."hostCurrency" + `, + { + replacements: { + collectiveIds, + dateFrom, + dateTo, + }, + type: sequelize.QueryTypes.SELECT, + raw: true, + }, + ); + + return sortResultsSimple(collectiveIds, stats, row => row.CollectiveId); + }), + }, }; /** *** Tier *****/ diff --git a/server/graphql/schemaV2.graphql b/server/graphql/schemaV2.graphql index 22eabe4d8e3..25158e8d0fc 100644 --- a/server/graphql/schemaV2.graphql +++ b/server/graphql/schemaV2.graphql @@ -223,6 +223,16 @@ interface Account { """ updatedAt: DateTime + """ + Date of unhosting by a given Fiscal Host. + """ + unhostedAt( + """ + The host account this collective was hosted by + """ + host: AccountReferenceInput! + ): DateTime + """ Returns whether this account is archived """ @@ -1726,6 +1736,23 @@ enum ImageFormat { svg } +input AccountReferenceInput { + """ + The public id identifying the account (ie: dgm9bnk8-0437xqry-ejpvzeol-jdayw5re) + """ + id: String + + """ + The internal id of the account (ie: 580) + """ + legacyId: Int @deprecated(reason: "2020-01-01: should only be used during the transition to GraphQL API v2.") + + """ + The slug identifying the account (ie: babel for https://opencollective.com/babel) + """ + slug: String +} + """ A collection of "Members" (ie: Organization backing a Collective) """ @@ -2647,6 +2674,16 @@ type Host implements Account & AccountWithContributions { createdAt: DateTime updatedAt: DateTime + """ + Date of unhosting by a given Fiscal Host. + """ + unhostedAt( + """ + The host account this collective was hosted by + """ + host: AccountReferenceInput! + ): DateTime + """ Returns whether this account is archived """ @@ -4122,23 +4159,6 @@ type Contributor { publicMessage: String } -input AccountReferenceInput { - """ - The public id identifying the account (ie: dgm9bnk8-0437xqry-ejpvzeol-jdayw5re) - """ - id: String - - """ - The internal id of the account (ie: 580) - """ - legacyId: Int @deprecated(reason: "2020-01-01: should only be used during the transition to GraphQL API v2.") - - """ - The slug identifying the account (ie: babel for https://opencollective.com/babel) - """ - slug: String -} - """ A legal document (e.g. W9, W8BEN, W8BEN-E) """ @@ -7640,6 +7660,16 @@ type Bot implements Account { createdAt: DateTime updatedAt: DateTime + """ + Date of unhosting by a given Fiscal Host. + """ + unhostedAt( + """ + The host account this collective was hosted by + """ + host: AccountReferenceInput! + ): DateTime + """ Returns whether this account is archived """ @@ -8487,6 +8517,16 @@ type Collective implements Account & AccountWithHost & AccountWithContributions createdAt: DateTime updatedAt: DateTime + """ + Date of unhosting by a given Fiscal Host. + """ + unhostedAt( + """ + The host account this collective was hosted by + """ + host: AccountReferenceInput! + ): DateTime + """ Returns whether this account is archived """ @@ -9313,6 +9353,11 @@ type Collective implements Account & AccountWithHost & AccountWithContributions """ approvedAt: DateTime + """ + Date when the collective was last unfrozen by current Fiscal Host + """ + unfrozenAt: DateTime + """ Returns whether it's approved by the Fiscal Host """ @@ -9332,6 +9377,17 @@ type Collective implements Account & AccountWithHost & AccountWithContributions """ offset: Int! = 0 ): AgreementCollection + summary( + """ + Calculate amount after this date + """ + dateFrom: DateTime + + """ + Calculate amount before this date + """ + dateTo: DateTime + ): HostedAccountSummary """ Number of unique financial contributors. @@ -9421,6 +9477,11 @@ interface AccountWithHost { """ approvedAt: DateTime + """ + Date when the collective was last unfrozen by current Fiscal Host + """ + unfrozenAt: DateTime + """ Returns whether it's approved by the Fiscal Host """ @@ -9445,6 +9506,31 @@ interface AccountWithHost { """ offset: Int! = 0 ): AgreementCollection + summary( + """ + Calculate amount after this date + """ + dateFrom: DateTime + + """ + Calculate amount before this date + """ + dateTo: DateTime + ): HostedAccountSummary +} + +""" +Return a summary of transaction info about a given account within the context of its current fiscal host +""" +type HostedAccountSummary { + expenseCount: Int + expenseTotal: Amount + expenseMaxValue: Amount + expenseDistinctPayee: Int + contributionCount: Int + contributionTotal: Amount + hostFeeTotal: Amount + spentTotal: Amount } """ @@ -9787,6 +9873,16 @@ type Event implements Account & AccountWithHost & AccountWithContributions & Acc createdAt: DateTime updatedAt: DateTime + """ + Date of unhosting by a given Fiscal Host. + """ + unhostedAt( + """ + The host account this collective was hosted by + """ + host: AccountReferenceInput! + ): DateTime + """ Returns whether this account is archived """ @@ -10613,6 +10709,11 @@ type Event implements Account & AccountWithHost & AccountWithContributions & Acc """ approvedAt: DateTime + """ + Date when the collective was last unfrozen by current Fiscal Host + """ + unfrozenAt: DateTime + """ Returns whether it's approved by the Fiscal Host """ @@ -10632,6 +10733,17 @@ type Event implements Account & AccountWithHost & AccountWithContributions & Acc """ offset: Int! = 0 ): AgreementCollection + summary( + """ + Calculate amount after this date + """ + dateFrom: DateTime + + """ + Calculate amount before this date + """ + dateTo: DateTime + ): HostedAccountSummary """ Number of unique financial contributors. @@ -10869,6 +10981,16 @@ type Individual implements Account { createdAt: DateTime updatedAt: DateTime + """ + Date of unhosting by a given Fiscal Host. + """ + unhostedAt( + """ + The host account this collective was hosted by + """ + host: AccountReferenceInput! + ): DateTime + """ Returns whether this account is archived """ @@ -11922,6 +12044,16 @@ type Organization implements Account & AccountWithContributions { createdAt: DateTime updatedAt: DateTime + """ + Date of unhosting by a given Fiscal Host. + """ + unhostedAt( + """ + The host account this collective was hosted by + """ + host: AccountReferenceInput! + ): DateTime + """ Returns whether this account is archived """ @@ -12864,6 +12996,16 @@ type Vendor implements Account & AccountWithContributions { createdAt: DateTime updatedAt: DateTime + """ + Date of unhosting by a given Fiscal Host. + """ + unhostedAt( + """ + The host account this collective was hosted by + """ + host: AccountReferenceInput! + ): DateTime + """ Returns whether this account is archived """ @@ -16440,6 +16582,16 @@ type Fund implements Account & AccountWithHost & AccountWithContributions { createdAt: DateTime updatedAt: DateTime + """ + Date of unhosting by a given Fiscal Host. + """ + unhostedAt( + """ + The host account this collective was hosted by + """ + host: AccountReferenceInput! + ): DateTime + """ Returns whether this account is archived """ @@ -17266,6 +17418,11 @@ type Fund implements Account & AccountWithHost & AccountWithContributions { """ approvedAt: DateTime + """ + Date when the collective was last unfrozen by current Fiscal Host + """ + unfrozenAt: DateTime + """ Returns whether it's approved by the Fiscal Host """ @@ -17285,6 +17442,17 @@ type Fund implements Account & AccountWithHost & AccountWithContributions { """ offset: Int! = 0 ): AgreementCollection + summary( + """ + Calculate amount after this date + """ + dateFrom: DateTime + + """ + Calculate amount before this date + """ + dateTo: DateTime + ): HostedAccountSummary """ Number of unique financial contributors. @@ -17400,6 +17568,16 @@ type Project implements Account & AccountWithHost & AccountWithContributions & A createdAt: DateTime updatedAt: DateTime + """ + Date of unhosting by a given Fiscal Host. + """ + unhostedAt( + """ + The host account this collective was hosted by + """ + host: AccountReferenceInput! + ): DateTime + """ Returns whether this account is archived """ @@ -18226,6 +18404,11 @@ type Project implements Account & AccountWithHost & AccountWithContributions & A """ approvedAt: DateTime + """ + Date when the collective was last unfrozen by current Fiscal Host + """ + unfrozenAt: DateTime + """ Returns whether it's approved by the Fiscal Host """ @@ -18245,6 +18428,17 @@ type Project implements Account & AccountWithHost & AccountWithContributions & A """ offset: Int! = 0 ): AgreementCollection + summary( + """ + Calculate amount after this date + """ + dateFrom: DateTime + + """ + Calculate amount before this date + """ + dateTo: DateTime + ): HostedAccountSummary """ Number of unique financial contributors. diff --git a/server/graphql/v2/interface/Account.ts b/server/graphql/v2/interface/Account.ts index 34868e8a2d3..e4cb38a0906 100644 --- a/server/graphql/v2/interface/Account.ts +++ b/server/graphql/v2/interface/Account.ts @@ -4,6 +4,7 @@ import { assign, get, invert, isEmpty, isNil, isNull, merge, omit, omitBy } from import moment from 'moment'; import { Order, Sequelize } from 'sequelize'; +import ActivityTypes from '../../../constants/activities'; import { CollectiveType } from '../../../constants/collectives'; import FEATURE from '../../../constants/feature'; import { buildSearchConditions } from '../../../lib/sql-search'; @@ -210,6 +211,28 @@ const accountFieldsDefinition = () => ({ type: GraphQLDateTime, description: 'The time of last update', }, + unhostedAt: { + description: 'Date of unhosting by a given Fiscal Host.', + type: GraphQLDateTime, + args: { + host: { + type: new GraphQLNonNull(GraphQLAccountReferenceInput), + description: 'The host account this collective was hosted by', + }, + }, + async resolve(collective, args, req) { + const host = await fetchAccountWithReference(args.host, { loaders: req.loaders, throwIfMissing: true }); + const activity = await models.Activity.findOne({ + order: [['createdAt', 'DESC']], + where: { + CollectiveId: collective.id, + type: ActivityTypes.COLLECTIVE_UNHOSTED, + HostCollectiveId: host.id, + }, + }); + return activity?.createdAt; + }, + }, isArchived: { type: new GraphQLNonNull(GraphQLBoolean), description: 'Returns whether this account is archived', @@ -1131,6 +1154,7 @@ export const AccountFields = { } }, }, + transactions: accountTransactions, orders: accountOrders, expenses: { diff --git a/server/graphql/v2/interface/AccountWithHost.ts b/server/graphql/v2/interface/AccountWithHost.ts index dc829fbece2..a0ae4fa8c88 100644 --- a/server/graphql/v2/interface/AccountWithHost.ts +++ b/server/graphql/v2/interface/AccountWithHost.ts @@ -2,7 +2,9 @@ import { GraphQLBoolean, GraphQLFloat, GraphQLInterfaceType, GraphQLNonNull } fr import { GraphQLDateTime } from 'graphql-scalars'; import { clamp, isNumber } from 'lodash'; +import ActivityTypes from '../../../constants/activities'; import { HOST_FEE_STRUCTURE } from '../../../constants/host-fee-structure'; +import models from '../../../models'; import Agreement from '../../../models/Agreement'; import Collective from '../../../models/Collective'; import HostApplication from '../../../models/HostApplication'; @@ -13,6 +15,7 @@ import { GraphQLPaymentMethodService } from '../enum/PaymentMethodService'; import { GraphQLPaymentMethodType } from '../enum/PaymentMethodType'; import { GraphQLHost } from '../object/Host'; import { GraphQLHostApplication } from '../object/HostApplication'; +import { HostedAccountSummary, resolveHostedAccountSummary } from '../object/HostedAccountSummary'; import { getCollectionArgs } from './Collection'; @@ -125,6 +128,21 @@ export const AccountWithHostFields = { return account.approvedAt; }, }, + unfrozenAt: { + type: GraphQLDateTime, + description: 'Date when the collective was last unfrozen by current Fiscal Host', + async resolve(collective) { + const activity = await models.Activity.findOne({ + order: [['createdAt', 'DESC']], + where: { + CollectiveId: collective.id, + type: ActivityTypes.COLLECTIVE_UNFROZEN, + HostCollectiveId: collective.HostCollectiveId, + }, + }); + return activity?.createdAt; + }, + }, isApproved: { description: "Returns whether it's approved by the Fiscal Host", type: new GraphQLNonNull(GraphQLBoolean), @@ -173,6 +191,20 @@ export const AccountWithHostFields = { }; }, }, + summary: { + type: HostedAccountSummary, + args: { + dateFrom: { + type: GraphQLDateTime, + description: 'Calculate amount after this date', + }, + dateTo: { + type: GraphQLDateTime, + description: 'Calculate amount before this date', + }, + }, + resolve: resolveHostedAccountSummary, + }, }; export const GraphQLAccountWithHost = new GraphQLInterfaceType({ diff --git a/server/graphql/v2/object/HostedAccountSummary.ts b/server/graphql/v2/object/HostedAccountSummary.ts new file mode 100644 index 00000000000..ce9b84e8e5e --- /dev/null +++ b/server/graphql/v2/object/HostedAccountSummary.ts @@ -0,0 +1,49 @@ +import { GraphQLInt, GraphQLObjectType } from 'graphql'; + +import { GraphQLAmount } from './Amount'; + +export const HostedAccountSummary = new GraphQLObjectType({ + name: 'HostedAccountSummary', + description: + 'Return a summary of transaction info about a given account within the context of its current fiscal host', + fields: () => ({ + expenseCount: { + type: GraphQLInt, + resolve: ({ summary }) => summary?.expenseCount || 0, + }, + expenseTotal: { + type: GraphQLAmount, + resolve: ({ host, summary }) => ({ value: summary?.expenseTotal || 0, currency: host.currency }), + }, + expenseMaxValue: { + type: GraphQLAmount, + resolve: ({ host, summary }) => ({ value: summary?.expenseMaxValue || 0, currency: host.currency }), + }, + expenseDistinctPayee: { + type: GraphQLInt, + resolve: ({ summary }) => summary?.expenseDistinctPayee || 0, + }, + contributionCount: { + type: GraphQLInt, + resolve: ({ summary }) => summary?.contributionCount || 0, + }, + contributionTotal: { + type: GraphQLAmount, + resolve: ({ host, summary }) => ({ value: summary?.contributionTotal || 0, currency: host.currency }), + }, + hostFeeTotal: { + type: GraphQLAmount, + resolve: ({ host, summary }) => ({ value: summary?.hostFeeTotal || 0, currency: host.currency }), + }, + spentTotal: { + type: GraphQLAmount, + resolve: ({ host, summary }) => ({ value: summary?.spentTotal || 0, currency: host.currency }), + }, + }), +}); + +export const resolveHostedAccountSummary = async (account, args, req) => { + const host = await req.loaders.Collective.byId.load(account.HostCollectiveId); + const summary = await req.loaders.Collective.stats.hostedAccountSummary.buildLoader(args).load(account.id); + return { host, summary }; +}; diff --git a/test/server/graphql/loaders/collective.test.ts b/test/server/graphql/loaders/collective.test.ts index c3314d44b36..31eefb7d332 100644 --- a/test/server/graphql/loaders/collective.test.ts +++ b/test/server/graphql/loaders/collective.test.ts @@ -1,10 +1,19 @@ import { expect } from 'chai'; +import moment from 'moment'; import { CollectiveType } from '../../../../server/constants/collectives'; import MemberRoles from '../../../../server/constants/roles'; +import { TransactionKind } from '../../../../server/constants/transaction-kind'; import CollectiveLoaders from '../../../../server/graphql/loaders/collective'; -import { fakeCollective, fakeMember, fakeUser } from '../../../test-helpers/fake-data'; -import { resetTestDB } from '../../../utils'; +import { + fakeActiveHost, + fakeCollective, + fakeMember, + fakeTransaction, + fakeUser, + multiple, +} from '../../../test-helpers/fake-data'; +import { makeRequest, resetTestDB } from '../../../utils'; describe('server/graphql/loaders/collective', () => { before(async () => { @@ -118,4 +127,103 @@ describe('server/graphql/loaders/collective', () => { }); }); }); + + describe('transactionSummary', () => { + let collectives; + const today = moment().utc().startOf('day').toDate(); + const lastWeek = moment().utc().subtract(8, 'days').toDate(); + + before(async () => { + const host = await fakeActiveHost(); + collectives = await multiple(fakeCollective, 3, { HostCollectiveId: host.id }); + await Promise.all( + collectives.map(async c => { + await fakeTransaction({ + CollectiveId: c.id, + kind: TransactionKind.EXPENSE, + amount: -1000, + HostCollectiveId: host.id, + createdAt: today, + }); + await fakeTransaction({ + CollectiveId: c.id, + kind: TransactionKind.CONTRIBUTION, + amount: 1000, + HostCollectiveId: host.id, + createdAt: today, + }); + await fakeTransaction({ + CollectiveId: c.id, + kind: TransactionKind.HOST_FEE, + amount: -100, + HostCollectiveId: host.id, + createdAt: today, + }); + }), + ); + await Promise.all( + collectives.map(async c => { + await fakeTransaction({ + CollectiveId: c.id, + kind: TransactionKind.EXPENSE, + amount: -2000, + HostCollectiveId: host.id, + createdAt: lastWeek, + }); + await fakeTransaction({ + CollectiveId: c.id, + kind: TransactionKind.CONTRIBUTION, + amount: 1500, + HostCollectiveId: host.id, + createdAt: lastWeek, + }); + await fakeTransaction({ + CollectiveId: c.id, + kind: TransactionKind.HOST_FEE, + amount: -150, + HostCollectiveId: host.id, + createdAt: lastWeek, + }); + }), + ); + }); + + it('should return the financial summary for a collective', async () => { + const request = makeRequest(); + const result = await request.loaders.Collective.stats.hostedAccountSummary.buildLoader().load(collectives[0].id); + + expect(result).to.containSubset({ + CollectiveId: collectives[0].id, + hostCurrency: 'USD', + expenseCount: 2, + expenseTotal: 3000, + expenseMaxValue: 2000, + expenseDistinctPayee: 2, + contributionCount: 2, + contributionTotal: 2500, + hostFeeTotal: 250, + spentTotal: 3000, + }); + }); + + it('should return the financial summary for a collective since date', async () => { + const request = makeRequest(); + const result = await request.loaders.Collective.stats.hostedAccountSummary + .buildLoader({ dateFrom: moment().utc().subtract(4, 'days').toDate() }) + .load(collectives[0].id); + + expect(result).to.containSubset({ + CollectiveId: collectives[0].id, + hostCurrency: 'USD', + expenseCount: 1, + expenseTotal: 1000, + expenseMaxValue: 1000, + expenseDistinctPayee: 1, + contributionCount: 1, + contributionTotal: 1000, + hostFeeTotal: 100, + spentTotal: 1000, + }); + }); + }); });