Skip to content
Draft
Show file tree
Hide file tree
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
1 change: 1 addition & 0 deletions components/contribution-flow/ContributionFlowSuccess.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,7 @@ class ContributionFlowSuccess extends React.Component<
<div>
<CustomPaymentMethodInstructions
instructions={manualPaymentProvider.instructions}
referenceTemplate={manualPaymentProvider.referenceTemplate ?? undefined}
values={{
amount: { valueInCents: totalAmount, currency },
collectiveSlug: get(data, 'order.toAccount.slug', ''),
Expand Down
1 change: 1 addition & 0 deletions components/contribution-flow/graphql/fragments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ export const orderSuccessFragment = gql`
instructions
icon
accountDetails
referenceTemplate
}
amount {
value
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ jest.mock('../../../manual-payment-provider/EditCustomPaymentMethodDialog', () =
{provider && <div>Editing: {provider.name}</div>}
<button
onClick={async () => {
await onSave({ name: 'Test', instructions: 'Test instructions', icon: 'venmo' }, provider || undefined);
await onSave(
{ name: 'Test', instructions: 'Test instructions', icon: 'venmo', referenceTemplate: '{contributionId}' },
provider || undefined,
);
}}
>
Save
Expand Down Expand Up @@ -80,6 +83,7 @@ const buildCreateProviderMock = () => ({
name: 'Test',
instructions: 'Test instructions',
icon: 'venmo',
referenceTemplate: '{contributionId}',
},
},
},
Expand All @@ -91,6 +95,7 @@ const buildCreateProviderMock = () => ({
name: 'Test',
instructions: 'Test instructions',
icon: 'venmo',
referenceTemplate: '{contributionId}',
accountDetails: null,
isArchived: false,
createdAt: new Date().toISOString(),
Expand All @@ -106,10 +111,10 @@ const buildUpdateProviderMock = (providerId: string) => ({
variables: {
manualPaymentProvider: { id: providerId },
input: {
type: 'OTHER',
name: 'Test',
instructions: 'Test instructions',
icon: 'venmo',
referenceTemplate: '{contributionId}',
},
},
},
Expand All @@ -121,6 +126,7 @@ const buildUpdateProviderMock = (providerId: string) => ({
name: 'Test',
instructions: 'Test instructions',
icon: 'venmo',
referenceTemplate: '{contributionId}',
accountDetails: null,
isArchived: false,
createdAt: new Date().toISOString(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { FormattedMessage, useIntl } from 'react-intl';

import { getAccountReferenceInput } from '@/lib/collective';
import { i18nGraphqlException } from '@/lib/errors';
import { type Account, type ManualPaymentProvider, ManualPaymentProviderType } from '@/lib/graphql/types/v2/graphql';
import { type Account, type ManualPaymentProvider } from '@/lib/graphql/types/v2/graphql';

import { useModal } from '@/components/ModalContext';

Expand Down Expand Up @@ -43,18 +43,21 @@ const CustomPaymentMethods = ({ account, manualPaymentProviders, canEdit, onRefe
const [customProviders, otherProviders] = partition(manualPaymentProviders, p => p.type === 'OTHER');

const handleSave = React.useCallback(
async (values: { name: string; instructions: string; icon?: string }, editingProvider?: ManualPaymentProvider) => {
async (
values: { name: string; instructions: string; icon?: string; referenceTemplate: string },
editingProvider?: ManualPaymentProvider,
) => {
try {
if (editingProvider) {
// Update existing
await updateProvider({
variables: {
manualPaymentProvider: { id: editingProvider.id },
input: {
type: ManualPaymentProviderType.OTHER,
name: values.name,
instructions: values.instructions,
icon: values.icon,
referenceTemplate: values.referenceTemplate,
},
},
});
Expand All @@ -68,6 +71,7 @@ const CustomPaymentMethods = ({ account, manualPaymentProviders, canEdit, onRefe
name: values.name,
instructions: values.instructions,
icon: values.icon,
referenceTemplate: values.referenceTemplate,
},
},
});
Expand Down
1 change: 1 addition & 0 deletions components/edit-collective/sections/receive-money/gql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const manualPaymentProviderFragment = gql`
instructions
icon
accountDetails
referenceTemplate
isArchived
createdAt
updatedAt
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,21 @@
* Load data-URL images so the QR SVG can be rasterized and decoded with jsQR.
* @jest-environment-options {"resources": "usable"}
*/
import '@testing-library/jest-dom';

Check failure on line 5 in components/manual-payment-provider/CustomPaymentMethodInstructions.test.tsx

View workflow job for this annotation

GitHub Actions / lint

Run autofix to sort these imports!

import React from 'react';
import { render, screen } from '@testing-library/react';
import jsQR from 'jsqr';

import { Currency } from '@/lib/graphql/types/v2/graphql';
import { createIntl, createIntlCache } from 'react-intl';

import { withRequiredProviders } from '../../test/providers';

import { CustomPaymentMethodInstructions } from './CustomPaymentMethodInstructions';
import {
CustomPaymentMethodInstructions,
resolvePaymentReferenceFromTemplate,
} from './CustomPaymentMethodInstructions';

/** Rasterizes the rendered QR SVG and decodes its payload with jsQR (validates pixels match the encoded string). */
async function decodeQrPayloadFromSvg(svg: SVGSVGElement): Promise<string> {
Expand Down Expand Up @@ -189,6 +194,20 @@
expect(screen.getByText(/Reference: 789/)).toBeInTheDocument();
});

it('resolves {reference} from referenceTemplate', () => {
const instructions = 'Reference: {reference}';
render(
withRequiredProviders(
<CustomPaymentMethodInstructions
instructions={instructions}
referenceTemplate="OC-{contributionId}"
values={defaultValues}
/>,
),
);
expect(screen.getByText(/Reference: OC-789/)).toBeInTheDocument();
});

it('handles list formatting with variables', () => {
const instructions = '<ul><li>Send {amount}</li><li>For {collective}</li></ul>';
render(
Expand Down Expand Up @@ -271,5 +290,52 @@
].join('\n');
expect(decoded).toBe(expectedEpcQrPayload);
});

it('uses resolved reference from referenceTemplate in EPC QR payload', async () => {
const eurValues = {
amount: { valueInCents: 2420, currency: Currency.EUR },
collectiveSlug: 'test-collective',
OrderId: 51431,
accountDetails: {
accountHolderName: 'A Valid Collective',
details: {
IBAN: 'BE69967102423878',
BIC: 'TRWIBEB1XXX',
},
},
};
const { container } = render(
withRequiredProviders(
<CustomPaymentMethodInstructions
instructions="Please pay {amount}."
referenceTemplate="REF-{contributionId}"
values={eurValues}
/>,
),
);
const qrSvg = container.querySelector('[data-cy="qr-code"]');
expect(qrSvg).toBeInTheDocument();
const decoded = await decodeQrPayloadFromSvg(qrSvg as SVGSVGElement);
const lines = decoded.split('\n');
// Remittance field is the 11th line (index 10) in the EPC payload.
expect(lines[10]).toBe('REF-51431');
});
});
});

describe('resolvePaymentReferenceFromTemplate', () => {
const intl = createIntl({ locale: 'en', messages: {} }, createIntlCache());
const values = {
amount: { valueInCents: 10000, currency: Currency.USD },
collectiveSlug: 'slug',
OrderId: 42,
};

it('defaults to {contributionId}', () => {
expect(resolvePaymentReferenceFromTemplate(undefined, values, intl)).toBe('42');
});

it('leaves unknown placeholders in place', () => {
expect(resolvePaymentReferenceFromTemplate('{unknown}', values, intl)).toBe('{unknown}');
});
});
106 changes: 77 additions & 29 deletions components/manual-payment-provider/CustomPaymentMethodInstructions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,27 @@ import { Currency } from '@/lib/graphql/types/v2/schema';
import { cn } from '@/lib/utils';

import type { TEMPLATE_VARIABLES } from './constants';
import { DEFAULT_REFERENCE_TEMPLATE } from './constants';
import { formatAccountDetails } from './utils';

const EPC_QR_WIKI = 'https://wikipedia.org/wiki/EPC_QR_code';

type InstructionValues = {
amount: Amount;
collectiveSlug: string;
OrderId: number;
accountDetails?: Record<string, unknown>;
};

type CustomPaymentMethodInstructionsProps = {
/** HTML instructions template with variables like {account}, {amount}, etc. */
instructions: string;
values: {
amount: Amount;
collectiveSlug: string;
OrderId: number;
accountDetails?: Record<string, unknown>;
};
values: InstructionValues;
/**
* Plain-text template for the payment reference (`{reference}` in instructions).
* Defaults to `{contributionId}`. Do not use `{reference}` inside this template.
*/
referenceTemplate?: string;
/** Additional className for styling */
className?: string;
};
Expand All @@ -42,20 +50,51 @@ const transform: TransformCallback = node => {
return undefined;
};

const ValueRenderers: Record<
TEMPLATE_VARIABLES,
(values: CustomPaymentMethodInstructionsProps['values'], intl: IntlShape) => string
> = {
reference: (values: CustomPaymentMethodInstructionsProps['values']) => values.OrderId.toString(),
OrderId: (values: CustomPaymentMethodInstructionsProps['values']) => values.OrderId.toString(),
amount: (values: CustomPaymentMethodInstructionsProps['values'], intl: IntlShape) =>
formatCurrency(values.amount.valueInCents, values.amount.currency, {
locale: intl.locale,
currencyDisplay: 'symbol',
}),
collective: (values: CustomPaymentMethodInstructionsProps['values']) => values.collectiveSlug,
account: (values: CustomPaymentMethodInstructionsProps['values']) =>
formatAccountDetails(values.accountDetails, { asSafeHTML: true }),
type SegmentMode = 'instructionHtml' | 'referencePlain';

/** Renders template segments; `{account}` is HTML in instructions and plain text in the reference template. */
const renderTemplateSegment = (
key: string,
values: InstructionValues,
intl: IntlShape,
mode: SegmentMode,
): string | undefined => {
switch (key as TEMPLATE_VARIABLES) {
case 'amount':
return formatCurrency(values.amount.valueInCents, values.amount.currency, {
locale: intl.locale,
currencyDisplay: 'symbol',
});
case 'collective':
return values.collectiveSlug;
case 'OrderId':
case 'contributionId':
return values.OrderId.toString();
case 'account':
return formatAccountDetails(values.accountDetails, {
asSafeHTML: mode === 'instructionHtml',
});
default:
return undefined;
}
};

/**
* Resolves the payment reference string from the host-configured template.
*/
export const resolvePaymentReferenceFromTemplate = (
referenceTemplate: string | undefined,
values: InstructionValues,
intl: IntlShape,
): string => {
const effective = referenceTemplate?.trim() ? referenceTemplate.trim() : DEFAULT_REFERENCE_TEMPLATE;
return effective.replace(/{([^\s{}][\s\S]*?)}/g, (match, key: string) => {
if (key === 'reference') {
return match;
}
const segment = renderTemplateSegment(key, values, intl, 'referencePlain');
return segment !== undefined ? segment : match;
});
};

/**
Expand All @@ -64,20 +103,23 @@ const ValueRenderers: Record<
*/
const replaceVariablesInHTML = (
instructions: string,
values: CustomPaymentMethodInstructionsProps['values'],
values: InstructionValues,
intl: IntlShape,
resolvedReference: string,
): string => {
if (!instructions) {
return '';
}

return instructions.replace(/{([^\s{}][\s\S]*?)}/g, (match, key) => {
const renderer = ValueRenderers[key as TEMPLATE_VARIABLES];
if (renderer) {
return renderer(values, intl);
} else {
return match;
if (key === 'reference') {
return resolvedReference;
}
const segment = renderTemplateSegment(key, values, intl, 'instructionHtml');
if (segment !== undefined) {
return segment;
}
return match;
});
};

Expand Down Expand Up @@ -118,17 +160,23 @@ export const CustomPaymentMethodInstructions = ({
instructions,
className,
values,
referenceTemplate,
}: CustomPaymentMethodInstructionsProps) => {
const intl = useIntl();
const resolvedReference = React.useMemo(
() => resolvePaymentReferenceFromTemplate(referenceTemplate, values, intl),
[referenceTemplate, values, intl],
);

const rendered = React.useMemo(
() => replaceVariablesInHTML(instructions, values, intl),
[instructions, values, intl],
() => replaceVariablesInHTML(instructions, values, intl, resolvedReference),
[instructions, values, intl, resolvedReference],
);
if (!rendered) {
return null;
}

const qrCodeData = formatQrCode(values.accountDetails, values.amount, values.OrderId.toString());
const qrCodeData = formatQrCode(values.accountDetails, values.amount, resolvedReference);
return (
<div>
<div
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ type CustomPaymentMethodTemplateEditorProps = {
OrderId: number;
accountDetails?: Record<string, unknown>;
};
/** Payment reference template for resolving `{reference}` in the preview */
referenceTemplate?: string;
/** Optional placeholder text */
placeholder?: string;
/** Optional minimum height for the editor */
Expand All @@ -38,6 +40,7 @@ export const CustomPaymentMethodTemplateEditor = ({
value,
onChange,
values,
referenceTemplate,
placeholder,
editorMinHeight = 200,
error = undefined,
Expand Down Expand Up @@ -79,7 +82,11 @@ export const CustomPaymentMethodTemplateEditor = ({
</TabsContent>
<TabsContent value="preview">
<div className="min-h-[275px] rounded border bg-gray-50 px-3 py-6 text-sm" id="instructions-preview">
<CustomPaymentMethodInstructions instructions={value} values={values} />
<CustomPaymentMethodInstructions
instructions={value}
values={values}
referenceTemplate={referenceTemplate}
/>
</div>
</TabsContent>
</Tabs>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ export const CustomPaymentMethodsList = ({
<div className="mt-3 mb-2 rounded border bg-gray-50 p-4 text-sm sm:p-5">
<CustomPaymentMethodInstructions
instructions={provider.instructions}
referenceTemplate={provider.referenceTemplate ?? undefined}
values={{
amount: { valueInCents: 3000, currency: account.currency },
collectiveSlug: account.slug,
Expand Down
Loading
Loading