diff --git a/migrations/20260324160000-add-reference-template-to-manual-payment-providers.ts b/migrations/20260324160000-add-reference-template-to-manual-payment-providers.ts new file mode 100644 index 00000000000..297ed9771f7 --- /dev/null +++ b/migrations/20260324160000-add-reference-template-to-manual-payment-providers.ts @@ -0,0 +1,17 @@ +'use strict'; + +import type { QueryInterface } from 'sequelize'; +import { DataTypes } from 'sequelize'; + +module.exports = { + async up(queryInterface: QueryInterface) { + await queryInterface.addColumn('ManualPaymentProviders', 'referenceTemplate', { + type: DataTypes.TEXT, + allowNull: true, + }); + }, + + async down(queryInterface: QueryInterface) { + await queryInterface.removeColumn('ManualPaymentProviders', 'referenceTemplate'); + }, +}; diff --git a/server/graphql/schemaV2.graphql b/server/graphql/schemaV2.graphql index 93b5b26371e..ad8caddf23d 100644 --- a/server/graphql/schemaV2.graphql +++ b/server/graphql/schemaV2.graphql @@ -8239,6 +8239,11 @@ type ManualPaymentProvider { """ accountDetails: JSON + """ + Plain-text template used to build the payment reference shown as {reference} in instructions (default {contributionId}) + """ + referenceTemplate: String + """ Whether this provider has been archived """ @@ -26829,6 +26834,11 @@ input ManualPaymentProviderCreateInput { Bank account details for BANK_TRANSFER type providers """ accountDetails: JSON + + """ + Plain-text template for the payment reference (e.g. {contributionId}). Variables: amount, collective, OrderId, contributionId, account. Do not use {reference} here. + """ + referenceTemplate: String } input ManualPaymentProviderUpdateInput { @@ -26851,6 +26861,11 @@ input ManualPaymentProviderUpdateInput { Bank account details for BANK_TRANSFER type providers """ accountDetails: JSON + + """ + Plain-text template for the payment reference (e.g. {contributionId}). Variables: amount, collective, OrderId, contributionId, account. Do not use {reference} here. + """ + referenceTemplate: String } type FollowAccountResult { diff --git a/server/graphql/v2/input/ManualPaymentProviderInput.ts b/server/graphql/v2/input/ManualPaymentProviderInput.ts index 9f593990d85..70087061978 100644 --- a/server/graphql/v2/input/ManualPaymentProviderInput.ts +++ b/server/graphql/v2/input/ManualPaymentProviderInput.ts @@ -49,6 +49,11 @@ export const GraphQLManualPaymentProviderCreateInput = new GraphQLInputObjectTyp type: GraphQLJSON, description: 'Bank account details for BANK_TRANSFER type providers', }, + referenceTemplate: { + type: GraphQLString, + description: + 'Plain-text template for the payment reference (e.g. {contributionId}). Variables: amount, collective, OrderId, contributionId, account. Do not use {reference} here.', + }, }), }); @@ -74,6 +79,11 @@ export const GraphQLManualPaymentProviderUpdateInput = new GraphQLInputObjectTyp type: GraphQLJSON, description: 'Bank account details for BANK_TRANSFER type providers', }, + referenceTemplate: { + type: GraphQLString, + description: + 'Plain-text template for the payment reference (e.g. {contributionId}). Variables: amount, collective, OrderId, contributionId, account. Do not use {reference} here.', + }, }), }); diff --git a/server/graphql/v2/mutation/ManualPaymentProviderMutations.ts b/server/graphql/v2/mutation/ManualPaymentProviderMutations.ts index 9acdd91f5cf..4b1a16189a4 100644 --- a/server/graphql/v2/mutation/ManualPaymentProviderMutations.ts +++ b/server/graphql/v2/mutation/ManualPaymentProviderMutations.ts @@ -64,6 +64,7 @@ const manualPaymentProviderMutations = { instructions: args.manualPaymentProvider.instructions, icon: args.manualPaymentProvider.icon, data: args.manualPaymentProvider.accountDetails, + referenceTemplate: args.manualPaymentProvider.referenceTemplate, order: (maxOrder || 0) + 1, }, { transaction }, @@ -125,6 +126,7 @@ const manualPaymentProviderMutations = { instructions: args.input.instructions, icon: args.input.icon, data: args.input.accountDetails, + referenceTemplate: args.input.referenceTemplate, }, isUndefined, ), diff --git a/server/graphql/v2/object/ManualPaymentProvider.ts b/server/graphql/v2/object/ManualPaymentProvider.ts index 33e4374d186..d0ead8902dd 100644 --- a/server/graphql/v2/object/ManualPaymentProvider.ts +++ b/server/graphql/v2/object/ManualPaymentProvider.ts @@ -46,6 +46,12 @@ export const GraphQLManualPaymentProvider = new GraphQLObjectType({ description: 'Bank account details for BANK_TRANSFER type providers', resolve: provider => (provider.type === ManualPaymentProviderTypes.BANK_TRANSFER ? provider.data : null), }, + referenceTemplate: { + type: GraphQLString, + description: + 'Plain-text template used to build the payment reference shown as {reference} in instructions (default {contributionId})', + resolve: provider => provider.referenceTemplate ?? '{contributionId}', + }, isArchived: { type: new GraphQLNonNull(GraphQLBoolean), description: 'Whether this provider has been archived', diff --git a/server/models/ManualPaymentProvider.ts b/server/models/ManualPaymentProvider.ts index 48c42ba0c74..5af102b0ac5 100644 --- a/server/models/ManualPaymentProvider.ts +++ b/server/models/ManualPaymentProvider.ts @@ -8,7 +8,7 @@ import { } from 'sequelize'; import { EntityShortIdPrefix } from '../lib/permalink/entity-map'; -import { optsSanitizedSimplifiedWithImages, sanitizeHTML } from '../lib/sanitize-html'; +import { optsSanitizedSimplifiedWithImages, sanitizeHTML, stripHTML } from '../lib/sanitize-html'; import sequelize from '../lib/sequelize'; import { RecipientAccount } from '../types/transferwise'; @@ -30,6 +30,14 @@ export enum ManualPaymentProviderTypes { export const sanitizeManualPaymentProviderInstructions = (instructions: string): string => sanitizeHTML(instructions, optsSanitizedSimplifiedWithImages); +/** + * Plain-text template for payment reference (no HTML); strips tags for safety. + */ +export const sanitizeManualPaymentProviderReferenceTemplate = (value: string): string | null => { + const trimmed = stripHTML(value ?? '').trim(); + return trimmed === '' ? null : trimmed; +}; + /** * Sequelize model to represent a ManualPaymentProvider, linked to the `ManualPaymentProviders` table. * These are custom payment methods that hosts can define for contributors to use when making @@ -49,6 +57,7 @@ class ManualPaymentProvider extends ModelWithPublicId< declare public instructions: string; declare public icon: CreationOptional; declare public data: CreationOptional>; + declare public referenceTemplate: CreationOptional; declare public order: CreationOptional; declare public archivedAt: CreationOptional; declare public createdAt: CreationOptional; @@ -88,6 +97,7 @@ class ManualPaymentProvider extends ModelWithPublicId< instructions: this.instructions, icon: this.icon, data: this.data, + referenceTemplate: this.referenceTemplate, order: this.order, archivedAt: this.archivedAt, }; @@ -148,6 +158,17 @@ ManualPaymentProvider.init( type: DataTypes.JSONB, allowNull: true, }, + referenceTemplate: { + type: DataTypes.TEXT, + allowNull: true, + set(value: string | null | undefined) { + if (value === null || value === undefined) { + this.setDataValue('referenceTemplate', null); + } else { + this.setDataValue('referenceTemplate', sanitizeManualPaymentProviderReferenceTemplate(value)); + } + }, + }, order: { type: DataTypes.INTEGER, allowNull: false, diff --git a/test/test-helpers/fake-data.ts b/test/test-helpers/fake-data.ts index 4b8adabdc30..c7524d5b01b 100644 --- a/test/test-helpers/fake-data.ts +++ b/test/test-helpers/fake-data.ts @@ -1433,6 +1433,7 @@ export const fakeManualPaymentProvider = async ( instructions: string; icon: string; data: Record; + referenceTemplate: string | null; order: number; archivedAt: Date; }> = {},