Skip to content

Commit

Permalink
Export Hosted Collectives: add contributionRefundedTotal and refact a…
Browse files Browse the repository at this point in the history
…verage resolvers (#10461)

* refact: add contributionRefundedTotal to HostedAccountSummary

* refact: HostedAccountSummary average period as argument

* chore: update schemas
  • Loading branch information
kewitz authored Nov 14, 2024
1 parent 58cf616 commit 1c80321
Show file tree
Hide file tree
Showing 4 changed files with 147 additions and 70 deletions.
3 changes: 1 addition & 2 deletions server/graphql/loaders/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -479,13 +479,12 @@ export const loaders = req => {
t."hostCurrency",
EXTRACT('days' FROM (NOW() - MAX(c."approvedAt"))) as "daysSinceApproved",
COUNT(t.id) FILTER (WHERE t.kind = 'EXPENSE' AND t.type = 'DEBIT') AS "expenseCount",
EXTRACT('days' FROM (NOW() - MIN(t."createdAt") FILTER (WHERE t.kind = 'EXPENSE' AND t.type = 'DEBIT'))) as "daysSinceFirstExpense",
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",
EXTRACT('days' FROM (NOW() - MIN(t."createdAt") FILTER (WHERE t.kind IN ('CONTRIBUTION', 'ADDED_FUNDS') AND t.type = 'CREDIT'))) as "daysSinceFirstContribution",
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 ('CONTRIBUTION', 'ADDED_FUNDS') AND t.type = 'DEBIT' AND t."isRefund" = true) AS "contributionRefundedTotal",
SUM(ABS(t."amountInHostCurrency")) FILTER (WHERE t.kind = 'HOST_FEE' AND t.type = 'DEBIT') AS "hostFeeTotal",
SUM(ABS(t."amountInHostCurrency")) FILTER (WHERE t.type = 'DEBIT' AND t.kind != 'HOST_FEE' AND t.kind != 'PAYMENT_PROCESSOR_FEE') AS "spentTotal",
SUM(ABS(t."amountInHostCurrency")) FILTER (WHERE t.type = 'CREDIT' AND t."kind" NOT IN ('PAYMENT_PROCESSOR_COVER')) AS "receivedTotal"
Expand Down
42 changes: 27 additions & 15 deletions server/graphql/schemaV2.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -9600,33 +9600,45 @@ Return a summary of transaction info about a given account within the context of
"""
type HostedAccountSummary {
expenseCount: Int
expenseTotal: Amount
expenseMaxValue: Amount
expenseDistinctPayee: Int
contributionCount: Int
contributionTotal: Amount
hostFeeTotal: Amount
spentTotal: Amount
receivedTotal: Amount

"""
Average calculated based on the number of months since the first transaction of this kind within the requested time frame
Average calculated over the number of months the collective was approved or the number of months since dateFrom, whichever is less
"""
expenseMonthlyAverageCount: Float
expenseTotal: Amount
expenseAverageTotal(period: AveragePeriod = MONTH): Amount

"""
Average calculated based on the number of months since the first transaction of this kind within the requested time frame
Average calculated over the number of months the collective was approved or the number of months since dateFrom, whichever is less
"""
expenseMonthlyAverageTotal: Amount
expenseMaxValue: Amount
expenseDistinctPayee: Int
contributionCount: Int
expenseAverageCount(period: AveragePeriod = MONTH): Float

"""
Average calculated based on the number of months since the first transaction of this kind within the requested time frame
Average calculated over the number of months the collective was approved or the number of months since dateFrom, whichever is less
"""
contributionMonthlyAverageCount: Float
contributionTotal: Amount
contributionAverageTotal(period: AveragePeriod = MONTH): Amount

"""
Average calculated based on the number of months since the first transaction of this kind within the requested time frame
Average calculated over the number of months/years the collective was approved or the number of months since dateFrom, whichever is less
"""
contributionMonthlyAverageTotal: Amount
hostFeeTotal: Amount
spentTotal: Amount
contributionAverageCount(period: AveragePeriod = MONTH): Float
spentTotalAverage(period: AveragePeriod = MONTH): Amount
receivedTotalAverage(period: AveragePeriod = MONTH): Amount
contributionRefundedTotal: Amount
}

"""
The period over which the average is calculated
"""
enum AveragePeriod {
YEAR
MONTH
}

"""
Expand Down
14 changes: 14 additions & 0 deletions server/graphql/v2/enum/AveragePeriod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { GraphQLEnumType } from 'graphql';

export const GraphQLAveragePeriod = new GraphQLEnumType({
name: 'AveragePeriod',
description: 'The period over which the average is calculated',
values: {
YEAR: {
value: 'year',
},
MONTH: {
value: 'month',
},
},
});
158 changes: 105 additions & 53 deletions server/graphql/v2/object/HostedAccountSummary.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { GraphQLFloat, GraphQLInt, GraphQLObjectType } from 'graphql';
import { ceil, min, round } from 'lodash';
import { min, round } from 'lodash';
import moment from 'moment';

import { GraphQLAveragePeriod } from '../enum/AveragePeriod';

import { GraphQLAmount } from './Amount';

export const HostedAccountSummary = new GraphQLObjectType({
Expand All @@ -13,28 +15,10 @@ export const HostedAccountSummary = new GraphQLObjectType({
type: GraphQLInt,
resolve: ({ summary }) => summary?.expenseCount || 0,
},
expenseMonthlyAverageCount: {
type: GraphQLFloat,
description:
'Average calculated over the number of months the collective was approved or the number of months since dateFrom, whichever is less',
resolve: ({ summary, months }) => {
const count = summary?.expenseCount || 0;
return months > 0 ? round(count / months, 2) : 0;
},
},
expenseTotal: {
type: GraphQLAmount,
resolve: ({ host, summary }) => ({ value: summary?.expenseTotal || 0, currency: host.currency }),
},
expenseMonthlyAverageTotal: {
type: GraphQLAmount,
description:
'Average calculated over the number of months the collective was approved or the number of months since dateFrom, whichever is less',
resolve: ({ host, summary, months }) => {
const value = months > 0 && summary?.expenseTotal ? Math.round(summary?.expenseTotal / months || 0) : 0;
return { value, currency: host.currency };
},
},
expenseMaxValue: {
type: GraphQLAmount,
resolve: ({ host, summary }) => ({ value: summary?.expenseMaxValue || 0, currency: host.currency }),
Expand All @@ -47,29 +31,10 @@ export const HostedAccountSummary = new GraphQLObjectType({
type: GraphQLInt,
resolve: ({ summary }) => summary?.contributionCount || 0,
},
contributionMonthlyAverageCount: {
type: GraphQLFloat,
description:
'Average calculated over the number of months the collective was approved or the number of months since dateFrom, whichever is less',
resolve: ({ summary, months }) => {
const count = summary?.contributionCount || 0;
return months > 0 ? round(count / months, 2) : 0;
},
},
contributionTotal: {
type: GraphQLAmount,
resolve: ({ host, summary }) => ({ value: summary?.contributionTotal || 0, currency: host.currency }),
},
contributionMonthlyAverageTotal: {
type: GraphQLAmount,
description:
'Average calculated over the number of months the collective was approved or the number of months since dateFrom, whichever is less',
resolve: ({ host, summary, months }) => {
const value =
months > 0 && summary?.contributionTotal ? Math.round(summary?.contributionTotal / months || 0) : 0;
return { value, currency: host.currency };
},
},
hostFeeTotal: {
type: GraphQLAmount,
resolve: ({ host, summary }) => ({ value: summary?.hostFeeTotal || 0, currency: host.currency }),
Expand All @@ -82,36 +47,123 @@ export const HostedAccountSummary = new GraphQLObjectType({
type: GraphQLAmount,
resolve: ({ host, summary }) => ({ value: summary?.receivedTotal || 0, currency: host.currency }),
},
spentTotalMonthlyAverage: {
// Averages
expenseAverageTotal: {
type: GraphQLAmount,
description:
'Average calculated over the number of months the collective was approved or the number of months since dateFrom, whichever is less',
args: {
period: {
type: GraphQLAveragePeriod,
defaultValue: 'month',
},
},
resolve: ({ host, summary, periods }, args) => {
const period = periods[args.period];
const value = period > 0 && summary?.expenseTotal ? Math.round(summary?.expenseTotal / period || 0) : 0;
return { value, currency: host.currency };
},
},
expenseAverageCount: {
type: GraphQLFloat,
description:
'Average calculated over the number of months the collective was approved or the number of months since dateFrom, whichever is less',
args: {
period: {
type: GraphQLAveragePeriod,
defaultValue: 'month',
},
},
resolve: ({ summary, periods }, args) => {
const period = periods[args.period];
const count = summary?.expenseCount || 0;
return period > 0 ? round(count / period, 2) : 0;
},
},
contributionAverageTotal: {
type: GraphQLAmount,
description:
'Average calculated over the number of months the collective was approved or the number of months since dateFrom, whichever is less',
args: {
period: {
type: GraphQLAveragePeriod,
defaultValue: 'month',
},
},
resolve: ({ host, summary, periods }, args) => {
const period = periods[args.period];
const value =
period > 0 && summary?.contributionTotal ? Math.round(summary?.contributionTotal / period || 0) : 0;
return { value, currency: host.currency };
},
},
contributionAverageCount: {
type: GraphQLFloat,
description:
'Average calculated over the number of months/years the collective was approved or the number of months since dateFrom, whichever is less',
args: {
period: {
type: GraphQLAveragePeriod,
defaultValue: 'month',
},
},
resolve: ({ summary, periods }, args) => {
const period = periods[args.period];
const count = summary?.contributionCount || 0;
return period > 0 ? round(count / period, 2) : 0;
},
},
spentTotalAverage: {
type: GraphQLAmount,
resolve: ({ host, summary, months }) => {
const value = months > 0 && summary?.spentTotal ? Math.round(summary?.spentTotal / months || 0) : 0;
args: {
period: {
type: GraphQLAveragePeriod,
defaultValue: 'month',
},
},
resolve: ({ host, summary, periods }, args) => {
const period = periods[args.period];
const value = period > 0 && summary?.spentTotal ? Math.round(summary?.spentTotal / period || 0) : 0;
return { value, currency: host.currency };
},
},
receivedTotalMonthlyAverage: {
receivedTotalAverage: {
type: GraphQLAmount,
resolve: ({ host, summary, months }) => {
const value = months > 0 && summary?.receivedTotal ? Math.round(summary?.receivedTotal / months || 0) : 0;
args: {
period: {
type: GraphQLAveragePeriod,
defaultValue: 'month',
},
},
resolve: ({ host, summary, periods }, args) => {
const period = periods[args.period];
const value = period > 0 && summary?.receivedTotal ? Math.round(summary?.receivedTotal / period || 0) : 0;
return { value, currency: host.currency };
},
},
contributionRefundedTotal: {
type: GraphQLAmount,
resolve: ({ host, summary }) => ({ value: summary?.contributionRefundedTotal || 0, currency: host.currency }),
},
}),
});

// It is OK to consider 1.4 months when calculating an average but it is misleading to consider 0.4 months.
const roundAveragePeriod = value => (value < 1 ? 1 : round(value, 1));

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);

// Average calculated over the number of months the collective was approved or the number of months since dateFrom, whichever is less
const monthsSinceApproved = moment.duration(summary?.daysSinceApproved || 0, 'days').asMonths();
let months;
// Periods are based on the time the collective is hosted (approved) or the number of months since dateFrom, whichever is less
const daysSinceApproved = moment.duration(summary?.daysSinceApproved || 0, 'days');
const monthsSinceApproved = daysSinceApproved.asMonths();
const yearsSinceApproved = daysSinceApproved.asYears();
let month = roundAveragePeriod(monthsSinceApproved);
let year = roundAveragePeriod(yearsSinceApproved);
if (args.dateFrom) {
const monthsSinceDateFrom = moment().diff(moment(args.dateFrom), 'months', true);
months = ceil(min([monthsSinceApproved, monthsSinceDateFrom]));
} else {
months = ceil(moment.duration(summary?.daysSinceApproved || 0, 'days').asMonths());
month = roundAveragePeriod(min([monthsSinceApproved, moment().diff(moment(args.dateFrom), 'months', true)]));
year = roundAveragePeriod(min([yearsSinceApproved, moment().diff(moment(args.dateFrom), 'years', true)]));
}

return { host, summary, months };
return { host, summary, periods: { month, year } };
};

0 comments on commit 1c80321

Please sign in to comment.