Skip to content

Commit 656cb1a

Browse files
author
Benjamin Piouffle
committed
feat: setting to disable taxable tiers
1 parent 5c02910 commit 656cb1a

File tree

10 files changed

+228
-17
lines changed

10 files changed

+228
-17
lines changed

codecov.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ coverage:
88
ignore:
99
- reports
1010
- migrations
11+
- eslint-rules
12+
- config
1113

1214
comment:
1315
behavior: once # update, if exists. Otherwise post new. Skip if deleted.

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
"@node-oauth/oauth2-server": "5.2.1",
4646
"@octokit/auth-oauth-app": "9.0.3",
4747
"@octokit/rest": "22.0.1",
48-
"@opencollective/taxes": "5.3.0",
48+
"@opencollective/taxes": "5.4.0",
4949
"@opensearch-project/opensearch": "^3.4.0",
5050
"@opentelemetry/context-async-hooks": "2.3.0",
5151
"@opentelemetry/instrumentation": "0.209.0",

server/graphql/v1/CollectiveInterface.js

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import queries from '../../lib/queries';
2828
import { canSeeLegalName } from '../../lib/user-permissions';
2929
import models, { Op } from '../../models';
3030
import { PayoutMethodTypes } from '../../models/PayoutMethod';
31+
import Tier, { AllTierTypes } from '../../models/Tier';
3132
import { hostResolver } from '../common/collective';
3233
import { GraphQLCollectiveFeatures } from '../common/CollectiveFeatures';
3334
import { getContextPermission, PERMISSION_TYPE } from '../common/context-permissions';
@@ -511,6 +512,12 @@ export const CollectiveInterfaceType = new GraphQLInterfaceType({
511512
id: { type: GraphQLInt },
512513
slug: { type: GraphQLString },
513514
slugs: { type: new GraphQLList(GraphQLString) },
515+
onlyValid: {
516+
type: GraphQLBoolean,
517+
defaultValue: true,
518+
description:
519+
'When true (default), exclude tiers with types that are no longer supported. Use false for edit pages to show all tiers.',
520+
},
514521
},
515522
},
516523
orders: {
@@ -1333,8 +1340,14 @@ const CollectiveFields = () => {
13331340
id: { type: GraphQLInt },
13341341
slug: { type: GraphQLString },
13351342
slugs: { type: new GraphQLList(GraphQLString) },
1343+
onlyValid: {
1344+
type: GraphQLBoolean,
1345+
defaultValue: true,
1346+
description:
1347+
'When true (default), exclude tiers with types that are no longer supported. Use false for edit pages to show all tiers.',
1348+
},
13361349
},
1337-
resolve(collective, args) {
1350+
async resolve(collective, args, req) {
13381351
const where = {};
13391352

13401353
if (args.id) {
@@ -1345,6 +1358,18 @@ const CollectiveFields = () => {
13451358
where.slug = { [Op.in]: args.slugs };
13461359
}
13471360

1361+
if (args.onlyValid !== false) {
1362+
const host = await req.loaders.Collective.host.load(collective);
1363+
if (Tier.hostHasDisabledTaxableTiers(host)) {
1364+
const validTypes = AllTierTypes.filter(
1365+
type => !models.Tier.isForbiddenTaxableTierType(collective, host, type),
1366+
);
1367+
if (validTypes.length !== AllTierTypes.length) {
1368+
where.type = validTypes;
1369+
}
1370+
}
1371+
}
1372+
13481373
return collective.getTiers({
13491374
where,
13501375
order: [['amount', 'ASC']],

server/graphql/v2/interface/AccountWithContributions.ts

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,17 @@ import {
1111
} from 'graphql';
1212
import { GraphQLDateTime } from 'graphql-scalars';
1313
import { isNil, omit } from 'lodash';
14-
import { OrderItem, QueryTypes } from 'sequelize';
14+
import { OrderItem, QueryTypes, WhereOptions } from 'sequelize';
1515

1616
import PlatformConstants from '../../../constants/platform';
1717
import { filterContributors } from '../../../lib/contributors';
1818
import models, { Collective, sequelize } from '../../../models';
19+
import Tier, { AllTierTypes, TierType } from '../../../models/Tier';
1920
import { checkReceiveFinancialContributions } from '../../common/features';
2021
import { GraphQLAccountCollection } from '../collection/AccountCollection';
2122
import { GraphQLContributorCollection } from '../collection/ContributorCollection';
2223
import { GraphQLTierCollection } from '../collection/TierCollection';
23-
import { GraphQLAccountType, GraphQLMemberRole } from '../enum';
24+
import { GraphQLAccountType, GraphQLMemberRole, GraphQLTierType } from '../enum';
2425

2526
import { CollectionArgs } from './Collection';
2627

@@ -58,22 +59,56 @@ export const AccountWithContributionsFields = {
5859
description: 'The number of results to fetch',
5960
defaultValue: 100,
6061
},
62+
onlyValid: {
63+
type: GraphQLBoolean,
64+
description:
65+
'When true (default), exclude tiers with types that are no longer supported (e.g. taxable tiers when disabled by host). Use false for edit pages to show all tiers.',
66+
defaultValue: true,
67+
},
6168
},
62-
async resolve(account: Collective, args: Record<string, unknown>): Promise<Record<string, unknown>> {
69+
async resolve(
70+
account: Collective,
71+
args: Record<string, unknown>,
72+
req: express.Request,
73+
): Promise<Record<string, unknown>> {
6374
if (!account.hasBudget()) {
6475
return { nodes: [], totalCount: 0 };
6576
}
6677

67-
const query = {
68-
where: { CollectiveId: account.id },
78+
const where: WhereOptions<Tier> = { CollectiveId: account.id };
79+
if (args.onlyValid !== false) {
80+
const host = await req.loaders.Collective.host.load(account);
81+
if (Tier.hostHasDisabledTaxableTiers(host)) {
82+
const validTypes = AllTierTypes.filter(type => !models.Tier.isForbiddenTaxableTierType(account, host, type));
83+
if (validTypes.length !== AllTierTypes.length) {
84+
where.type = validTypes;
85+
}
86+
}
87+
}
88+
89+
const result = await models.Tier.findAndCountAll({
90+
where,
6991
order: [['amount', 'ASC']] as OrderItem[],
7092
limit: <number>args.limit,
7193
offset: <number>args.offset,
72-
};
73-
const result = await models.Tier.findAndCountAll(query);
94+
});
95+
7496
return { nodes: result.rows, totalCount: result.count, limit: args.limit, offset: args.offset };
7597
},
7698
},
99+
supportedTierTypes: {
100+
type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(GraphQLTierType))),
101+
description:
102+
'Tier types that can be created for this account. Uses host tax settings and account overrides via Tier.isForbiddenTaxableTierType.',
103+
async resolve(account: Collective, _, req: express.Request): Promise<readonly TierType[]> {
104+
const host = await req.loaders.Collective.host.load(account);
105+
if (Tier.hostHasDisabledTaxableTiers(host)) {
106+
return AllTierTypes.filter(tierType => !models.Tier.isForbiddenTaxableTierType(account, host, tierType));
107+
} else {
108+
return AllTierTypes;
109+
}
110+
},
111+
},
77112
contributors: {
78113
type: new GraphQLNonNull(GraphQLContributorCollection),
79114
description: 'All the persons and entities that contribute to this account',

server/graphql/v2/mutation/TierMutations.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { EntityShortIdPrefix, isEntityPublicId } from '../../../lib/permalink/en
77
import twoFactorAuthLib from '../../../lib/two-factor-authentication';
88
import models, { Tier as TierModel } from '../../../models';
99
import { checkRemoteUserCanUseAccount } from '../../common/scope-check';
10-
import { NotFound, Unauthorized } from '../../errors';
10+
import { NotFound, Unauthorized, ValidationFailed } from '../../errors';
1111
import { getIntervalFromTierFrequency } from '../enum/TierFrequency';
1212
import { idDecode, IDENTIFIER_TYPES } from '../identifiers';
1313
import { fetchAccountWithReference, GraphQLAccountReferenceInput } from '../input/AccountReferenceInput';
@@ -102,6 +102,14 @@ const tierMutations = {
102102
// Check 2FA
103103
await twoFactorAuthLib.enforceForAccount(req, collective, { onlyAskOnLogin: true });
104104

105+
// Validate taxable tier restriction when changing type
106+
const host = await req.loaders.Collective.host.load(collective);
107+
if (args.tier.type && models.Tier.isForbiddenTaxableTierType(collective, host, args.tier.type)) {
108+
throw new ValidationFailed(
109+
'This tier type is not allowed by your fiscal host, because it is subject to taxes in your country. Reach out to them for more information.',
110+
);
111+
}
112+
105113
// Update tier
106114
const updatedTier = await tier.update(transformTierInputToAttributes(args.tier));
107115

@@ -135,6 +143,14 @@ const tierMutations = {
135143
// Check 2FA
136144
await twoFactorAuthLib.enforceForAccount(req, account, { onlyAskOnLogin: true });
137145

146+
// Validate taxable tier restriction
147+
const host = await req.loaders.Collective.host.load(account);
148+
if (models.Tier.isForbiddenTaxableTierType(account, host, args.tier.type)) {
149+
throw new ValidationFailed(
150+
'This tier type is not allowed by your fiscal host, because it is subject to taxes in your country. Reach out to them for more information.',
151+
);
152+
}
153+
138154
// Create tier
139155
const tier = await TierModel.create({
140156
...transformTierInputToAttributes(args.tier),

server/lib/collectivelib.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ export const COLLECTIVE_SETTINGS_KEYS_LIST = [
177177
'showSetupGuide',
178178
'showInitialOverviewSubscriptionCard',
179179
'kyc',
180+
'disableTaxableTiers',
180181
];
181182

182183
/**
@@ -198,6 +199,10 @@ export function filterCollectiveSettings(settings: Record<string, unknown> | nul
198199
preparedSettings.GST = pick(preparedSettings.GST, ['number', 'disabled']);
199200
}
200201

202+
if (preparedSettings.disableTaxableTiers !== undefined) {
203+
preparedSettings.disableTaxableTiers = Boolean(preparedSettings.disableTaxableTiers);
204+
}
205+
201206
// Generate warnings for invalid settings
202207
Object.keys(settings).forEach(key => {
203208
if (!COLLECTIVE_SETTINGS_KEYS_LIST.includes(key)) {

server/models/Collective.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ type Settings = {
192192
applyMessage?: string;
193193
tos?: string;
194194
expenseTypes?: Partial<Record<ExpenseType, boolean>>;
195+
disableTaxableTiers?: boolean;
195196
} & TaxSettings;
196197

197198
type Data = Partial<{

server/models/Tier.ts

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1+
import {
2+
accountHasGST,
3+
isMemberOfTheEuropeanUnion,
4+
isTierTypeSubjectToGST,
5+
isTierTypeSubjectToVAT,
6+
} from '@opencollective/taxes';
17
import debugLib from 'debug';
28
import slugify from 'limax';
3-
import { defaults, isNil, min, uniq } from 'lodash';
9+
import { defaults, get, isNil, min, uniq } from 'lodash';
410
import pMap from 'p-map';
511
import { CreationOptional, InferAttributes, InferCreationAttributes, NonAttribute } from 'sequelize';
612
import Temporal from 'sequelize-temporal';
@@ -30,7 +36,8 @@ const longDescriptionSanitizerOpts = buildSanitizerOptions({
3036
videoIframes: true,
3137
});
3238

33-
export type TierType = 'TIER' | 'MEMBERSHIP' | 'DONATION' | 'TICKET' | 'PRODUCT' | 'SERVICE';
39+
export const AllTierTypes = ['TIER', 'MEMBERSHIP', 'DONATION', 'TICKET', 'PRODUCT', 'SERVICE'] as const;
40+
export type TierType = (typeof AllTierTypes)[number];
3441

3542
class Tier extends ModelWithPublicId<EntityShortIdPrefix.Tier, InferAttributes<Tier>, InferCreationAttributes<Tier>> {
3643
public static readonly nanoIdPrefix = EntityShortIdPrefix.Tier;
@@ -181,6 +188,32 @@ class Tier extends ModelWithPublicId<EntityShortIdPrefix.Tier, InferAttributes<T
181188
});
182189
};
183190

191+
static hostHasDisabledTaxableTiers = (host: Collective): boolean => {
192+
return Boolean(host?.settings?.disableTaxableTiers);
193+
};
194+
195+
static isForbiddenTaxableTierType = (account: Collective, host: Collective, tierType: TierType): boolean => {
196+
// Check if taxable tiers are disabled at the host level
197+
if (!Tier.hostHasDisabledTaxableTiers(host)) {
198+
return false;
199+
}
200+
201+
// Check if taxable tiers are allowed at the account level (override)
202+
const allowTaxableTiers = get(account, 'data.allowTaxableTiers');
203+
if (allowTaxableTiers === true || (Array.isArray(allowTaxableTiers) && allowTaxableTiers.includes(tierType))) {
204+
return false;
205+
}
206+
207+
// Check if the tier type is subject to VAT or GST
208+
if (isMemberOfTheEuropeanUnion(host.countryISO)) {
209+
return isTierTypeSubjectToVAT(tierType);
210+
} else if (host.countryISO === 'NZ' || accountHasGST(account)) {
211+
return isTierTypeSubjectToGST(tierType);
212+
}
213+
214+
return false;
215+
};
216+
184217
/**
185218
* Getters
186219
*/
@@ -289,7 +322,12 @@ Tier.init(
289322
type: DataTypes.STRING, // TIER, TICKET, DONATION, SERVICE, PRODUCT, MEMBERSHIP
290323
defaultValue: 'TIER',
291324
allowNull: false,
292-
// TODO validate value
325+
validate: {
326+
isIn: {
327+
args: [AllTierTypes],
328+
msg: 'Invalid tier type',
329+
},
330+
},
293331
},
294332

295333
description: {

0 commit comments

Comments
 (0)