Skip to content
Open
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
143 changes: 143 additions & 0 deletions scripts/accounting/generate-host-report.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import '../../server/env';

import assert from 'assert';

import { Command } from 'commander';
import { toNumber } from 'lodash';
import moment from 'moment';
import { QueryTypes } from 'sequelize';

import { getHostReportNodesFromQueryResult } from '../../server/lib/transaction-reports';
import models, { sequelize } from '../../server/models';

const program = new Command();

const query = dateField => {
dateField = dateField === 'effective' ? `COALESCE(t."clearedAt", t."createdAt")` : `t."createdAt"`;
return `
Comment on lines +15 to +17

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject invalid dateField instead of silently defaulting.

Any unexpected dateField currently falls back to createdAt, which can silently produce the wrong report basis.

Proposed fix
   .action(async (period, hostId, from, to, dateField = 'createdAt') => {
     console.log('Generating report for host', hostId, 'from', from, 'to', to, 'dateField', dateField);
     const host = await models.Collective.findByPk(toNumber(hostId));
     assert(['month', 'quarter', 'year'].includes(period), 'Invalid period, must be month, quarter or year');
+    assert(['createdAt', 'effective'].includes(dateField), 'Invalid dateField, must be createdAt or effective');
     assert(host, 'Host not found');

Also applies to: 102-106

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/accounting/generate-host-report.ts` around lines 15 - 17, The current
query function silently treats any non-'effective' dateField as 'createdAt';
change it to validate dateField explicitly and throw a clear Error for invalid
values. In the query(dateField) function (and the similar block around the other
occurrence), accept only the allowed tokens (e.g., 'effective' and 'created' or
the exact two permitted strings used across the file), and if dateField is not
one of them, throw new Error('Invalid dateField: must be "effective" or
"created"') so callers cannot silently get the wrong date basis; then map the
valid tokens to the SQL expressions (use COALESCE(...) for 'effective',
t."createdAt" for 'created').

WITH "HostMonthlyTransactions" AS (SELECT DATE_TRUNC('month', ${dateField} AT TIME ZONE 'UTC') AS "date",
t."HostCollectiveId",
SUM(t."amountInHostCurrency") AS "amountInHostCurrency",
SUM(COALESCE(t."platformFeeInHostCurrency", 0)) AS "platformFeeInHostCurrency",
SUM(COALESCE(t."hostFeeInHostCurrency", 0)) AS "hostFeeInHostCurrency",
SUM(COALESCE(t."paymentProcessorFeeInHostCurrency", 0)) AS "paymentProcessorFeeInHostCurrency",
SUM(COALESCE(t."taxAmount" * COALESCE(t."hostCurrencyFxRate", 1), 0)) AS "taxAmountInHostCurrency",
COALESCE(
SUM(COALESCE(t."amountInHostCurrency", 0))
+ SUM(COALESCE(t."platformFeeInHostCurrency", 0))
+ SUM(COALESCE(t."hostFeeInHostCurrency", 0))
+ SUM(COALESCE(t."paymentProcessorFeeInHostCurrency", 0))
+
SUM(COALESCE(t."taxAmount" * COALESCE(t."hostCurrencyFxRate", 1), 0)),
0
) AS "netAmountInHostCurrency",
t."kind",
t."isRefund",
t."hostCurrency",
t."type",
CASE
WHEN t."CollectiveId" = t."HostCollectiveId" THEN TRUE
WHEN EXISTS (SELECT 1
FROM "Collectives" c
WHERE c."id" = t."CollectiveId"
AND c."ParentCollectiveId" = t."HostCollectiveId"
AND c."type" != 'VENDOR') THEN TRUE
ELSE FALSE
END AS "isHost",
e."type" AS "expenseType",
NOW() AS "refreshedAt"
FROM "Transactions" t
LEFT JOIN LATERAL (
SELECT e2."type" FROM "Expenses" e2 WHERE e2.id = t."ExpenseId"
) AS e ON t."ExpenseId" IS NOT NULL
Comment on lines +49 to +52

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion: The lateral join for expenses looks more complex than necessary.

Given the correlation WHERE e2.id = t."ExpenseId", the LATERAL isn’t necessary here, and ON t."ExpenseId" IS NOT NULL is unusual. You can keep the same semantics with a simpler left join:

LEFT JOIN "Expenses" e ON e.id = t."ExpenseId"

This preserves rows with a null ExpenseId while avoiding the lateral subquery and custom ON clause.

Suggested change
FROM "Transactions" t
LEFT JOIN LATERAL (
SELECT e2."type" FROM "Expenses" e2 WHERE e2.id = t."ExpenseId"
) AS e ON t."ExpenseId" IS NOT NULL
FROM "Transactions" t
LEFT JOIN "Expenses" e ON e.id = t."ExpenseId"

WHERE t."deletedAt" IS NULL
AND t."HostCollectiveId" = :hostCollectiveId
GROUP BY DATE_TRUNC('month', ${dateField} AT TIME ZONE 'UTC'), t."HostCollectiveId", t."kind", t."hostCurrency",
t."isRefund", t."type", "isHost", "expenseType"
ORDER BY "date", t."HostCollectiveId", t."kind"),
CombinedData AS (SELECT DATE_TRUNC(:timeUnit, "date" AT TIME ZONE 'UTC') AS "date",
"HostCollectiveId",
"amountInHostCurrency",
"platformFeeInHostCurrency",
"hostFeeInHostCurrency",
"paymentProcessorFeeInHostCurrency",
"taxAmountInHostCurrency",
"netAmountInHostCurrency",
"kind",
"isRefund",
"hostCurrency",
"type",
"isHost",
"expenseType"
FROM "HostMonthlyTransactions"
WHERE "HostCollectiveId" = :hostCollectiveId
AND date <= :dateTo)
Comment on lines +73 to +74

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Apply the from bound in SQL to keep the query scoped.

<from> is accepted by the CLI but never applied in the SQL filter. That makes the query unbounded on the lower side and can scan far more history than needed.

Proposed fix
       FROM "HostMonthlyTransactions"
       WHERE "HostCollectiveId" = :hostCollectiveId
+        AND date >= :dateFrom
         AND date <= :dateTo)
     const queryResult = await sequelize.query(query(dateField), {
       replacements: {
         hostCollectiveId: toNumber(hostId),
         timeUnit: period,
+        dateFrom: moment(from).utc().toISOString(),
         dateTo: moment(to).utc().toISOString(),
       },

Also applies to: 109-113

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/accounting/generate-host-report.ts` around lines 73 - 74, The SQL
WHERE clause is missing the lower bound so the CLI's "from" parameter isn't
applied; update the queries that filter by "HostCollectiveId" and "date <=
:dateTo" to also include "AND date >= :from" (or "AND date >= :fromDate" if that
is the CLI flag name used) and ensure the query parameter binding for :from
matches the CLI param; apply this change to the two affected queries (the block
using hostCollectiveId/dateTo around the lines shown and the second occurrence
at lines 109-113).

SELECT "date",
"isRefund",
"isHost",
"kind",
"type",
"expenseType",
"hostCurrency",
SUM("platformFeeInHostCurrency") AS "platformFeeInHostCurrency",
SUM("hostFeeInHostCurrency") AS "hostFeeInHostCurrency",
SUM("paymentProcessorFeeInHostCurrency") AS "paymentProcessorFeeInHostCurrency",
SUM("taxAmountInHostCurrency") AS "taxAmountInHostCurrency",
SUM("netAmountInHostCurrency") AS "netAmountInHostCurrency",
SUM("amountInHostCurrency") AS "amountInHostCurrency"
FROM CombinedData
GROUP BY "date",
"isRefund",
"isHost",
"kind",
"type",
"expenseType",
"hostCurrency"
ORDER BY "date";
`;
};

program
.command('generate <period> <hostId> <from> <to> [dateField] [env]')
.action(async (period, hostId, from, to, dateField = 'createdAt') => {
console.log('Generating report for host', hostId, 'from', from, 'to', to, 'dateField', dateField);
const host = await models.Collective.findByPk(toNumber(hostId));
assert(['month', 'quarter', 'year'].includes(period), 'Invalid period, must be month, quarter or year');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion: Period validation is case-sensitive and may cause avoidable failures.

Because the allowed period values are lowercase, inputs like Month or YEAR will fail even though they’re reasonable for a CLI. If you want more user-friendly behavior, normalize period (e.g., period = period.toLowerCase()) before the assert and reuse that normalized value for validation and timeUnit.

Suggested implementation:

  .command('generate <period> <hostId> <from> <to> [dateField] [env]')
  .action(async (period, hostId, from, to, dateField = 'createdAt') => {
    const normalizedPeriod = period.toLowerCase();

    console.log('Generating report for host', hostId, 'from', from, 'to', to, 'dateField', dateField);
    const host = await models.Collective.findByPk(toNumber(hostId));
    const host = await models.Collective.findByPk(toNumber(hostId));
    assert(['month', 'quarter', 'year'].includes(normalizedPeriod), 'Invalid period, must be month, quarter or year');

Anywhere later in this file where period is used as the time unit (e.g. const timeUnit = period; or in date calculations), replace that usage with normalizedPeriod so that all logic relies on the normalized value while still accepting case-insensitive CLI input.

assert(host, 'Host not found');

const queryResult = await sequelize.query(query(dateField), {
replacements: {
hostCollectiveId: toNumber(hostId),
timeUnit: period,
dateTo: moment(to).utc().toISOString(),
},
type: QueryTypes.SELECT,
raw: true,
});

const nodes = await getHostReportNodesFromQueryResult({
queryResult,
dateFrom: from,
dateTo: to,
timeUnit: period,
currency: host.currency,
});

console.dir(nodes, { depth: null });
sequelize.close();
});

program.addHelpText(
'after',
`
This script generates a report of the transactions for a given host and period. It can be used to generate reports for the dashboard or for other purposes.

Usage:
node scripts/accounting/generate-host-report.js generate <period> <hostId> <from> <to> [createdAt|effective] [env]

Example:
node scripts/accounting/generate-host-report.js generate month 11004 2022-01-01 2022-12-31 effective
`,
);

program.parse();
Loading