Skip to content
Open
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/contributions/ContributionCharges.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ export function ContributionCharges({
resetFilters: transactionsQueryFilter.resetFilters,
redirectRelatedTransactionsTo: redirectRelatedTransactionsTo,
excludeActions: ['reject'],
restrictRefundToHostDashboard: true,
});
const getChargeActions = React.useCallback(
(chargeGroup: ChargeGroup, onCloseFocusRef) =>
Expand Down
2 changes: 1 addition & 1 deletion components/contributions/ContributionTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ function ContributionTimeline(props: OrderTimelineProps) {
const { LoggedInUser } = useLoggedInUser();
const intl = useIntl();
const [collapseGroupsToggle, setCollapseGroupsToggle] = useState({});
const getTransactionActions = useTransactionActions();
const getTransactionActions = useTransactionActions({ restrictRefundToHostDashboard: true });

const toggleGroup = React.useCallback((group: string) => {
setCollapseGroupsToggle(cur => {
Expand Down
15 changes: 15 additions & 0 deletions components/dashboard/DashboardContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,18 @@ export const DashboardContext = React.createContext<DashboardContextType>({
setDefaultSlug: () => {},
getProfileUrl: () => null,
});

type EntityWithHost = {
host?: { id: string } | null;
};

/**
* Checks whether the currently active dashboard *is* the fiscal host of `entity` (a transaction,
* contribution/order, etc.) - i.e. whether we're inside that host's own dashboard right now.
*/
export function inHostDashboardOfEntity(
entity: EntityWithHost | null | undefined,
dashboardAccount: { id: string; isHost?: boolean } | null | undefined,
): boolean {
return Boolean(dashboardAccount?.isHost && entity?.host?.id === dashboardAccount.id);
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ const hostRefundChargeTransactionQuery = gql`
isRefunded
createdAt
description
paymentMethod {
id
service
}
amount {
valueInCents
currency
Expand Down Expand Up @@ -194,15 +198,23 @@ type HostRefundChargeModalProps = BaseModalProps & {
onSuccess?: () => void;
};

const HostRefundChargeFormSchema = z.object({
cancelRecurringContribution: z.boolean(),
removeAsContributor: z.boolean(),
sendMessage: z.boolean(),
message: z.string().max(2000).optional(),
ignoreBalanceCheck: z.boolean(),
});
const getHostRefundChargeFormSchema = () =>
z
.object({
cancelRecurringContribution: z.boolean(),
removeAsContributor: z.boolean(),
sendMessage: z.boolean(),
message: z.string().max(2000).optional(),
ignoreBalanceCheck: z.boolean(),
confirmManualRefund: z.boolean(),
isManualSettlement: z.boolean(),
})
.refine(values => !values.isManualSettlement || values.confirmManualRefund, {
message: 'Please confirm the manual refund',
path: ['confirmManualRefund'],
});

type HostRefundChargeFormValues = z.infer<typeof HostRefundChargeFormSchema>;
type HostRefundChargeFormValues = z.infer<ReturnType<typeof getHostRefundChargeFormSchema>>;

const Section: React.FC<{
children: React.ReactNode;
Expand Down Expand Up @@ -657,6 +669,7 @@ type RefundOptions = {
showRemoveAsContributor: boolean;
showHostMessage: boolean;
canIgnoreBalanceCheck: boolean;
isManualSettlement: boolean;
};

type HostRefundChargeFormProps = {
Expand Down Expand Up @@ -850,11 +863,32 @@ const HostRefundChargeForm: React.FC<HostRefundChargeFormProps> = ({ transaction
</ToggleOptionSection>
)}

{options.isManualSettlement && (
<label className="flex cursor-pointer items-start gap-2 text-sm">
<Checkbox
checked={values.confirmManualRefund}
onCheckedChange={checked => setFieldValue('confirmManualRefund', checked === true)}
disabled={isSubmitting}
/>
<FormattedMessage
defaultMessage="I confirm that the refund has been or will be performed manually off-platform. This action only reverses the transaction in the ledger; no money will be moved by the platform."
id="HostRefundChargeModal.confirmManualRefund"
/>
</label>
)}

<DialogFooter>
<Button type="button" variant="outline" onClick={onClose} disabled={isSubmitting}>
<FormattedMessage defaultMessage="Cancel" id="actions.cancel" />
</Button>
<Button type="submit" loading={isSubmitting} disabled={isInsufficientBalance && !values.ignoreBalanceCheck}>
<Button
type="submit"
loading={isSubmitting}
disabled={
(isInsufficientBalance && !values.ignoreBalanceCheck) ||
(options.isManualSettlement && !values.confirmManualRefund)
}
>
{submitLabel}
</Button>
</DialogFooter>
Expand All @@ -879,20 +913,23 @@ export const HostRefundChargeModal = ({
>(hostRefundChargeTransactionQuery, {
variables: { transaction: { id: transactionRef.id } },
skip: !open,
fetchPolicy: 'cache-and-network',
fetchPolicy: 'network-only',
});

const queriedTransaction = data?.transaction ?? previousData?.transaction;
const transaction = queriedTransaction?.id === transactionRef.id ? queriedTransaction : undefined;
const order = transaction?.order;
const isManualSettlement = transaction?.kind === TransactionKind.ADDED_FUNDS || transaction?.paymentMethod === null;

const isFiscalHostAdmin = Boolean(transaction?.host && LoggedInUser?.isAdminOfCollective(transaction.host));
const isVendorContributor = transaction?.oppositeAccount?.type === AccountType.VENDOR;

const options: RefundOptions = {
showCancelRecurring: Boolean(order?.permissions?.canCancel),
showRemoveAsContributor: Boolean(order?.permissions?.canRemoveAsContributor),
showHostMessage: isFiscalHostAdmin,
showHostMessage: isFiscalHostAdmin && !isVendorContributor,
canIgnoreBalanceCheck: isFiscalHostAdmin,
isManualSettlement,
};

const [runRefund] = useMutation<HostRefundPaymentMutation, HostRefundPaymentMutationVariables>(
Expand All @@ -906,22 +943,30 @@ export const HostRefundChargeModal = ({
[setOpen],
);

const schema = React.useMemo(() => getHostRefundChargeFormSchema(), []);

const initialValues = React.useMemo<HostRefundChargeFormValues>(
() => ({
cancelRecurringContribution: options.showCancelRecurring,
removeAsContributor: false,
sendMessage: false,
message: '',
ignoreBalanceCheck: false,
confirmManualRefund: false,
isManualSettlement: options.isManualSettlement,
}),
[options.showCancelRecurring],
[options.showCancelRecurring, options.isManualSettlement],
);

const handleSubmit = async (values: HostRefundChargeFormValues) => {
if (!transaction) {
return;
}

if (options.isManualSettlement && !values.confirmManualRefund) {
return;
}

try {
await runRefund({
variables: {
Expand Down Expand Up @@ -956,7 +1001,14 @@ export const HostRefundChargeModal = ({
>
<DialogHeader>
<DialogTitle>
<FormattedMessage defaultMessage="Refund contribution charge" id="gCyTuO" />
{isManualSettlement ? (
<FormattedMessage
defaultMessage="Mark contribution as refunded"
id="HostRefundChargeModal.MarkAsRefunded"
/>
) : (
<FormattedMessage defaultMessage="Refund contribution charge" id="gCyTuO" />
)}
Comment thread
kewitz marked this conversation as resolved.
</DialogTitle>
<DialogDescription>
<FormattedMessage defaultMessage="Review and confirm the refund details." id="i4akIU" />
Expand All @@ -976,7 +1028,7 @@ export const HostRefundChargeModal = ({
) : (
<FormikZod<HostRefundChargeFormValues>
key={transaction.id}
schema={HostRefundChargeFormSchema}
schema={schema}
initialValues={initialValues}
onSubmit={handleSubmit}
>
Expand Down
30 changes: 23 additions & 7 deletions components/dashboard/sections/transactions/actions.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useContext } from 'react';
import { gql, useMutation } from '@apollo/client';
import { compact } from 'lodash-es';
import { Download, ExternalLink, Filter, MinusCircle, Undo2 } from 'lucide-react';
Expand All @@ -10,9 +11,11 @@ import { PaymentMethodService } from '../../../../lib/graphql/types/v2/graphql';
import { useAsyncCall } from '../../../../lib/hooks/useAsyncCall';
import useLoggedInUser from '../../../../lib/hooks/useLoggedInUser';
import { saveInvoice } from '../../../../lib/transactions';
import { TransactionKind } from '@/lib/constants/transactions';

import { useModal } from '../../../ModalContext';
import { toast } from '../../../ui/useToast';
import { DashboardContext, inHostDashboardOfEntity } from '../../DashboardContext';

import { HostRefundChargeModal } from './HostRefundChargeModal';
import TransactionRejectModal from './TransactionRejectModal';
Expand All @@ -25,6 +28,11 @@ type UseTransactionActionsOptions = {
refetchList?: () => void;
redirectRelatedTransactionsTo?: string;
excludeActions?: TransactionActionKey[];
/**
* When true, the refund action is only shown if we're currently inside the transaction's
* fiscal host's own dashboard.
*/
restrictRefundToHostDashboard?: boolean;
};

const refundTransactionMutation = gql`
Expand Down Expand Up @@ -59,15 +67,13 @@ export function useTransactionActions<T extends TransactionsTableQueryNode | Tra
refetchList = null,
redirectRelatedTransactionsTo = undefined,
excludeActions = [],
restrictRefundToHostDashboard = false,
}: UseTransactionActionsOptions = {}) {
const intl = useIntl();

const { showModal, showConfirmationModal } = useModal();

const { LoggedInUser } = useLoggedInUser();

const { account: dashboardAccount } = useContext(DashboardContext);
const [refundTransaction] = useMutation(refundTransactionMutation);

const { callWith: downloadInvoiceWith } = useAsyncCall(saveInvoice, { useErrorToast: true });
const excludedActions = new Set(excludeActions);

Expand All @@ -81,10 +87,17 @@ export function useTransactionActions<T extends TransactionsTableQueryNode | Tra
}

const isFiscalHostAdmin = LoggedInUser.isAdminOfCollective(transaction.host);
const isAddedFunds = transaction.kind === TransactionKind.ADDED_FUNDS;
const isManualPayment =
transaction.kind === TransactionKind.CONTRIBUTION &&
transaction.type === 'CREDIT' &&
transaction.paymentMethod === null;
const isContributionCharge = Boolean(
transaction.order &&
transaction.paymentMethod?.service &&
[PaymentMethodService.PAYPAL, PaymentMethodService.STRIPE].includes(transaction.paymentMethod.service),
((transaction.paymentMethod?.service &&
[PaymentMethodService.PAYPAL, PaymentMethodService.STRIPE].includes(transaction.paymentMethod.service)) ||
isManualPayment ||
isAddedFunds),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);

const onMutationSuccess = () => {
Expand Down Expand Up @@ -117,7 +130,10 @@ export function useTransactionActions<T extends TransactionsTableQueryNode | Tra
{
key: 'refund',
label: intl.formatMessage({ defaultMessage: 'Refund', id: 'Refund' }),
if: transaction?.permissions.canRefund && !transaction.isRefunded,
if:
transaction?.permissions.canRefund &&
!transaction.isRefunded &&
(!restrictRefundToHostDashboard || inHostDashboardOfEntity(transaction, dashboardAccount)),
onClick: () => {
if (isContributionCharge && isFiscalHostAdmin) {
showModal(
Expand Down
2 changes: 2 additions & 0 deletions lang/ca.json
Original file line number Diff line number Diff line change
Expand Up @@ -2398,6 +2398,8 @@
"HostFee.Global": "Global host fee",
"HostFee.MonthlyRetainer": "Monthly retainer",
"HostPaymentRequests": "All Payment Requests",
"HostRefundChargeModal.confirmManualRefund": "I confirm that the refund has been or will be performed manually off-platform. This action only reverses the transaction in the ledger; no money will be moved by the platform.",
"HostRefundChargeModal.MarkAsRefunded": "Mark contribution as refunded",
"HostSince": "Host since",
"HowHcn": "Select an individual for verification",
"howItWorks": "How it works",
Expand Down
2 changes: 2 additions & 0 deletions lang/cs.json
Original file line number Diff line number Diff line change
Expand Up @@ -2398,6 +2398,8 @@
"HostFee.Global": "Global host fee",
"HostFee.MonthlyRetainer": "Měsíční uchovávání",
"HostPaymentRequests": "All Payment Requests",
"HostRefundChargeModal.confirmManualRefund": "I confirm that the refund has been or will be performed manually off-platform. This action only reverses the transaction in the ledger; no money will be moved by the platform.",
"HostRefundChargeModal.MarkAsRefunded": "Mark contribution as refunded",
"HostSince": "Hostitel od",
"HowHcn": "Select an individual for verification",
"howItWorks": "How it works",
Expand Down
2 changes: 2 additions & 0 deletions lang/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -2398,6 +2398,8 @@
"HostFee.Global": "Global host fee",
"HostFee.MonthlyRetainer": "Monatliche Aufbewahrung",
"HostPaymentRequests": "All Payment Requests",
"HostRefundChargeModal.confirmManualRefund": "I confirm that the refund has been or will be performed manually off-platform. This action only reverses the transaction in the ledger; no money will be moved by the platform.",
"HostRefundChargeModal.MarkAsRefunded": "Mark contribution as refunded",
"HostSince": "Träger seit",
"HowHcn": "Select an individual for verification",
"howItWorks": "So funktioniert es",
Expand Down
2 changes: 2 additions & 0 deletions lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2398,6 +2398,8 @@
"HostFee.Global": "Global host fee",
"HostFee.MonthlyRetainer": "Monthly retainer",
"HostPaymentRequests": "All Payment Requests",
"HostRefundChargeModal.confirmManualRefund": "I confirm that the refund has been or will be performed manually off-platform. This action only reverses the transaction in the ledger; no money will be moved by the platform.",
"HostRefundChargeModal.MarkAsRefunded": "Mark contribution as refunded",
"HostSince": "Host since",
"HowHcn": "Select an individual for verification",
"howItWorks": "How it works",
Expand Down
2 changes: 2 additions & 0 deletions lang/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -2398,6 +2398,8 @@
"HostFee.Global": "Comisión global del Anfitrión",
"HostFee.MonthlyRetainer": "Retención mensual",
"HostPaymentRequests": "All Payment Requests",
"HostRefundChargeModal.confirmManualRefund": "I confirm that the refund has been or will be performed manually off-platform. This action only reverses the transaction in the ledger; no money will be moved by the platform.",
"HostRefundChargeModal.MarkAsRefunded": "Mark contribution as refunded",
"HostSince": "Anfitrión desde",
"HowHcn": "Seleccionar una persona para su verificación",
"howItWorks": "Cómo funciona",
Expand Down
2 changes: 2 additions & 0 deletions lang/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -2398,6 +2398,8 @@
"HostFee.Global": "Frais d'hôte globaux",
"HostFee.MonthlyRetainer": "Abonnement mensuel",
"HostPaymentRequests": "Toutes les demandes de paiement",
"HostRefundChargeModal.confirmManualRefund": "I confirm that the refund has been or will be performed manually off-platform. This action only reverses the transaction in the ledger; no money will be moved by the platform.",
"HostRefundChargeModal.MarkAsRefunded": "Mark contribution as refunded",
"HostSince": "Hôte depuis",
"HowHcn": "Sélectionnez un individu pour la vérification",
"howItWorks": "Comment ça marche ?",
Expand Down
2 changes: 2 additions & 0 deletions lang/he.json
Original file line number Diff line number Diff line change
Expand Up @@ -2398,6 +2398,8 @@
"HostFee.Global": "Global host fee",
"HostFee.MonthlyRetainer": "תשלום חודשי",
"HostPaymentRequests": "All Payment Requests",
"HostRefundChargeModal.confirmManualRefund": "I confirm that the refund has been or will be performed manually off-platform. This action only reverses the transaction in the ledger; no money will be moved by the platform.",
"HostRefundChargeModal.MarkAsRefunded": "Mark contribution as refunded",
"HostSince": "ארגון גג מאז",
"HowHcn": "Select an individual for verification",
"howItWorks": "איך זה עובד",
Expand Down
2 changes: 2 additions & 0 deletions lang/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -2398,6 +2398,8 @@
"HostFee.Global": "Global host fee",
"HostFee.MonthlyRetainer": "Monthly retainer",
"HostPaymentRequests": "All Payment Requests",
"HostRefundChargeModal.confirmManualRefund": "I confirm that the refund has been or will be performed manually off-platform. This action only reverses the transaction in the ledger; no money will be moved by the platform.",
"HostRefundChargeModal.MarkAsRefunded": "Mark contribution as refunded",
"HostSince": "Host since",
"HowHcn": "Select an individual for verification",
"howItWorks": "Come funziona",
Expand Down
2 changes: 2 additions & 0 deletions lang/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -2398,6 +2398,8 @@
"HostFee.Global": "Global host fee",
"HostFee.MonthlyRetainer": "Monthly retainer",
"HostPaymentRequests": "All Payment Requests",
"HostRefundChargeModal.confirmManualRefund": "I confirm that the refund has been or will be performed manually off-platform. This action only reverses the transaction in the ledger; no money will be moved by the platform.",
"HostRefundChargeModal.MarkAsRefunded": "Mark contribution as refunded",
"HostSince": "Host since",
"HowHcn": "Select an individual for verification",
"howItWorks": "特長",
Expand Down
2 changes: 2 additions & 0 deletions lang/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -2398,6 +2398,8 @@
"HostFee.Global": "Global host fee",
"HostFee.MonthlyRetainer": "Monthly retainer",
"HostPaymentRequests": "All Payment Requests",
"HostRefundChargeModal.confirmManualRefund": "I confirm that the refund has been or will be performed manually off-platform. This action only reverses the transaction in the ledger; no money will be moved by the platform.",
"HostRefundChargeModal.MarkAsRefunded": "Mark contribution as refunded",
"HostSince": "Host since",
"HowHcn": "Select an individual for verification",
"howItWorks": "How it works",
Expand Down
2 changes: 2 additions & 0 deletions lang/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -2398,6 +2398,8 @@
"HostFee.Global": "Global host fee",
"HostFee.MonthlyRetainer": "Monthly retainer",
"HostPaymentRequests": "All Payment Requests",
"HostRefundChargeModal.confirmManualRefund": "I confirm that the refund has been or will be performed manually off-platform. This action only reverses the transaction in the ledger; no money will be moved by the platform.",
"HostRefundChargeModal.MarkAsRefunded": "Mark contribution as refunded",
"HostSince": "Host since",
"HowHcn": "Select an individual for verification",
"howItWorks": "Hoe het werkt",
Expand Down
2 changes: 2 additions & 0 deletions lang/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -2398,6 +2398,8 @@
"HostFee.Global": "Opłata za globalnego Gospodarza",
"HostFee.MonthlyRetainer": "Opłata miesięczna",
"HostPaymentRequests": "All Payment Requests",
"HostRefundChargeModal.confirmManualRefund": "I confirm that the refund has been or will be performed manually off-platform. This action only reverses the transaction in the ledger; no money will be moved by the platform.",
"HostRefundChargeModal.MarkAsRefunded": "Mark contribution as refunded",
"HostSince": "Gospodarz od",
"HowHcn": "Select an individual for verification",
"howItWorks": "Jak to działa",
Expand Down
2 changes: 2 additions & 0 deletions lang/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -2398,6 +2398,8 @@
"HostFee.Global": "Taxa global de hospedagem",
"HostFee.MonthlyRetainer": "Recipiente mensal",
"HostPaymentRequests": "Todas as solicitações de pagamento",
"HostRefundChargeModal.confirmManualRefund": "I confirm that the refund has been or will be performed manually off-platform. This action only reverses the transaction in the ledger; no money will be moved by the platform.",
"HostRefundChargeModal.MarkAsRefunded": "Mark contribution as refunded",
"HostSince": "Administrador desde",
"HowHcn": "Selecione um indivíduo para verificação",
"howItWorks": "Como funciona",
Expand Down
Loading
Loading