Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
36 changes: 36 additions & 0 deletions migrations/20260123205849-add-transactions-order-index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
'use strict';

/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface) {
await queryInterface.sequelize.query(`
CREATE INDEX CONCURRENTLY IF NOT EXISTS "transactions__contributions_date"
ON "Transactions" (COALESCE("clearedAt", "createdAt"))
INCLUDE ("OrderId", "HostCollectiveId")
WHERE
"kind" IN ('CONTRIBUTION', 'ADDED_FUNDS') AND
type = 'CREDIT' AND
"isRefund" = false AND "RefundTransactionId" IS NULL AND "deletedAt" IS NULL;
`);

await queryInterface.sequelize.query(`
CREATE INDEX CONCURRENTLY IF NOT EXISTS "transactions__contributions_host_id"
ON "Transactions" ("HostCollectiveId", COALESCE("clearedAt", "createdAt"))
INCLUDE ("OrderId")
WHERE
"kind" IN ('CONTRIBUTION', 'ADDED_FUNDS') AND
type = 'CREDIT' AND
"isRefund" = false AND "RefundTransactionId" IS NULL and "deletedAt" IS NULL;
`);
},

async down(queryInterface) {
await queryInterface.sequelize.query(`
DROP INDEX CONCURRENTLY IF EXISTS "transactions__contributions_date";
`);

await queryInterface.sequelize.query(`
DROP INDEX CONCURRENTLY IF EXISTS "transactions__contributions_host_id";
`);
},
};
5 changes: 5 additions & 0 deletions server/graphql/schemaV2.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -4078,6 +4078,11 @@
"""
hostExpensesReport(timeUnit: TimeUnit = MONTH, dateFrom: DateTime, dateTo: DateTime): HostExpensesReports

"""
EXPERIMENTAL (this may change or be removed)
"""
hostContributionsReport(timeUnit: TimeUnit = MONTH, dateFrom: DateTime, dateTo: DateTime): HostExpensesReports

Check notice on line 4084 in server/graphql/schemaV2.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector - Schema v2

Field 'hostContributionsReport' was added to object type 'Host'

Field 'hostContributionsReport' was added to object type 'Host'

"""
The list of payment methods (Stripe, Paypal, manual bank transfer, etc ...) the Host can accept for its Collectives
"""
Expand Down
68 changes: 67 additions & 1 deletion server/graphql/v2/object/Host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ import { GraphQLLastCommentBy } from '../enum/LastCommentByType';
import { GraphQLLegalDocumentRequestStatus } from '../enum/LegalDocumentRequestStatus';
import { GraphQLLegalDocumentType } from '../enum/LegalDocumentType';
import { PaymentMethodLegacyTypeEnum } from '../enum/PaymentMethodLegacyType';
import { GraphQLTimeUnit } from '../enum/TimeUnit';
import { GraphQLTimeUnit, TimeUnit } from '../enum/TimeUnit';
import { GraphQLTransactionsImportRowStatus, TransactionsImportRowStatus } from '../enum/TransactionsImportRowStatus';
import { GraphQLTransactionsImportStatus } from '../enum/TransactionsImportStatus';
import { GraphQLTransactionsImportType } from '../enum/TransactionsImportType';
Expand Down Expand Up @@ -601,6 +601,72 @@ export const GraphQLHost = new GraphQLObjectType({
};
},
},
hostContributionsReport: {
type: GraphQLHostExpensesReports,
description: 'EXPERIMENTAL (this may change or be removed)',
args: {
timeUnit: {
type: GraphQLTimeUnit,
defaultValue: 'MONTH',
},
dateFrom: {
type: GraphQLDateTime,
},
dateTo: {
type: GraphQLDateTime,
},
},
resolve: async (host: Collective, args: { timeUnit: TimeUnit; dateFrom: Date; dateTo: Date }) => {
if (args.timeUnit !== 'MONTH' && args.timeUnit !== 'QUARTER' && args.timeUnit !== 'YEAR') {
throw new Error('Only monthly, quarterly and yearly reports are supported.');
}

const query = `
WITH HostCollectiveIds AS (
SELECT "id" FROM "Collectives"
WHERE "id" = :hostCollectiveId
OR ("ParentCollectiveId" = :hostCollectiveId AND "type" != 'VENDOR')
)
SELECT
DATE_TRUNC(:timeUnit, COALESCE(t."clearedAt", t."createdAt") AT TIME ZONE 'UTC') AS "date",
SUM(t."amountInHostCurrency") AS "amount",
(SELECT "currency" FROM "Collectives" where id = :hostCollectiveId) as "currency",
COUNT(o."id") AS "count",
CASE
WHEN o."CollectiveId" IN (SELECT * FROM HostCollectiveIds) THEN TRUE ELSE FALSE
END AS "isHost",
o."AccountingCategoryId"

FROM "Transactions" t
JOIN "Orders" o ON o.id = t."OrderId"

WHERE t."HostCollectiveId" = :hostCollectiveId
AND t."kind" in ('CONTRIBUTION', 'ADDED_FUNDS') AND t."type" = 'CREDIT' AND NOT t."isRefund" AND t."RefundTransactionId" IS NULL AND t."deletedAt" IS NULL
${args.dateFrom ? 'AND COALESCE(t."clearedAt", t."createdAt") >= :dateFrom' : ''}
${args.dateTo ? 'AND COALESCE(t."clearedAt", t."createdAt") <= :dateTo' : ''}

GROUP BY "date", "isHost", o."AccountingCategoryId"
`;

const queryResult = await sequelize.query(query, {
replacements: {
hostCollectiveId: host.id,
timeUnit: args.timeUnit,
dateTo: moment(args.dateTo).utc().toISOString(),
dateFrom: moment(args.dateFrom).utc().toISOString(),

This comment was marked as outdated.

},

This comment was marked as outdated.

type: sequelize.QueryTypes.SELECT,
raw: true,
});

return {
timeUnit: args.timeUnit,
dateFrom: args.dateFrom,
dateTo: args.dateTo,
nodes: queryResult,
};
},
},
supportedPaymentMethods: {
type: new GraphQLList(GraphQLPaymentMethodLegacyType),
description:
Expand Down
80 changes: 62 additions & 18 deletions server/graphql/v2/query/collection/OrdersCollectionQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import express from 'express';
import { GraphQLBoolean, GraphQLEnumType, GraphQLInt, GraphQLList, GraphQLNonNull, GraphQLString } from 'graphql';
import { GraphQLDateTime } from 'graphql-scalars';
import { compact, isEmpty, isNil, uniq } from 'lodash';
import { Includeable, Order, Utils as SequelizeUtils, WhereOptions } from 'sequelize';
import { DataTypes, Includeable, Order, Utils as SequelizeUtils, WhereOptions } from 'sequelize';

import OrderStatuses from '../../../../constants/order-status';
import { includeCte } from '../../../../lib/sequelize-cte';
import { buildSearchConditions } from '../../../../lib/sql-search';
import { ifStr } from '../../../../lib/utils';
import models, { AccountingCategory, Collective, Op, sequelize } from '../../../../models';
import { checkScope } from '../../../common/scope-check';
import { Forbidden, NotFound, Unauthorized } from '../../../errors';
Expand Down Expand Up @@ -554,21 +556,6 @@ export const OrdersCollectionResolver = async (args, req: express.Request) => {
where['data.expectedAt'][Op.lte] = args.expectedDateTo;
}

if (args.chargedDateFrom) {
where[Op.and].push(
sequelize.where(sequelize.literal(`COALESCE("Subscription"."lastChargedAt", "Order"."createdAt")`), {
[Op.gte]: args.chargedDateFrom,
}),
);
}
if (args.chargedDateTo) {
where[Op.and].push(
sequelize.where(sequelize.literal(`COALESCE("Subscription"."lastChargedAt", "Order"."createdAt")`), {
[Op.lte]: args.chargedDateTo,
}),
);
}

if (args.status && args.status.length > 0) {
where['status'] = { [Op.in]: args.status };
if (args.status.includes(OrderStatuses.PAUSED) && args.pausedBy) {
Expand Down Expand Up @@ -656,9 +643,66 @@ export const OrdersCollectionResolver = async (args, req: express.Request) => {
order = [[args.orderBy.field, args.orderBy.direction]];
}
const { offset, limit } = args;

const cte = [];

if (args.chargedDateFrom || args.chargedDateTo) {
cte.push({
query: SequelizeUtils.formatNamedParameters(
`
SELECT DISTINCT t."OrderId" as "id"
FROM "Transactions" "t"
WHERE t."kind" in ('CONTRIBUTION', 'ADDED_FUNDS') AND
${ifStr(args.chargedDateFrom, `COALESCE(t."clearedAt", t."createdAt") >= :chargedDateFrom AND`)}
${ifStr(args.chargedDateTo, `COALESCE(t."clearedAt", t."createdAt") <= :chargedDateTo AND`)}
t."type" = 'CREDIT' AND
NOT t."isRefund" AND
t."RefundTransactionId" IS NULL AND
t."deletedAt" IS NULL
${ifStr(host?.id, `AND t."HostCollectiveId" = :hostCollectiveId`)}

This comment was marked as outdated.

`,
{
chargedDateFrom: args.chargedDateFrom,
chargedDateTo: args.chargedDateTo,

This comment was marked as outdated.

hostCollectiveId: host?.id,
oppositeCollectiveId: oppositeAccount?.id,
},
'postgres',
),
as: 'OrdersWithTxns',
});

include.push(
includeCte(
'OrdersWithTxns',
{
id: {
type: DataTypes.INTEGER,
},
},
models.Order,
'id',
{ required: true },

This comment was marked as outdated.

),
);
}

return {
nodes: () => models.Order.findAll({ include, where, order, offset, limit }),
totalCount: () => models.Order.count({ include, where }),
nodes: () =>
models.Order.findAll({
cte,
include,
where,
order,
offset,
limit,
}),
totalCount: () =>
models.Order.count({
cte,
include,
where,
}),
limit: args.limit,
offset: args.offset,
createdByUsers: async (subArgs: { limit?: number; offset?: number; searchTerm?: string } = {}) => {
Expand Down
114 changes: 114 additions & 0 deletions server/lib/sequelize-cte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { Association, IncludeOptions, Model, ModelAttributes, ModelStatic, Sequelize } from 'sequelize';

declare module 'sequelize' {
interface CteOption {
as: string;
query: string;
}

interface QueryOptions {
/** @experimental Use with caution. Not tested with all query types. */
cte?: CteOption[];
}

interface CountOptions {
/** @experimental Use with caution. Not tested with all query types. */
cte?: CteOption[];
}
}

//

/**
* Adds a sequelize hook to prepend optional CTEs to a query.
* CTEs can be added via the `cte` option in the query options.
*
*/
export function sequelizeCte(sequelize: Sequelize) {
sequelize.addHook('beforeQuery', (options, query) => {
if (options.cte && options.cte.length > 0) {
const queryGenerator = sequelize.getQueryInterface().queryGenerator as {
quoteIdentifier: (identifier: string) => string;
};
const runFn = query['run'];
(query as unknown)['run'] = async function (sql, bindParams) {
const ctes = options.cte.map(cte => `${queryGenerator.quoteIdentifier(cte.as)} AS (${cte.query})`).join(', ');
const result = await Reflect.apply(runFn, query, [`WITH ${ctes} ${sql}`, bindParams]);
return result;
};
}
});
}

/**
* Adds a CTE to an include option.
* Used to include a CTE in a query. CTEs are first added via the `cte` option in the query options.
* @param tableName - The name of the CTE table
* @param attributes - The attributes of the CTE table
* @param leftModel - The model to join the CTE to
* @param leftKey - The key on the left model to join the CTE to
* @returns The include options for the CTE
*
* @example
* ```typescript
* const cte = [
* {
* query: 'SELECT "OrderId" as "id" FROM "Trasactions" t WHERE ...',
* as: 'TransactionsCTE',
* },
* ];
* const include = [
* includeCte('TransactionsCTE', {
* id: {
* type: DataTypes.INTEGER,
* },
* }, models.Order, 'id'),
* ];
*
* models.Order.findAll({
* cte,
* include,
* });
* ```
*
* ```sql
*
* The query will be:
* WITH "TransactionsCTE" AS (SELECT "OrderId" as "id" FROM "Trasactions" t WHERE ...)
* SELECT * FROM "Orders"
* INNER JOIN "TransactionsCTE" ON "Orders"."id" = "TransactionsCTE"."id"
* ```
*/
export function includeCte(
tableName: string,
attributes: ModelAttributes<Model, unknown>,
leftModel: ModelStatic<Model>,
leftKey: string,
options: Partial<IncludeOptions> = {},
): IncludeOptions {
return {
as: tableName,
association: {
source: leftModel,
identifierField: leftKey,
} as unknown as Association<Model, Model>,
_pseudo: true,
model: {
rawAttributes: attributes,
getTableName: () => tableName,
tableAttributes: Object.keys(attributes),
_injectDependentVirtualAttributes: () => {
return [];
},
primaryKeyAttribute: Object.entries(attributes).find(([, value]) => value['primaryKey'])?.[0] ?? 'id',
_virtualAttributes: new Set(),
options: {
paranoid: false,
},
_expandAttributes: options => {
options.attributes = [];
},
} as unknown as ModelStatic<Model>,
Copy link
Member

Choose a reason for hiding this comment

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

Seems like a clever approach to get the CTE functionality in Sequelize, at the same time it relies on some undocumented internal APIs (_pseudo, _injectDependentVirtualAttributes, etc.) which could shift between Sequelize versions.

I don't necessarily see it as a blocker but a bit out of my depth here also. I think at least we can add a comment about what version this was tested in, and maybe add a test or two for this path using the charge date filter on the orders collection.

If we're adding kysely which has CTE support, it seems like a good idea to rewrite the OrdersCollectionQuery at some point, to eventually not have to rely on the sequelize internals.

...options,
} as unknown as IncludeOptions;
}
4 changes: 4 additions & 0 deletions server/lib/sequelize.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import Sequelize from 'sequelize';
import { getDBConf } from '../lib/db';
import logger from '../lib/logger';

import { sequelizeCte } from './sequelize-cte';

// this is needed to prevent sequelize from converting integers to strings, when model definition isn't clear
// like in case of the key totalOrders and raw query (like User.getTopBackers())
pg.defaults.parseInt8 = true;
Expand Down Expand Up @@ -62,6 +64,8 @@ const sequelize = new Sequelize(dbConfig.database, dbConfig.username, dbConfig.p
...config.database.options,
});

sequelizeCte(sequelize);

export { Op, DataTypes, Model, QueryTypes, Sequelize, Transaction } from 'sequelize';

export default sequelize;
Loading