Skip to content

Contribution categorization#11456

Merged
hdiniz merged 23 commits intomainfrom
contribution-categorization
Apr 10, 2026
Merged

Contribution categorization#11456
hdiniz merged 23 commits intomainfrom
contribution-categorization

Conversation

@hdiniz
Copy link
Copy Markdown
Contributor

@hdiniz hdiniz commented Feb 24, 2026

No description provided.

@hdiniz hdiniz self-assigned this Feb 24, 2026
@hdiniz hdiniz marked this pull request as ready for review February 25, 2026 17:18
@hdiniz hdiniz requested a review from Betree February 25, 2026 17:18
Comment thread migrations/20260204130046-add-contribution-accounting-category-rule.ts Outdated
Comment thread migrations/20260204130046-add-contribution-accounting-category-rule.ts Outdated
Comment thread server/constants/contribution-roles.ts
Comment thread server/graphql/v2/input/ContributionAccountingCategoryRuleInput.ts Outdated
Comment on lines +209 to +212
await ContributionAccountingCategoryRule.bulkCreate(rules, {
transaction,
updateOnDuplicate: ['order', 'enabled', 'name', 'predicates', 'AccountingCategoryId', 'updatedAt'],
});
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🚫 This code would let users edit rules they are not allowed to delete. When building the rules list above, we simply idDecode the ID, without loading the entry to make sure it belongs to the account.

We use richDiffDBEntries in other places to prevent this issue.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch, I've added a check and accompanying test case to validate this

Comment thread server/lib/accounting/categorization/contribution-rules.ts Outdated
Comment thread server/lib/accounting/categorization/types.ts
Comment thread server/lib/payments.ts
Comment on lines +936 to +937
await applyContributionAccountingCategoryRules(order);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Not a requested change, but out of curiosity - have you considered adding that as a afterCreate hook on Orders, to have it in a single place and make sure we don't forget any path?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

There are only a couple of cases where we want to run this at the moment, I had a preference for doing it explicitly in such places to also take into consideration the roles and the semantics of the mutations where the host can perform remove/ignore a category on updating an order

Comment thread server/models/AccountingCategoryRule.ts
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Given that there is an order.update above, another order.update in applyContributionAccountingCategoryRules, both without transaction and without reloading the order, it sounds like there is a risk of concurrency issue.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I see what you mean, this is possible, I guess the particularities of the updates that happen mitigate this as there are other awaits inside the side effects.

I went ahead and refactored this section to execute the effects sequentially, I believe there should not be a need to reload the order explicitly as sequelize will update the internal order representation after the update.

Comment thread server/lib/accounting/categorization/types.ts Outdated
Comment thread server/lib/accounting/categorization/types.ts
Comment thread server/lib/accounting/categorization/types.ts
Comment thread server/graphql/v2/mutation/OrderMutations.js Outdated
Comment on lines +79 to +81
...order.data,
valuesByRole: {
...(order.data.valuesByRole || {}),

This comment was marked as outdated.

Comment on lines +178 to +188
idDecode(rule.id, IDENTIFIER_TYPES.ACCOUNTING_CATEGORY_RULE),
);

if (existingRule && existingRule.type !== 'CONTRIBUTION') {
throw new ValidationFailed('This mutation can only update contribution accounting category rules');
}

if (existingRule && existingRule.CollectiveId !== account.id) {
throw new Forbidden('You are not authorized to update this rule');
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: The updateContributionAccountingCategoryRules mutation allows creating a new rule with a user-specified primary key if the provided id does not already exist.
Severity: MEDIUM

Suggested Fix

Add validation to check if a rule exists when an id is provided. If the rule does not exist, throw an error to reject the request, similar to the validation logic in the editAccountingCategories mutation.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: server/graphql/v2/mutation/AccountingCategoriesMutations.ts#L176-L188

Potential issue: In the `updateContributionAccountingCategoryRules` mutation, if a rule
`id` is provided that does not exist in the database, validation is skipped. The code
then calls `bulkCreate` with the `updateOnDuplicate` option, which causes Sequelize to
insert a new rule using the user-specified, non-existent ID as its primary key. This
bypasses the intended upsert logic and allows users to create records with arbitrary
primary keys, which could potentially interfere with the database's auto-increment
sequence.

Copy link
Copy Markdown
Member

@Betree Betree left a comment

Choose a reason for hiding this comment

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

All blockers resolved on my end!

Comment on lines +359 to +363
const valuesByRole = {
...(order.data.valuesByRole || {}),
...(accountingCategory === null ? { hostAdmin: { accountingCategory: { code: UncategorizedValue } } } : {}),
...(accountingCategory?.id ? { hostAdmin: { accountingCategory: accountingCategory?.publicInfo } } : {}),
};

This comment was marked as outdated.

Comment on lines +248 to +251
await AccountingCategoryRule.bulkCreate(rules, {
transaction,
updateOnDuplicate: ['order', 'enabled', 'name', 'predicates', 'AccountingCategoryId', 'updatedAt'],
});

This comment was marked as outdated.

Comment on lines +83 to +85
resolve(rule) {
return rule.id;
},

This comment was marked as outdated.

lock: Transaction.LOCK.UPDATE,
});

const toDelete = existingRules.filter(rule => !rules.some(r => r.publicId === rule.publicId));

This comment was marked as outdated.

value: unknown,
order: Order,
) => {
return typeof value === 'string' && value.length > 0 && order.description.includes(value as string);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: The code calls order.description.includes() without checking if order.description is undefined, which will cause a TypeError.
Severity: MEDIUM

Suggested Fix

Add an optional chaining operator to safely handle cases where order.description is undefined. Change order.description.includes(value as string) to order.description?.includes(value as string).

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: server/lib/accounting/categorization/types.ts#L169

Potential issue: The `Order.description` field is optional and can be `undefined`.
However, when evaluating accounting category rules, the code at
`server/lib/accounting/categorization/types.ts:169` attempts to call
`order.description.includes(value)` without a null check. This will throw a `TypeError`
if an order without a description is processed. While a `try/catch` block prevents a
crash, the rule application will fail silently, preventing automatic categorization and
creating Sentry error noise.

return;
}

const rules = await AccountingCategoryRule.getRulesForCollective(collective.HostCollectiveId, 'CONTRIBUTION');
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: The code incorrectly uses collective.HostCollectiveId to fetch rules, which can be null even when a host object exists, causing silent failure.
Severity: MEDIUM

Suggested Fix

Use host.id instead of collective.HostCollectiveId when calling AccountingCategoryRule.getRulesForCollective. This ensures the correct host ID is used, as the host object is guaranteed to be available at that point.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: server/lib/accounting/categorization/contribution-rules.ts#L68

Potential issue: In `applyContributionAccountingCategoryRules`, the code fetches the
host for a collective but then uses `collective.HostCollectiveId` to retrieve the
accounting rules. The `getHostCollective()` method does not always populate
`collective.HostCollectiveId`, even when it successfully returns a `host` object. This
causes the subsequent rule lookup to query with a `null` ID, finding no rules and
causing the categorization to fail silently.

@hdiniz hdiniz merged commit 0e2d7d3 into main Apr 10, 2026
19 checks passed
@hdiniz hdiniz deleted the contribution-categorization branch April 10, 2026 21:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants