Skip to content

Commit 19fa748

Browse files
author
Benjamin Piouffle
committed
WIP: Handle partial expense refunds
1 parent 1079f1e commit 19fa748

29 files changed

+487
-135
lines changed

config/default.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -237,8 +237,8 @@
237237
},
238238
"ledger": {
239239
"fastBalance": true,
240-
"separatePaymentProcessorFees": false,
241-
"separateTaxes": false,
240+
"separatePaymentProcessorFees": true,
241+
"separateTaxes": true,
242242
"orderedTransactions": false
243243
},
244244
"timeline": {

cron/disabled/reject-contributions.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ async function run({ dryRun, limit, force } = {}) {
107107
if (paymentMethodProvider.refundTransaction) {
108108
await refundTransaction(transaction, null, 'Contribution rejected');
109109
} else if (force) {
110-
await createRefundTransaction(transaction, 0, null);
110+
await createRefundTransaction(transaction, { refundedPaymentProcessorFeeInHostCurrency: 0 });
111111
} else {
112112
if (order.status === 'PAID' || order.status === 'CANCELLED') {
113113
shouldNotifyContributor = false;

scripts/paypal/mark-double-transactions-as-refunded.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,10 @@ const main = async (): Promise<void> => {
5353
} else {
5454
await Promise.all(
5555
transactionsToRefund.map(async transaction => {
56-
await createRefundTransaction(
57-
transaction,
58-
transaction.paymentProcessorFeeInHostCurrency,
59-
{ refundedFromDoubleTransactionsScript: true },
60-
null,
61-
);
56+
await createRefundTransaction(transaction, {
57+
refundedPaymentProcessorFeeInHostCurrency: transaction.paymentProcessorFeeInHostCurrency,
58+
data: { refundedFromDoubleTransactionsScript: true },
59+
});
6260
}),
6361
);
6462
}

server/graphql/common/expenses.ts

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import { EXPENSE_PERMISSION_ERROR_CODES } from '../../constants/permissions';
4242
import PlatformConstants from '../../constants/platform';
4343
import POLICIES from '../../constants/policies';
4444
import { TransactionKind } from '../../constants/transaction-kind';
45+
import { TransactionTypes } from '../../constants/transactions';
4546
import { checkFeatureAccess, hasFeature } from '../../lib/allowed-features';
4647
import cache from '../../lib/cache';
4748
import { convertToCurrency, getDate, getFxRate, loadFxRatesMap } from '../../lib/currency';
@@ -3774,7 +3775,7 @@ export async function payExpense(req: express.Request, args: PayExpenseArgs): Pr
37743775
export async function markExpenseAsUnpaid(
37753776
req: express.Request,
37763777
expenseId: number,
3777-
shouldRefundPaymentProcessorFee: boolean,
3778+
amountRefundedInHostCurrency?: number,
37783779
markAsUnPaidStatus: ExpenseStatus.APPROVED | ExpenseStatus.ERROR | ExpenseStatus.INCOMPLETE = ExpenseStatus.APPROVED,
37793780
): Promise<Expense> {
37803781
const newExpenseStatus = markAsUnPaidStatus || ExpenseStatus.APPROVED;
@@ -3813,22 +3814,38 @@ export async function markExpenseAsUnpaid(
38133814
RefundTransactionId: null,
38143815
kind: TransactionKind.EXPENSE,
38153816
isRefund: false,
3817+
type: TransactionTypes.DEBIT,
38163818
},
38173819
include: [{ model: models.Expense }],
38183820
});
38193821

3820-
// Load payment processor fee amount, either from the column or from the related transaction
3821-
let refundedPaymentProcessorFeeAmount = 0;
3822-
if (shouldRefundPaymentProcessorFee) {
3823-
refundedPaymentProcessorFeeAmount = transaction.paymentProcessorFeeInHostCurrency;
3824-
if (!refundedPaymentProcessorFeeAmount) {
3825-
refundedPaymentProcessorFeeAmount = Math.abs(
3826-
(await transaction.getPaymentProcessorFeeTransaction().then(t => t?.amountInHostCurrency)) || 0,
3827-
);
3828-
}
3822+
// Check amounts
3823+
const originalBaseAmountInHostCurrency = Math.abs(transaction.amountInHostCurrency || 0);
3824+
const originalProcessorFeeAmount =
3825+
transaction.paymentProcessorFeeInHostCurrency ||
3826+
Math.abs((await transaction.getPaymentProcessorFeeTransaction())?.amountInHostCurrency || 0);
3827+
const originalTotalAmountInHostCurrency = originalBaseAmountInHostCurrency + originalProcessorFeeAmount;
3828+
3829+
if (!amountRefundedInHostCurrency || amountRefundedInHostCurrency <= 0) {
3830+
throw new ValidationFailed('The refunded amount cannot be 0');
3831+
} else if (amountRefundedInHostCurrency > originalTotalAmountInHostCurrency) {
3832+
throw new ValidationFailed('The refunded amount cannot exceed the total amount paid');
38293833
}
38303834

3831-
await createRefundTransaction(transaction, refundedPaymentProcessorFeeAmount, null, expense.User);
3835+
// If refunding more than the original gross amount, refund the difference as payment processor fee (full or partial)
3836+
let refundedPaymentProcessorFee = 0;
3837+
let baseAmountToRefund = originalBaseAmountInHostCurrency;
3838+
if (amountRefundedInHostCurrency >= originalBaseAmountInHostCurrency) {
3839+
refundedPaymentProcessorFee = amountRefundedInHostCurrency - originalBaseAmountInHostCurrency;
3840+
} else {
3841+
baseAmountToRefund = originalBaseAmountInHostCurrency - amountRefundedInHostCurrency;
3842+
}
3843+
3844+
await createRefundTransaction(transaction, {
3845+
user: expense.User,
3846+
refundedPaymentProcessorFeeInHostCurrency: refundedPaymentProcessorFee,
3847+
amountRefundedInHostCurrency: baseAmountToRefund,
3848+
});
38323849

38333850
await expense.update({ status: newExpenseStatus, lastEditedById: remoteUser.id, PaymentMethodId: null });
38343851
return { expense, transaction };

server/graphql/schemaV2.graphql

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2657,6 +2657,11 @@ type Expense {
26572657
"""
26582658
paidAt: DateTime
26592659

2660+
"""
2661+
Payment information for this expense. Only available when the expense status is PAID.
2662+
"""
2663+
paymentInfo: ExpensePaymentInformationField
2664+
26602665
"""
26612666
Whether this expense is on hold
26622667
"""
@@ -4371,6 +4376,11 @@ type Host implements Account & AccountWithContributions & AccountWithPlatformSub
43714376
Only return accounts that expended within this amount range
43724377
"""
43734378
totalExpended: AmountRangeInput
4379+
4380+
"""
4381+
Order of the results
4382+
"""
4383+
orderBy: OrderByInput
43744384
): VendorCollection!
43754385

43764386
"""
@@ -5203,6 +5213,7 @@ enum OrderByFieldType {
52035213
BALANCE
52045214
MEMBER_COUNT
52055215
TOTAL_CONTRIBUTED
5216+
TOTAL_EXPENDED
52065217
NAME
52075218

52085219
"""
@@ -5687,6 +5698,7 @@ enum ActivityType {
56875698
COLLECTIVE_EDITED
56885699
COLLECTIVE_DELETED
56895700
COLLECTIVE_UNHOSTED
5701+
COLLECTIVE_BALANCE_TRANSFERRED
56905702
COLLECTIVE_CONVERTED_TO_ORGANIZATION
56915703
ORGANIZATION_COLLECTIVE_CREATED
56925704
ORGANIZATION_CONVERTED_TO_COLLECTIVE
@@ -6148,6 +6160,16 @@ type PayoutMethod {
61486160
Whether this payout method can be archived
61496161
"""
61506162
canBeArchived: Boolean
6163+
6164+
"""
6165+
The date and time this payout method was created
6166+
"""
6167+
createdAt: DateTime!
6168+
6169+
"""
6170+
The date and time this payout method was updated
6171+
"""
6172+
updatedAt: DateTime!
61516173
}
61526174

61536175
"""
@@ -6156,6 +6178,16 @@ PaymentMethod model
61566178
type PaymentMethod {
61576179
id: String
61586180
legacyId: Int
6181+
6182+
"""
6183+
The date and time this payout method was created
6184+
"""
6185+
createdAt: DateTime!
6186+
6187+
"""
6188+
The date and time this payout method was updated
6189+
"""
6190+
updatedAt: DateTime!
61596191
name: String
61606192
service: PaymentMethodService
61616193
type: PaymentMethodType
@@ -6178,7 +6210,6 @@ type PaymentMethod {
61786210
data: JSON
61796211
limitedToHosts: [Host]
61806212
expiryDate: DateTime
6181-
createdAt: DateTime
61826213

61836214
"""
61846215
For monthly gift cards, this field will return the monthly limit
@@ -8654,6 +8685,16 @@ enum ExpenseStatus {
86548685
INVITE_DECLINED
86558686
}
86568687

8688+
"""
8689+
Payment information for a paid expense
8690+
"""
8691+
type ExpensePaymentInformationField {
8692+
"""
8693+
The payment processor fee for this expense (in host currency)
8694+
"""
8695+
processorFee: Amount
8696+
}
8697+
86578698
"""
86588699
Fields for an expense's attached file
86598700
"""
@@ -19604,6 +19645,7 @@ enum ActivityAndClassesType {
1960419645
COLLECTIVE_EDITED
1960519646
COLLECTIVE_DELETED
1960619647
COLLECTIVE_UNHOSTED
19648+
COLLECTIVE_BALANCE_TRANSFERRED
1960719649
COLLECTIVE_CONVERTED_TO_ORGANIZATION
1960819650
ORGANIZATION_COLLECTIVE_CREATED
1960919651
ORGANIZATION_CONVERTED_TO_COLLECTIVE
@@ -24098,6 +24140,16 @@ type Mutation {
2409824140
Remove the given payout method. Scope: "expenses".
2409924141
"""
2410024142
removePayoutMethod(payoutMethodId: String!): PayoutMethod!
24143+
24144+
"""
24145+
Restore the given payout method. Scope: "expenses".
24146+
"""
24147+
restorePayoutMethod(
24148+
"""
24149+
Payout Method reference
24150+
"""
24151+
payoutMethod: PayoutMethodReferenceInput!
24152+
): PayoutMethod! @deprecated(reason: "2025-02-10: Payout methods cannot be restored.")
2410124153
editPayoutMethod(
2410224154
"""
2410324155
Payout Method reference
@@ -25981,6 +26033,11 @@ input ProcessExpensePaymentParams {
2598126033
"""
2598226034
shouldRefundPaymentProcessorFee: Boolean
2598326035

26036+
"""
26037+
Amount refunded when triggering MARK_AS_UNPAID. Must be > 0 and <= total amount paid. If omitted, full refund is performed. The difference (total paid - refunded) is recorded as payment processor fee.
26038+
"""
26039+
amountRefunded: AmountInput
26040+
2598426041
"""
2598526042
New expense status when triggering MARK_AS_UNPAID
2598626043
"""

server/graphql/v2/mutation/ExpenseMutations.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ import { GraphQLPaymentMethodService } from '../enum/PaymentMethodService';
6565
import { idDecode, IDENTIFIER_TYPES } from '../identifiers';
6666
import { fetchAccountingCategoryWithReference } from '../input/AccountingCategoryInput';
6767
import { fetchAccountWithReference, GraphQLAccountReferenceInput } from '../input/AccountReferenceInput';
68+
import { getValueInCentsFromAmountInput, GraphQLAmountInput } from '../input/AmountInput';
6869
import { GraphQLExpenseCreateInput } from '../input/ExpenseCreateInput';
6970
import { GraphQLExpenseInviteDraftInput } from '../input/ExpenseInviteDraftInput';
7071
import {
@@ -398,9 +399,10 @@ const expenseMutations = {
398399
type: GraphQLInt,
399400
description: 'The total amount paid in host currency',
400401
},
401-
shouldRefundPaymentProcessorFee: {
402-
type: GraphQLBoolean,
403-
description: 'Whether the payment processor fees should be refunded when triggering MARK_AS_UNPAID',
402+
amountRefunded: {
403+
type: GraphQLAmountInput,
404+
description:
405+
'Amount refunded when triggering MARK_AS_UNPAID. Must be > 0 and <= total amount paid. If omitted, full refund is performed. The difference (total paid - refunded) is recorded as payment processor fee.',
404406
},
405407
markAsUnPaidStatus: {
406408
type: new GraphQLEnumType({
@@ -490,14 +492,24 @@ const expenseMutations = {
490492
case 'MARK_AS_SPAM':
491493
expense = await markExpenseAsSpam(req, expense);
492494
break;
493-
case 'MARK_AS_UNPAID':
495+
case 'MARK_AS_UNPAID': {
496+
if (!args.paymentParams?.amountRefunded) {
497+
throw new ValidationFailed('amountRefunded is required when triggering MARK_AS_UNPAID');
498+
}
499+
500+
const amountRefundedInHostCurrency = getValueInCentsFromAmountInput(args.paymentParams.amountRefunded, {
501+
expectedCurrency: expense.collective.host.currency,
502+
allowNilCurrency: false,
503+
});
504+
494505
expense = await markExpenseAsUnpaid(
495506
req,
496507
expense.id,
497-
args.paymentParams?.shouldRefundPaymentProcessorFee || args.paymentParams?.paymentProcessorFee,
508+
amountRefundedInHostCurrency,
498509
args.paymentParams?.markAsUnPaidStatus,
499510
);
500511
break;
512+
}
501513
case 'SCHEDULE_FOR_PAYMENT':
502514
expense = await scheduleExpenseForPayment(req, expense, {
503515
feesPayer: args.paymentParams?.feesPayer,

server/graphql/v2/object/Expense.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import { GraphQLActivity } from './Activity';
5454
import { GraphQLAmount } from './Amount';
5555
import GraphQLExpenseAttachedFile from './ExpenseAttachedFile';
5656
import GraphQLExpenseItem from './ExpenseItem';
57+
import { GraphQLExpensePaymentInformationField } from './ExpensePaymentInformationField';
5758
import GraphQLExpensePermissions from './ExpensePermissions';
5859
import GraphQLExpenseQuote from './ExpenseQuote';
5960
import { GraphQLExpenseValuesByRole } from './ExpenseValuesByRole';
@@ -302,6 +303,15 @@ export const GraphQLExpense = new GraphQLObjectType<ExpenseModel, Express.Reques
302303
return transaction?.clearedAt || transaction?.createdAt || null;
303304
},
304305
},
306+
paymentInfo: {
307+
type: GraphQLExpensePaymentInformationField,
308+
description: 'Payment information for this expense. Only available when the expense status is PAID.',
309+
resolve(expense) {
310+
if (expense.status === expenseStatus.PAID) {
311+
return expense;
312+
}
313+
},
314+
},
305315
onHold: {
306316
type: GraphQLBoolean,
307317
description: 'Whether this expense is on hold',
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type Express from 'express';
2+
import { GraphQLObjectType } from 'graphql';
3+
import { find } from 'lodash';
4+
5+
import expenseStatus from '../../../constants/expense-status';
6+
import { TransactionKind } from '../../../constants/transaction-kind';
7+
import { TransactionTypes } from '../../../constants/transactions';
8+
import ExpenseModel from '../../../models/Expense';
9+
10+
import { GraphQLAmount } from './Amount';
11+
12+
export const GraphQLExpensePaymentInformationField = new GraphQLObjectType<ExpenseModel, Express.Request>({
13+
name: 'ExpensePaymentInformationField',
14+
description: 'Payment information for a paid expense',
15+
fields: () => ({
16+
processorFee: {
17+
type: GraphQLAmount,
18+
description: 'The payment processor fee for this expense (in host currency)',
19+
async resolve(expense, _, req) {
20+
if (expense.status !== expenseStatus.PAID) {
21+
return null;
22+
}
23+
24+
const transactions = await req.loaders.Transaction.byExpenseId.load(expense.id);
25+
const transaction = find(transactions, {
26+
kind: TransactionKind.PAYMENT_PROCESSOR_FEE,
27+
isRefund: false,
28+
type: TransactionTypes.DEBIT,
29+
RefundTransactionId: null,
30+
});
31+
32+
if (!transaction) {
33+
return null;
34+
}
35+
36+
return {
37+
value: Math.abs(transaction.amountInHostCurrency || 0),
38+
currency: transaction.hostCurrency,
39+
exchangeRate: null,
40+
};
41+
},
42+
},
43+
}),
44+
});

0 commit comments

Comments
 (0)