diff --git a/scripts/ledger/refund-host-fee-on-transaction.ts b/scripts/ledger/refund-host-fee-on-transaction.ts index 17c617386af..359c94ae1e6 100644 --- a/scripts/ledger/refund-host-fee-on-transaction.ts +++ b/scripts/ledger/refund-host-fee-on-transaction.ts @@ -50,7 +50,7 @@ const main = async () => { `Refunding host fee for transaction ${transactionGroup} (${transaction.description}) on behalf of ${userCollectiveSlug}`, ); if (!DRY_RUN) { - await refundHostFee(transaction, user, 0, uuid(), null); + await refundHostFee(transaction, user, 0, uuid()); } }; diff --git a/scripts/ledger/split-host-fee-shares.ts b/scripts/ledger/split-host-fee-shares.ts index 69c0ee121a1..b58cdd058a2 100644 --- a/scripts/ledger/split-host-fee-shares.ts +++ b/scripts/ledger/split-host-fee-shares.ts @@ -51,14 +51,9 @@ const migrate = async () => { console.log(`Migrated ${count}/${hostFeeTransactions.length} transactions`); } - const host = await models.Collective.findByPk(hostFeeTransaction.CollectiveId, { paranoid: false }); const transaction = await hostFeeTransaction.getRelatedTransaction({ kind: ['CONTRIBUTION', 'ADDED_FUNDS'] }); // TODO preload Payment method to define wether debts have ben created automatically - const result = await models.Transaction.createHostFeeShareTransactions( - { transaction, hostFeeTransaction }, - host, - false, - ); + const result = await models.Transaction.createHostFeeShareTransactions({ transaction, hostFeeTransaction }); results.push(Boolean(result)); } diff --git a/scripts/ledger/split-host-fees.ts b/scripts/ledger/split-host-fees.ts index 8d5167a60fa..2e840f9b7a3 100644 --- a/scripts/ledger/split-host-fees.ts +++ b/scripts/ledger/split-host-fees.ts @@ -42,7 +42,6 @@ const migrate = async () => { const groupedTransactions = Object.values(groupBy(transactions, 'TransactionGroup')); const timestamp = Date.now().toString(); const transactionsData = { hostFeeMigration: timestamp }; - const hostsCache = {}; let count = 0; console.log(`Migrating ${groupedTransactions.length} transaction pairs...`); @@ -64,18 +63,9 @@ const migrate = async () => { continue; } - // Caching hosts (small optimization) - let host; - if (credit.HostCollectiveId && hostsCache[credit.HostCollectiveId]) { - host = hostsCache[credit.HostCollectiveId]; - } else { - host = await credit.getHostCollective(); - hostsCache[host.id] = host; - } - // Create host fee transaction const creditPreMigrationData = pick(credit.dataValues, BACKUP_COLUMNS); - const result = await models.Transaction.createHostFeeTransactions(credit, host, transactionsData); + const result = await models.Transaction.createHostFeeTransactions(credit, transactionsData); if (!result) { continue; } diff --git a/scripts/ledger/update-transactions.ts b/scripts/ledger/update-transactions.ts index 6ea14c8453c..5519c793d64 100755 --- a/scripts/ledger/update-transactions.ts +++ b/scripts/ledger/update-transactions.ts @@ -43,7 +43,7 @@ const MODIFIERS = { const host = accountsCache[transaction.HostCollectiveId] || (await transaction.getHostCollective()); accountsCache[transaction.HostCollectiveId] = host; transaction.hostFeeInHostCurrency = transaction.amount * (percentage / 100); - await models.Transaction.createHostFeeTransactions(transaction, host); + await models.Transaction.createHostFeeTransactions(transaction); } }, }, diff --git a/scripts/taxes/add-tax-on-expense.ts b/scripts/taxes/add-tax-on-expense.ts index 5e292c002b1..4ef7a372f4d 100644 --- a/scripts/taxes/add-tax-on-expense.ts +++ b/scripts/taxes/add-tax-on-expense.ts @@ -6,6 +6,7 @@ import { Command } from 'commander'; import { sum } from 'lodash'; import models, { sequelize } from '../../server/models'; +import { ExpenseTaxDefinition } from '../../server/models/Expense'; const program = new Command() .description('Helper to add a tax on an existing expense') @@ -44,7 +45,7 @@ const main = async () => { } } - const tax = { type: 'GST', id: 'GST', rate: 0.15, percentage: 15 }; + const tax = { type: 'GST', id: 'GST', rate: 0.15, percentage: 15 } as ExpenseTaxDefinition; const taxAmountFromExpense = getTaxAmount(expense.amount, tax.rate); const taxAmountFromItems = sum(expense.items.map(i => getTaxAmount(i.amount, tax.rate))); if (taxAmountFromExpense !== taxAmountFromItems) { diff --git a/server/constants/paymentMethods.ts b/server/constants/paymentMethods.ts index 7994730685b..87412fcf598 100644 --- a/server/constants/paymentMethods.ts +++ b/server/constants/paymentMethods.ts @@ -9,6 +9,7 @@ export enum PAYMENT_METHOD_SERVICE { export const PAYMENT_METHOD_SERVICES = Object.values(PAYMENT_METHOD_SERVICE); export enum PAYMENT_METHOD_TYPE { + DEFAULT = 'default', ALIPAY = 'alipay', CREDITCARD = 'creditcard', PREPAID = 'prepaid', diff --git a/server/graphql/common/expenses.ts b/server/graphql/common/expenses.ts index b41ba8a0ab4..07c2be5b367 100644 --- a/server/graphql/common/expenses.ts +++ b/server/graphql/common/expenses.ts @@ -63,7 +63,7 @@ import { canUseFeature } from '../../lib/user-permissions'; import { formatCurrency, parseToBoolean } from '../../lib/utils'; import models, { Collective, sequelize } from '../../models'; import AccountingCategory from '../../models/AccountingCategory'; -import Expense, { ExpenseDataValuesByRole, ExpenseStatus } from '../../models/Expense'; +import Expense, { ExpenseDataValuesByRole, ExpenseStatus, ExpenseTaxDefinition } from '../../models/Expense'; import ExpenseAttachedFile from '../../models/ExpenseAttachedFile'; import ExpenseItem from '../../models/ExpenseItem'; import LegalDocument, { LEGAL_DOCUMENT_TYPE } from '../../models/LegalDocument'; @@ -1141,12 +1141,6 @@ export const hasMultiCurrency = (collective, host): boolean => { return collective.currency === host?.currency; // Only support multi-currency when collective/host have the same currency }; -type TaxDefinition = { - type: string; - rate: number; - idNumber: string; -}; - type ExpenseData = { id?: number; payoutMethod?: Record; @@ -1164,7 +1158,7 @@ type ExpenseData = { longDescription?: string; amount?: number; currency?: SupportedCurrency; - tax?: TaxDefinition[]; + tax?: ExpenseTaxDefinition[]; customData: Record; accountingCategory?: AccountingCategory; }; @@ -1934,7 +1928,7 @@ export async function editExpense(req: express.Request, expenseData: ExpenseData const updatedItemsData: Partial[] = (await prepareExpenseItemInputs(expenseCurrency, expenseData.items, { isEditing: true })) || expense.items; const [hasItemChanges, itemsDiff] = await getItemsChanges(expense.items, updatedItemsData); - const taxes = expenseData.tax || (expense.data?.taxes as TaxDefinition[]) || []; + const taxes = expenseData.tax || (expense.data?.taxes as ExpenseTaxDefinition[]) || []; checkTaxes(expense.collective, expense.collective.host, expenseType, taxes); if (!options?.['skipPermissionCheck'] && !(await canEditExpense(req, expense))) { diff --git a/server/graphql/v1/mutations/orders.js b/server/graphql/v1/mutations/orders.js index d5bf7479871..9ce6719258f 100644 --- a/server/graphql/v1/mutations/orders.js +++ b/server/graphql/v1/mutations/orders.js @@ -436,7 +436,7 @@ export async function createOrder(order, req) { orderPublicData = pick(order.data, []); } - const platformTipEligible = await libPayments.isPlatformTipEligible({ ...order, collective }, host); + const platformTipEligible = await libPayments.isPlatformTipEligible({ ...order, collective }); const orderData = { CreatedByUserId: remoteUser.id, diff --git a/server/graphql/v1/types.js b/server/graphql/v1/types.js index 47c8994bc41..3af931a37ba 100644 --- a/server/graphql/v1/types.js +++ b/server/graphql/v1/types.js @@ -1487,7 +1487,7 @@ export const OrderType = new GraphQLObjectType({ type: GraphQLJSON, description: 'Additional information on order: tax and custom fields', resolve(order) { - return pick(order.data, ['tax', 'customData', 'isFeesOnTop', 'platformFee', 'hasPlatformTip', 'platformTip']); + return pick(order.data, ['tax', 'customData', 'platformFee', 'hasPlatformTip', 'platformTip']); }, }, stripeError: { diff --git a/server/lib/budget.js b/server/lib/budget.js index 1b693d155b8..67817a68f75 100644 --- a/server/lib/budget.js +++ b/server/lib/budget.js @@ -481,7 +481,7 @@ export async function getSumCollectivesAmountReceived( export async function getTotalMoneyManagedAmount( host, - { endDate, collectiveIds, currency, version, loaders = null } = {}, + { endDate, collectiveIds, currency, version = null, loaders = null } = {}, ) { version = version || host.settings?.budget?.version || DEFAULT_BUDGET_VERSION; currency = currency || host.currency; diff --git a/server/lib/payments.js b/server/lib/payments.ts similarity index 77% rename from server/lib/payments.js rename to server/lib/payments.ts index 3944bf97eb4..638affb860f 100644 --- a/server/lib/payments.js +++ b/server/lib/payments.ts @@ -1,5 +1,6 @@ /** @module lib/payments */ import config from 'config'; +import DataLoader from 'dataloader'; import debugLib from 'debug'; import { find, get, includes, isNil, isNumber, omit, pick } from 'lodash'; import { v4 as uuid } from 'uuid'; @@ -7,14 +8,27 @@ import { v4 as uuid } from 'uuid'; import activities from '../constants/activities'; import { ExpenseFeesPayer } from '../constants/expense-fees-payer'; import status from '../constants/order-status'; -import { PAYMENT_METHOD_TYPE } from '../constants/paymentMethods'; +import { PAYMENT_METHOD_SERVICE, PAYMENT_METHOD_TYPE } from '../constants/paymentMethods'; import roles from '../constants/roles'; import tiers from '../constants/tiers'; import { TransactionKind } from '../constants/transaction-kind'; import { TransactionTypes } from '../constants/transactions'; -import models, { Op } from '../models'; +import { Op } from '../models'; +import Activity from '../models/Activity'; +import Order, { OrderModelInterface } from '../models/Order'; +import PaymentMethod, { PaymentMethodModelInterface } from '../models/PaymentMethod'; +import PayoutMethod, { PayoutMethodTypes } from '../models/PayoutMethod'; +import Subscription from '../models/Subscription'; +import Transaction, { + TransactionCreationAttributes, + TransactionData, + TransactionInterface, +} from '../models/Transaction'; import TransactionSettlement, { TransactionSettlementStatus } from '../models/TransactionSettlement'; +import User from '../models/User'; import paymentProviders from '../paymentProviders'; +import type { PaymentProviderService } from '../paymentProviders/types'; +import { RecipientAccount as BankAccountPayoutMethodData } from '../types/transferwise'; import { notify } from './notifications/email'; import { getFxRate } from './currency'; @@ -33,6 +47,8 @@ const { CREDIT, DEBIT } = TransactionTypes; const debug = debugLib('payments'); +type loaders = Record>>; + /** Check if paymentMethod has a given fully qualified name * * Payment Provider names are composed by service and type joined with @@ -41,7 +57,7 @@ const debug = debugLib('payments'); * given *fqn*. * * @param {String} fqn is the fully qualified name to be matched. - * @param {models.PaymentMethod} paymentMethod is the instance that + * @param {PaymentMethod} paymentMethod is the instance that * will have the fully qualified name compared to the parameter * *fqn*. * @returns {Boolean} true if *paymentMethod* has a fully qualified @@ -52,22 +68,25 @@ const debug = debugLib('payments'); * > isProvider('stripe.creditcard', { service: 'stripe', type: 'creditcard' }) * true */ -export function isProvider(fqn, paymentMethod) { - const pmFqn = `${paymentMethod.service}.${paymentMethod.type || 'default'}`; +export function isProvider(fqn, paymentMethod: PaymentMethodModelInterface): boolean { + const pmFqn = `${paymentMethod.service}.${paymentMethod.type || PAYMENT_METHOD_TYPE.DEFAULT}`; return fqn === pmFqn; } /** Find payment method handler * - * @param {models.PaymentMethod} paymentMethod This must point to a row in the + * @param {PaymentMethod} paymentMethod This must point to a row in the * `PaymentMethods` table. That information is retrieved and the * fields `service' & `type' are used to figure out which payment * {service: 'stripe', type: 'creditcard'}. * @return the payment method's JS module. */ -export function findPaymentMethodProvider(paymentMethod, { throwIfMissing = true } = {}) { - const provider = get(paymentMethod, 'service') || 'opencollective'; - const methodType = get(paymentMethod, 'type') || 'default'; +export function findPaymentMethodProvider( + paymentMethod: PaymentMethodModelInterface, + { throwIfMissing = true }: { throwIfMissing?: boolean } = {}, +): PaymentProviderService { + const provider = get(paymentMethod, 'service') || PAYMENT_METHOD_SERVICE.OPENCOLLECTIVE; + const methodType = get(paymentMethod, 'type') || PAYMENT_METHOD_TYPE.DEFAULT; let paymentMethodProvider = paymentProviders[provider]; if (!paymentMethodProvider) { if (throwIfMissing) { @@ -90,12 +109,15 @@ export function findPaymentMethodProvider(paymentMethod, { throwIfMissing = true * field. Which means that the query to select the order must include * the `PaymentMethods` table. */ -export async function processOrder(order, options) { +export async function processOrder( + order: OrderModelInterface, + options: { isAddedFund?: boolean; invoiceTemplate?: string } = {}, +): Promise { const paymentMethodProvider = findPaymentMethodProvider(order.paymentMethod); if (get(paymentMethodProvider, 'features.waitToCharge') && !get(order, 'paymentMethod.paid')) { return; } else { - return await paymentMethodProvider.processOrder(order, options); + return paymentMethodProvider.processOrder(order, options); } } @@ -108,11 +130,15 @@ export async function processOrder(order, options) { * associated to the refund transaction as who performed the refund. * @param {string} message a optional message to explain why the transaction is rejected */ -export async function refundTransaction(transaction, user, message = undefined, opts = {}) { +export async function refundTransaction( + transaction: TransactionInterface, + user?: User, + message?: string, +): Promise { // Make sure to fetch PaymentMethod // Fetch PaymentMethod even if it's deleted if (!transaction.PaymentMethod && transaction.PaymentMethodId) { - transaction.PaymentMethod = await models.PaymentMethod.findByPk(transaction.PaymentMethodId, { paranoid: false }); + transaction.PaymentMethod = await PaymentMethod.findByPk(transaction.PaymentMethodId, { paranoid: false }); } // If no payment method was used, it means that we're using a manual payment method @@ -120,8 +146,8 @@ export async function refundTransaction(transaction, user, message = undefined, ? findPaymentMethodProvider(transaction.PaymentMethod) : // TODO: Drop this in favor of findPaymentMethodProvider when persisting PaymentIntents as Payment Methods ['us_bank_account', 'sepa_debit'].includes(transaction.data?.charge?.payment_method_details?.type) - ? paymentProviders.stripe.types.paymentintent - : paymentProviders.opencollective.types.manual; + ? (paymentProviders.stripe.types.paymentintent as PaymentProviderService) + : (paymentProviders.opencollective.types.manual as PaymentProviderService); if (!paymentMethodProvider.refundTransaction) { throw new Error('This payment method provider does not support refunds'); @@ -130,7 +156,7 @@ export async function refundTransaction(transaction, user, message = undefined, let result; try { - result = await paymentMethodProvider.refundTransaction(transaction, user, message, opts); + result = await paymentMethodProvider.refundTransaction(transaction, user, message); } catch (e) { if ( (e.message.includes('has already been refunded') || e.message.includes('has been charged back')) && @@ -154,11 +180,16 @@ export async function refundTransaction(transaction, user, message = undefined, * calcFee(100, 3.5); // 4.0 * @return {Number} fee-percent of the amount rounded */ -export function calcFee(amount, fee) { +export function calcFee(amount: number, fee: number): number { return Math.round((amount * fee) / 100); } -export const buildRefundForTransaction = (t, user, data, refundedPaymentProcessorFee) => { +export const buildRefundForTransaction = ( + t: TransactionInterface, + user?: User, + data?: TransactionData, + refundedPaymentProcessorFee?: number, +): TransactionCreationAttributes => { const refund = pick(t, [ 'currency', 'FromCollectiveId', @@ -174,12 +205,11 @@ export const buildRefundForTransaction = (t, user, data, refundedPaymentProcesso 'paymentProcessorFeeInHostCurrency', 'taxAmount', 'data.hasPlatformTip', - 'data.isFeesOnTop', // deprecated form, replaced by hasPlatformTip 'data.tax', 'kind', 'isDebt', 'PayoutMethodId', - ]); + ]) as TransactionCreationAttributes; refund.CreatedByUserId = user?.id || null; refund.description = `Refund of "${t.description}"`; @@ -197,7 +227,7 @@ export const buildRefundForTransaction = (t, user, data, refundedPaymentProcesso /* Amount fields. Must be calculated after tweaking all the fees */ refund.amount = -t.amount; refund.amountInHostCurrency = -t.amountInHostCurrency; - refund.netAmountInCollectiveCurrency = -models.Transaction.calculateNetAmountInCollectiveCurrency(t); + refund.netAmountInCollectiveCurrency = -Transaction.calculateNetAmountInCollectiveCurrency(t); refund.isRefund = true; // We're handling host fees in separate transactions @@ -229,12 +259,17 @@ export const buildRefundForTransaction = (t, user, data, refundedPaymentProcesso } // Re-compute the net amount - refund.netAmountInCollectiveCurrency = models.Transaction.calculateNetAmountInCollectiveCurrency(refund); + refund.netAmountInCollectiveCurrency = Transaction.calculateNetAmountInCollectiveCurrency(refund); return refund; }; -export const refundPaymentProcessorFeeToCollective = async (transaction, refundTransactionGroup, data, createdAt) => { +export const refundPaymentProcessorFeeToCollective = async ( + transaction: TransactionInterface, + refundTransactionGroup: string, + data: { hostFeeMigration?: string } = {}, + createdAt: Date = null, +): Promise => { if (transaction.CollectiveId === transaction.HostCollectiveId) { return; } @@ -254,7 +289,7 @@ export const refundPaymentProcessorFeeToCollective = async (transaction, refundT processorFeeTransaction?.amountInHostCurrency || transaction.paymentProcessorFeeInHostCurrency, ); const amount = Math.round(amountInHostCurrency / hostCurrencyFxRate); - await models.Transaction.createDoubleEntry({ + await Transaction.createDoubleEntry({ type: CREDIT, kind: TransactionKind.PAYMENT_PROCESSOR_COVER, CollectiveId: transaction.CollectiveId, @@ -279,14 +314,13 @@ export const refundPaymentProcessorFeeToCollective = async (transaction, refundT }); }; -export async function refundPaymentProcessorFee( - transaction, - user, - refundedPaymentProcessorFee, - transactionGroup, - data, - clearedAt, -) { +async function refundPaymentProcessorFee( + transaction: TransactionInterface, + user: User, + refundedPaymentProcessorFee: number, + transactionGroup: string, + clearedAt?: Date, +): Promise { const isLegacyPaymentProcessorFee = Boolean(transaction.paymentProcessorFeeInHostCurrency); // Refund processor fees if the processor sent money back @@ -316,13 +350,13 @@ export async function refundPaymentProcessorFee( if (processorFeeTransaction) { const processorFeeRefund = { - ...buildRefundForTransaction(processorFeeTransaction, user, data), + ...buildRefundForTransaction(processorFeeTransaction, user), TransactionGroup: transactionGroup, clearedAt, }; - const processorFeeRefundTransaction = await models.Transaction.createDoubleEntry(processorFeeRefund); - await associateTransactionRefundId(processorFeeTransaction, processorFeeRefundTransaction, data); + const processorFeeRefundTransaction = await Transaction.createDoubleEntry(processorFeeRefund); + await associateTransactionRefundId(processorFeeTransaction, processorFeeRefundTransaction); } } @@ -340,11 +374,17 @@ export async function refundPaymentProcessorFee( } } -export async function refundHostFee(transaction, user, refundedPaymentProcessorFee, transactionGroup, data, clearedAt) { +export async function refundHostFee( + transaction: TransactionInterface, + user: User, + refundedPaymentProcessorFee: number, + transactionGroup: string, + clearedAt?: Date, +): Promise { const hostFeeTransaction = await transaction.getHostFeeTransaction(); const buildRefund = transaction => { return { - ...buildRefundForTransaction(transaction, user, data, refundedPaymentProcessorFee), + ...buildRefundForTransaction(transaction, user, null, refundedPaymentProcessorFee), TransactionGroup: transactionGroup, clearedAt, }; @@ -352,20 +392,20 @@ export async function refundHostFee(transaction, user, refundedPaymentProcessorF if (hostFeeTransaction) { const hostFeeRefund = buildRefund(hostFeeTransaction); - const hostFeeRefundTransaction = await models.Transaction.createDoubleEntry(hostFeeRefund); - await associateTransactionRefundId(hostFeeTransaction, hostFeeRefundTransaction, data); + const hostFeeRefundTransaction = await Transaction.createDoubleEntry(hostFeeRefund); + await associateTransactionRefundId(hostFeeTransaction, hostFeeRefundTransaction); // Refund Host Fee Share const hostFeeShareTransaction = await transaction.getHostFeeShareTransaction(); if (hostFeeShareTransaction) { const hostFeeShareRefund = buildRefund(hostFeeShareTransaction); - const hostFeeShareRefundTransaction = await models.Transaction.createDoubleEntry(hostFeeShareRefund); - await associateTransactionRefundId(hostFeeShareTransaction, hostFeeShareRefundTransaction, data); + const hostFeeShareRefundTransaction = await Transaction.createDoubleEntry(hostFeeShareRefund); + await associateTransactionRefundId(hostFeeShareTransaction, hostFeeShareRefundTransaction); // Refund Host Fee Share Debt const hostFeeShareDebtTransaction = await transaction.getHostFeeShareDebtTransaction(); if (hostFeeShareDebtTransaction) { - const hostFeeShareSettlement = await models.TransactionSettlement.findOne({ + const hostFeeShareSettlement = await TransactionSettlement.findOne({ where: { TransactionGroup: transaction.TransactionGroup, kind: TransactionKind.HOST_FEE_SHARE_DEBT, @@ -380,8 +420,8 @@ export async function refundHostFee(transaction, user, refundedPaymentProcessorF } const hostFeeShareDebtRefund = buildRefund(hostFeeShareDebtTransaction); - const hostFeeShareDebtRefundTransaction = await models.Transaction.createDoubleEntry(hostFeeShareDebtRefund); - await associateTransactionRefundId(hostFeeShareDebtTransaction, hostFeeShareDebtRefundTransaction, data); + const hostFeeShareDebtRefundTransaction = await Transaction.createDoubleEntry(hostFeeShareDebtRefund); + await associateTransactionRefundId(hostFeeShareDebtTransaction, hostFeeShareDebtRefundTransaction); await TransactionSettlement.createForTransaction( hostFeeShareDebtRefundTransaction, hostFeeShareRefundSettlementStatus, @@ -391,16 +431,21 @@ export async function refundHostFee(transaction, user, refundedPaymentProcessorF } } -export async function refundTax(transaction, user, transactionGroup, data, clearedAt) { +async function refundTax( + transaction: TransactionInterface, + user: User, + transactionGroup: string, + clearedAt?: Date, +): Promise { const taxTransaction = await transaction.getTaxTransaction(); if (taxTransaction) { const taxRefundData = { - ...buildRefundForTransaction(taxTransaction, user, data), + ...buildRefundForTransaction(taxTransaction, user), TransactionGroup: transactionGroup, clearedAt, }; - const taxRefundTransaction = await models.Transaction.createDoubleEntry(taxRefundData); - await associateTransactionRefundId(taxTransaction, taxRefundTransaction, data); + const taxRefundTransaction = await Transaction.createDoubleEntry(taxRefundData); + await associateTransactionRefundId(taxTransaction, taxRefundTransaction); } } @@ -415,7 +460,7 @@ export async function refundTax(transaction, user, transactionGroup, data, clear * 1. CREDIT from collective B to collective A * 2. DEBIT from collective A to collective B * - * @param {models.Transaction} transaction Can be either a + * @param {Transaction} transaction Can be either a * DEBIT or a CREDIT transaction and it will generate a pair of * transactions that debit the collective that was credited and * credit the user that was debited. @@ -429,13 +474,13 @@ export async function refundTax(transaction, user, transactionGroup, data, clear * transactions being created. */ export async function createRefundTransaction( - transaction, - refundedPaymentProcessorFee, - data, - user, - transactionGroupId = null, - clearedAt = undefined, -) { + transaction: TransactionInterface, + refundedPaymentProcessorFee: number, + data: TransactionData, + user: User, + transactionGroupId?: string, + clearedAt?: Date, +): Promise { /* If the transaction passed isn't the one from the collective * perspective, the opposite transaction is retrieved. * @@ -467,7 +512,7 @@ export async function createRefundTransaction( const platformTipTransaction = await transaction.getPlatformTipTransaction(); if (platformTipTransaction) { const platformTipRefund = buildRefund(platformTipTransaction); - const platformTipRefundTransaction = await models.Transaction.createDoubleEntry(platformTipRefund); + const platformTipRefundTransaction = await Transaction.createDoubleEntry(platformTipRefund); await associateTransactionRefundId(platformTipTransaction, platformTipRefundTransaction, data); // Refund Platform Tip Debt @@ -475,7 +520,7 @@ export async function createRefundTransaction( const platformTipDebtTransaction = await transaction.getPlatformTipDebtTransaction(); if (platformTipDebtTransaction) { // Update tip settlement status - const tipSettlement = await models.TransactionSettlement.findOne({ + const tipSettlement = await TransactionSettlement.findOne({ where: { TransactionGroup: transaction.TransactionGroup, kind: TransactionKind.PLATFORM_TIP_DEBT, @@ -490,29 +535,33 @@ export async function createRefundTransaction( } const platformTipDebtRefund = buildRefund(platformTipDebtTransaction); - const platformTipDebtRefundTransaction = await models.Transaction.createDoubleEntry(platformTipDebtRefund); + const platformTipDebtRefundTransaction = await Transaction.createDoubleEntry(platformTipDebtRefund); await associateTransactionRefundId(platformTipDebtTransaction, platformTipDebtRefundTransaction, data); await TransactionSettlement.createForTransaction(platformTipDebtRefundTransaction, tipRefundSettlementStatus); } } // Refund Payment Processor Fee - await refundPaymentProcessorFee(transaction, user, refundedPaymentProcessorFee, transactionGroup, {}, clearedAt); + await refundPaymentProcessorFee(transaction, user, refundedPaymentProcessorFee, transactionGroup, clearedAt); // Refund Host Fee - await refundHostFee(transaction, user, refundedPaymentProcessorFee, transactionGroup, {}, clearedAt); + await refundHostFee(transaction, user, refundedPaymentProcessorFee, transactionGroup, clearedAt); // Refund Tax - await refundTax(transaction, user, transactionGroup, {}, clearedAt); + await refundTax(transaction, user, transactionGroup, clearedAt); // Refund main transaction const creditTransactionRefund = buildRefund(transaction); - const refundTransaction = await models.Transaction.createDoubleEntry(creditTransactionRefund); + const refundTransaction = await Transaction.createDoubleEntry(creditTransactionRefund); return associateTransactionRefundId(transaction, refundTransaction, data); } -export async function associateTransactionRefundId(transaction, refund, data) { - const transactions = await models.Transaction.findAll({ +export async function associateTransactionRefundId( + transaction: TransactionInterface, + refund: TransactionInterface, + data?: TransactionData, +): Promise { + const transactions = await Transaction.findAll({ order: ['id'], where: { [Op.or]: [ @@ -528,7 +577,7 @@ export async function associateTransactionRefundId(transaction, refund, data) { const refundDebit = transactions.find(t => t.isRefund && t.type === DEBIT); // After refunding a transaction, in some cases the data may be updated as well (stripe data changes after refunds) - if (data) { + if (data && Object.keys(data).length) { debit.data = data; credit.data = data; } @@ -565,7 +614,7 @@ export async function associateTransactionRefundId(transaction, refund, data) { * In all cases, transaction.type is CREDIT. * */ -export const sendEmailNotifications = (order, transaction) => { +export const sendEmailNotifications = (order: OrderModelInterface, transaction?: TransactionInterface): void => { debug('sendEmailNotifications'); if ( transaction && @@ -594,8 +643,8 @@ export const sendEmailNotifications = (order, transaction) => { } }; -export const createSubscription = async order => { - const subscription = await models.Subscription.create({ +export const createSubscription = async (order: OrderModelInterface): Promise => { + const subscription = await Subscription.create({ amount: order.totalAmount, interval: order.interval, currency: order.currency, @@ -625,18 +674,22 @@ export const createSubscription = async order => { /** * Execute an order as user using paymentMethod - * Note: validation of the paymentMethod happens in `models.Order.setPaymentMethod`. Not here anymore. + * Note: validation of the paymentMethod happens in `Order.setPaymentMethod`. Not here anymore. * @param {Object} order { tier, description, totalAmount, currency, interval (null|month|year), paymentMethod } * @param {Object} options { hostFeePercent, platformFeePercent} (only for add funds and if remoteUser is admin of host or root) */ -export const executeOrder = async (user, order, options = {}) => { - if (!(user instanceof models.User)) { +export const executeOrder = async ( + user: User, + order: OrderModelInterface, + options: { isAddedFund?: boolean; invoiceTemplate?: string } = {}, +): Promise => { + if (!(user instanceof User)) { return Promise.reject(new Error('user should be an instance of the User model')); } if (!order) { return Promise.reject(new Error('No order provided')); } - if (!(order instanceof models.Order)) { + if (!(order instanceof Order)) { return Promise.reject(new Error('order should be an instance of the Order model')); } @@ -696,7 +749,9 @@ export const executeOrder = async (user, order, options = {}) => { order.paymentMethod.save(); } - sendEmailNotifications(order, transaction); + if (transaction) { + sendEmailNotifications(order, transaction); + } // Register gift card emitter as collective backer too if (transaction && transaction.UsingGiftCardFromCollectiveId) { @@ -709,7 +764,7 @@ export const executeOrder = async (user, order, options = {}) => { } }; -const validatePayment = payment => { +const validatePayment = (payment): void => { if (payment.interval && !includes(['month', 'year'], payment.interval)) { throw new Error('Interval should be null, month or year.'); } @@ -719,7 +774,10 @@ const validatePayment = payment => { } }; -const sendOrderConfirmedEmail = async (order, transaction) => { +const sendOrderConfirmedEmail = async ( + order: OrderModelInterface, + transaction: TransactionInterface, +): Promise => { const attachments = []; const { collective, interval, fromCollective, paymentMethod } = order; const user = await order.getUserForActivity(); @@ -732,7 +790,7 @@ const sendOrderConfirmedEmail = async (order, transaction) => { } if (order.tier?.type === tiers.TICKET) { - return models.Activity.create({ + await Activity.create({ type: activities.TICKET_CONFIRMED, CollectiveId: collective.id, FromCollectiveId: fromCollective.id, @@ -763,6 +821,8 @@ const sendOrderConfirmedEmail = async (order, transaction) => { firstPayment: true, subscriptionsLink: interval && getEditRecurringContributionsUrl(fromCollective), customMessage, + transactionPdf: false, + platformTipPdf: false, // Include Pending Order contact info if available fromAccountInfo: order.data?.fromAccountInfo, }; @@ -796,7 +856,7 @@ const sendOrderConfirmedEmail = async (order, transaction) => { } const activity = { type: activities.ORDER_THANKYOU, data }; - return notify.collective(activity, { + await notify.collective(activity, { collectiveId: data.fromCollective.id, role: [roles.ACCOUNTANT, roles.ADMIN], from: emailLib.generateFromEmailHeader(collective.name), @@ -806,14 +866,17 @@ const sendOrderConfirmedEmail = async (order, transaction) => { }; // Assumes one-time payments, -export const sendOrderPendingEmail = async order => { +export const sendOrderPendingEmail = async (order: OrderModelInterface): Promise => { const { collective, fromCollective } = order; const user = order.createdByUser; const host = await collective.getHostCollective(); - const manualPayoutMethod = await models.PayoutMethod.findOne({ + const manualPayoutMethod = await PayoutMethod.findOne({ where: { CollectiveId: host.id, data: { isManualBankTransfer: true } }, }); - const account = manualPayoutMethod ? formatAccountDetails(manualPayoutMethod.data) : ''; + const account = + manualPayoutMethod?.type === PayoutMethodTypes.BANK_ACCOUNT + ? formatAccountDetails(manualPayoutMethod.data as BankAccountPayoutMethodData) + : ''; const data = { account, @@ -823,6 +886,7 @@ export const sendOrderPendingEmail = async order => { host: host.info, fromCollective: fromCollective.activity, subscriptionsLink: getEditRecurringContributionsUrl(fromCollective), + instructions: null, }; const instructions = get(host, 'settings.paymentMethods.manual.instructions'); if (instructions) { @@ -843,7 +907,7 @@ export const sendOrderPendingEmail = async order => { } }); } - await models.Activity.create({ + await Activity.create({ type: activities.ORDER_PENDING, UserId: user.id, CollectiveId: collective.id, @@ -854,7 +918,7 @@ export const sendOrderPendingEmail = async order => { }); }; -export const sendOrderProcessingEmail = async order => { +const sendOrderProcessingEmail = async (order: OrderModelInterface): Promise => { const { collective, fromCollective } = order; const user = order.createdByUser; const host = await collective.getHostCollective(); @@ -867,7 +931,7 @@ export const sendOrderProcessingEmail = async order => { fromCollective: fromCollective.activity, }; - await models.Activity.create({ + await Activity.create({ type: activities.ORDER_PROCESSING, UserId: user.id, CollectiveId: collective.id, @@ -878,7 +942,7 @@ export const sendOrderProcessingEmail = async order => { }); }; -export const sendOrderFailedEmail = async (order, reason) => { +export const sendOrderFailedEmail = async (order: OrderModelInterface, reason: string): Promise => { const user = order.createdByUser; const { collective, fromCollective } = order; const host = await collective.getHostCollective(); @@ -892,7 +956,7 @@ export const sendOrderFailedEmail = async (order, reason) => { reason, }; - await models.Activity.create({ + await Activity.create({ type: activities.ORDER_PAYMENT_FAILED, UserId: user.id, CollectiveId: collective.id, @@ -903,14 +967,14 @@ export const sendOrderFailedEmail = async (order, reason) => { }); }; -const sendManualPendingOrderEmail = async order => { +const sendManualPendingOrderEmail = async (order: OrderModelInterface): Promise => { const { collective, fromCollective } = order; const host = await collective.getHostCollective(); let replyTo = []; if (fromCollective.isIncognito) { // We still want to surface incognito emails to the host as they often need to contact them to reconciliate the bank transfer - const user = await models.User.findByPk(fromCollective.CreatedByUserId); + const user = await User.findByPk(fromCollective.CreatedByUserId); if (user) { replyTo.push(user.email); } @@ -928,7 +992,7 @@ const sendManualPendingOrderEmail = async order => { replyTo, isSystem: true, }; - return models.Activity.create({ + await Activity.create({ type: activities.ORDER_PENDING_CONTRIBUTION_NEW, CollectiveId: order.CollectiveId, FromCollectiveId: order.FromCollectiveId, @@ -939,7 +1003,7 @@ const sendManualPendingOrderEmail = async order => { }); }; -export const sendReminderPendingOrderEmail = async order => { +export const sendReminderPendingOrderEmail = async (order: OrderModelInterface): Promise => { const { collective, fromCollective } = order; const host = await collective.getHostCollective(); @@ -958,7 +1022,7 @@ export const sendReminderPendingOrderEmail = async order => { viewDetailsLink: `${config.host.website}/${host.slug}/dashboard/orders/contributions/${order.id}`, isSystem: true, }; - return await models.Activity.create({ + await Activity.create({ type: activities.ORDER_PENDING_CONTRIBUTION_REMINDER, CollectiveId: order.CollectiveId, FromCollectiveId: order.FromCollectiveId, @@ -968,88 +1032,95 @@ export const sendReminderPendingOrderEmail = async order => { }); }; -export const sendExpiringCreditCardUpdateEmail = async data => { +export const sendExpiringCreditCardUpdateEmail = async (data): Promise => { data = { ...data, updateDetailsLink: `${config.host.website}/paymentmethod/${data.id}/update`, isSystem: true, }; - await models.Activity.create({ + await Activity.create({ type: activities.PAYMENT_CREDITCARD_EXPIRING, CollectiveId: data.CollectiveId, data, }); }; -export const getApplicationFee = async (order, { host = null } = {}) => { - let applicationFee = getPlatformTip(order); +export const getApplicationFee = async (order: OrderModelInterface): Promise => { + let applicationFee = 0; - const hostFeeSharePercent = await getHostFeeSharePercent(order, { host }); - if (hostFeeSharePercent) { - const hostFee = await getHostFee(order, { host }); - const sharedRevenue = calcFee(hostFee, hostFeeSharePercent); - applicationFee += sharedRevenue; + if (order.platformTipAmount) { + applicationFee += order.platformTipAmount; + } + + const hostFeeAmount = await getHostFee(order); + const hostFeeSharePercent = await getHostFeeSharePercent(order); + if (hostFeeAmount && hostFeeSharePercent) { + const hostFeeShareAmount = calcFee(hostFeeAmount, hostFeeSharePercent); + applicationFee += hostFeeShareAmount; } return applicationFee; }; -export const getPlatformTip = object => { - if (!isNil(object.platformTipAmount)) { - return object.platformTipAmount; +export const getPlatformTip = (order: OrderModelInterface): number => { + if (!isNil(order.platformTipAmount)) { + return order.platformTipAmount; } - // Legacy form, but still being used sometime when extracting platformTip from transactionData - if (!isNil(object.data?.platformTip)) { - return object.data?.platformTip; + // Legacy form, but still being used sometime (to be verified and removed) + if (!isNil(order.data?.platformTip)) { + return order.data?.platformTip; } return 0; }; -export const getPlatformFeePercent = async () => { - // Platform Fees are deprecated - return 0; -}; - -export const getHostFee = async (order, { host = null } = {}) => { - const platformTip = getPlatformTip(order); +export const getHostFee = async (order: OrderModelInterface): Promise => { + const totalAmount = order.totalAmount || 0; const taxAmount = order.taxAmount || 0; + const platformTipAmount = order.platformTipAmount || 0; - const hostFeePercent = await getHostFeePercent(order, { host }); + const hostFeePercent = await getHostFeePercent(order); - return calcFee(order.totalAmount - platformTip - taxAmount, hostFeePercent); + return calcFee(totalAmount - taxAmount - platformTipAmount, hostFeePercent); }; -export const isPlatformTipEligible = async (order, host = null) => { - if (!isNil(order.collective.data?.platformTips)) { - return order.collective.data.platformTips; +export const isPlatformTipEligible = async (order: OrderModelInterface): Promise => { + if (!isNil(order.platformTipEligible)) { + return order.platformTipEligible; } + // Make sure payment method is available if (!order.paymentMethod && order.PaymentMethodId) { order.paymentMethod = await order.getPaymentMethod(); } // Added Funds are not eligible to Platform Tips - if (order.paymentMethod?.service === 'opencollective' && order.paymentMethod?.type === 'host') { + if ( + order.paymentMethod?.service === PAYMENT_METHOD_SERVICE.OPENCOLLECTIVE && + order.paymentMethod?.type === PAYMENT_METHOD_TYPE.HOST + ) { return false; } - host = host || (await order.collective.getHostCollective()); + const host = await order.collective.getHostCollective(); if (host) { const plan = await host.getPlan(); + // At this stage, only OSC /opensourcce and Open Collective /opencollective will return false return plan.platformTips; } return false; }; -export const getHostFeePercent = async (order, { host = null, loaders = null } = {}) => { +export const getHostFeePercent = async ( + order: OrderModelInterface, + { loaders = null }: { loaders?: loaders } = {}, +): Promise => { const collective = order.collective || (await loaders?.Collective.byId.load(order.CollectiveId)) || (await order.getCollective()); + const host = await collective.getHostCollective({ loaders }); const parent = await collective.getParentCollective({ loaders }); - host = host || (await collective.getHostCollective({ loaders })); - // Make sure payment method is available if (!order.paymentMethod && order.PaymentMethodId) { order.paymentMethod = await order.getPaymentMethod(); @@ -1065,7 +1136,10 @@ export const getHostFeePercent = async (order, { host = null, loaders = null } = order.data?.hostFeePercent, ]; - if (order.paymentMethod?.service === 'opencollective' && order.paymentMethod?.type === 'manual') { + if ( + order.paymentMethod?.service === PAYMENT_METHOD_SERVICE.OPENCOLLECTIVE && + order.paymentMethod?.type === PAYMENT_METHOD_TYPE.MANUAL + ) { // Fixed for Bank Transfers at collective level // As of December 2023, this will be only set on a selection of OCF Collectives // 1kproject 6%, mealsofgratitude 5%, modulo 5% @@ -1088,13 +1162,19 @@ export const getHostFeePercent = async (order, { host = null, loaders = null } = possibleValues.push(host?.data?.bankTransfersHostFeePercent); } - if (order.paymentMethod?.service === 'opencollective' && order.paymentMethod?.type === 'prepaid') { + if ( + order.paymentMethod?.service === PAYMENT_METHOD_SERVICE.OPENCOLLECTIVE && + order.paymentMethod?.type === PAYMENT_METHOD_TYPE.PREPAID + ) { if (order.paymentMethod.data?.hostFeePercent) { possibleValues.push(order.paymentMethod.data?.hostFeePercent); } } - if (order.paymentMethod?.service === 'opencollective' && order.paymentMethod?.type === 'host') { + if ( + order.paymentMethod?.service === PAYMENT_METHOD_SERVICE.OPENCOLLECTIVE && + order.paymentMethod?.type === PAYMENT_METHOD_TYPE.COLLECTIVE + ) { // Fixed for Added Funds at collective level possibleValues.push(collective.data?.addedFundsHostFeePercent); // Fixed for Added Funds at parent level @@ -1112,12 +1192,15 @@ export const getHostFeePercent = async (order, { host = null, loaders = null } = possibleValues.push(host?.data?.addedFundsHostFeePercent); } - if (order.paymentMethod?.service === 'opencollective' && order.paymentMethod?.type === 'collective') { + if ( + order.paymentMethod?.service === PAYMENT_METHOD_SERVICE.OPENCOLLECTIVE && + order.paymentMethod?.type === PAYMENT_METHOD_TYPE.COLLECTIVE + ) { // Default to 0 for Collective to Collective on the same Host possibleValues.push(0); } - if (order.paymentMethod?.service === 'stripe') { + if (order.paymentMethod?.service === PAYMENT_METHOD_SERVICE.STRIPE) { // Configurable by the Host globally, at the Collective or Parent level // possibleValues.push(collective.data?.stripeHostFeePercent); // not used in the wild so far // possibleValues.push(parent?.data?.stripeHostFeePercent); // not used in the wild so far @@ -1138,7 +1221,7 @@ export const getHostFeePercent = async (order, { host = null, loaders = null } = // possibleValues.push(host.data?.stripeHostFeePercent); // not used in the wild so far } - if (order.paymentMethod?.service === 'paypal') { + if (order.paymentMethod?.service === PAYMENT_METHOD_SERVICE.PAYPAL) { // Configurable by the Host globally or at the Collective level // possibleValues.push(collective.data?.paypalHostFeePercent); // not used in the wild so far // possibleValues.push(parent?.data?.paypalHostFeePercent); // not used in the wild so far @@ -1169,41 +1252,41 @@ export const getHostFeePercent = async (order, { host = null, loaders = null } = return possibleValues.find(isNumber); }; -export const getHostFeeSharePercent = async (order, { host = null, loaders = null } = {}) => { - if (!host) { - if (!order.collective) { - order.collective = (await loaders?.Collective.byId.load(order.CollectiveId)) || (await order.getCollective()); - } - host = await order.collective.getHostCollective({ loaders }); +export const getHostFeeSharePercent = async ( + order: OrderModelInterface, + { loaders = null }: { loaders?: loaders } = {}, +): Promise => { + if (!order.collective) { + order.collective = (await loaders?.Collective.byId.load(order.CollectiveId)) || (await order.getCollective()); } + const host = await order.collective.getHostCollective({ loaders }); + const plan = await host.getPlan(); const possibleValues = []; - if (order) { - // Platform Tip Eligible? No Host Fee Share, that's it - if (order.platformTipEligible === true) { - return 0; - } + // Platform Tip Eligible or Platform Fee? No Host Fee Share, that's it + if (order.platformTipEligible === true) { + return 0; + } - // Make sure payment method is available - if (!order.paymentMethod && order.PaymentMethodId) { - order.paymentMethod = await order.getPaymentMethod(); - } + // Make sure payment method is available + if (!order.paymentMethod && order.PaymentMethodId) { + order.paymentMethod = await order.getPaymentMethod(); + } - // Used by 1st party hosts to set Stripe and PayPal (aka "Crowfunding") share percent to zero - // Ideally, this will not be used in the future as we'll always rely on the platformTipEligible flag to do that - // We still have a lot of old orders were platformTipEligible is not set, so we'll keep that configuration for now - - // Assign different fees based on the payment provider - if (order.paymentMethod?.service === 'stripe') { - possibleValues.push(host.data?.stripeHostFeeSharePercent); - possibleValues.push(plan?.stripeHostFeeSharePercent); // deprecated - } else if (order.paymentMethod?.service === 'paypal') { - possibleValues.push(host.data?.paypalHostFeeSharePercent); - possibleValues.push(plan?.paypalHostFeeSharePercent); // deprecated - } + // Used by 1st party hosts to set Stripe and PayPal (aka "Crowfunding") share percent to zero + // Ideally, this will not be used in the future as we'll always rely on the platformTipEligible flag to do that + // We still have a lot of old orders were platformTipEligible is not set, so we'll keep that configuration for now + + // Assign different fees based on the payment provider + if (order.paymentMethod?.service === PAYMENT_METHOD_SERVICE.STRIPE) { + possibleValues.push(host.data?.stripeHostFeeSharePercent); + possibleValues.push(plan?.stripeHostFeeSharePercent); // deprecated + } else if (order.paymentMethod?.service === PAYMENT_METHOD_SERVICE.PAYPAL) { + possibleValues.push(host.data?.paypalHostFeeSharePercent); + possibleValues.push(plan?.paypalHostFeeSharePercent); // deprecated } // Default diff --git a/server/lib/transactions.ts b/server/lib/transactions.ts index d507ddb4c62..682478d0ac5 100644 --- a/server/lib/transactions.ts +++ b/server/lib/transactions.ts @@ -10,8 +10,10 @@ import { TransactionTypes } from '../constants/transactions'; import { toNegative } from '../lib/math'; import { exportToCSV, sumByWhen } from '../lib/utils'; import models, { Op, sequelize } from '../models'; +import Collective from '../models/Collective'; +import Expense from '../models/Expense'; import Tier from '../models/Tier'; -import { TransactionInterface } from '../models/Transaction'; +import { TransactionData, TransactionInterface } from '../models/Transaction'; import { getFxRate } from './currency'; @@ -116,7 +118,7 @@ export const getPaidTaxTransactions = async ( * From a payout provider response, compute all amounts and FX rates in their proper currency */ const computeExpenseAmounts = async ( - expense, + expense: Expense, hostCurrency: string, expenseToHostFxRate: number, fees: FEES_IN_HOST_CURRENCY, @@ -179,13 +181,13 @@ const computeExpenseAmounts = async ( * in case it differs from expense.amount (e.g. when fees are put on the payee) */ export async function createTransactionsFromPaidExpense( - host, - expense, + host: Collective, + expense: Expense, fees: FEES_IN_HOST_CURRENCY = DEFAULT_FEES, /** Set this to a different value if the expense was paid in a currency that differs form the host's */ expenseToHostFxRateConfig: number | 'auto', /** Will be stored in transaction.data */ - transactionData: Record & { clearedAt?: Date } = null, + transactionData: TransactionData & { clearedAt?: Date } = null, ) { fees = { ...DEFAULT_FEES, ...fees }; if (!expense.collective) { @@ -198,7 +200,7 @@ export async function createTransactionsFromPaidExpense( ? await getFxRate(expense.currency, host.currency, new Date()) : expenseToHostFxRateConfig; - const expenseDataForTransaction: Record = { expenseToHostFxRate }; + const expenseDataForTransaction: TransactionData = { expenseToHostFxRate }; if (expense.data?.taxes?.length) { expenseDataForTransaction['tax'] = { ...expense.data.taxes[0], @@ -259,12 +261,12 @@ export async function createTransactionsFromPaidExpense( } export async function createTransactionsForManuallyPaidExpense( - host, - expense, - paymentProcessorFeeInHostCurrency, - totalAmountPaidInHostCurrency, + host: Collective, + expense: Expense, + paymentProcessorFeeInHostCurrency: number, + totalAmountPaidInHostCurrency: number, /** Will be stored in transaction.data */ - transactionData: Record & { clearedAt?: Date } = {}, + transactionData: TransactionData & { clearedAt?: Date } = {}, ) { assert(paymentProcessorFeeInHostCurrency >= 0, 'Payment processor fee must be positive'); assert(totalAmountPaidInHostCurrency > 0, 'Total amount paid must be positive'); @@ -275,7 +277,7 @@ export async function createTransactionsForManuallyPaidExpense( // Values are already adjusted to negative DEBIT values const isCoveredByPayee = expense.feesPayer === 'PAYEE'; - const taxRate = get(expense.data, 'taxes.0.rate') || 0; + const taxRate = (get(expense.data, 'taxes.0.rate') as number | null) || 0; const grossPaidAmountWithTaxes = toNegative(totalAmountPaidInHostCurrency - paymentProcessorFeeInHostCurrency); const grossPaidAmount = Math.round(grossPaidAmountWithTaxes / (1 + taxRate)); const taxAmountInHostCurrency = grossPaidAmountWithTaxes - grossPaidAmount; @@ -345,7 +347,7 @@ export async function createTransactionsForManuallyPaidExpense( return models.Transaction.createDoubleEntry(transaction); } -const computeExpenseTaxes = (expense): number | null => { +const computeExpenseTaxes = (expense: Expense): number | null => { if (!expense.data?.taxes?.length) { return null; } else { diff --git a/server/models/Collective.ts b/server/models/Collective.ts index 1a3524d6d86..fb6bb1a936b 100644 --- a/server/models/Collective.ts +++ b/server/models/Collective.ts @@ -169,6 +169,7 @@ type Settings = { payoutsTwoFactorAuth?: { enabled?: boolean; }; + customEmailMessage?: string; } & TaxSettings; type Data = Partial<{ diff --git a/server/models/Expense.ts b/server/models/Expense.ts index e47ba7c7071..e9a8e1ed67e 100644 --- a/server/models/Expense.ts +++ b/server/models/Expense.ts @@ -1,3 +1,4 @@ +import { TaxType } from '@opencollective/taxes'; import { get, isEmpty, pick, sumBy } from 'lodash'; import { isMoment } from 'moment'; import { @@ -34,7 +35,7 @@ import Activity from './Activity'; import Collective from './Collective'; import ExpenseAttachedFile from './ExpenseAttachedFile'; import ExpenseItem from './ExpenseItem'; -import PaymentMethod from './PaymentMethod'; +import { PaymentMethodModelInterface } from './PaymentMethod'; import PayoutMethod, { PayoutMethodTypes } from './PayoutMethod'; import RecurringExpense from './RecurringExpense'; import Transaction, { TransactionInterface } from './Transaction'; @@ -55,6 +56,14 @@ export type ExpenseDataValuesByRole = { submitter?: ExpenseDataValuesRoleDetails; }; +export type ExpenseTaxDefinition = { + id?: TaxType | `${TaxType}`; // deprecated + type: TaxType | `${TaxType}`; + rate: number; + percentage?: number; // deprecated, https://github.com/opencollective/opencollective/issues/5389 + idNumber?: string; +}; + class Expense extends Model, InferCreationAttributes> { public declare readonly id: CreationOptional; public declare UserId: ForeignKey; @@ -68,7 +77,7 @@ class Expense extends Model, InferCreationAttributes; public declare AccountingCategoryId: ForeignKey; - public declare payeeLocation: Location; // TODO This can be typed + public declare payeeLocation: Location; public declare data: Record & { batchGroup?: BatchGroup; quote?: ExpenseDataQuoteV2 | ExpenseDataQuoteV3; @@ -80,6 +89,7 @@ class Expense extends Model, InferCreationAttributes, InferCreationAttributes, InferCreationAttributes; declare getItems: HasManyGetAssociationsMixin; declare getPayoutMethod: BelongsToGetAssociationMixin; - declare getPaymentMethod: BelongsToGetAssociationMixin; + declare getPaymentMethod: BelongsToGetAssociationMixin; declare getRecurringExpense: BelongsToGetAssociationMixin; declare getTransactions: HasManyGetAssociationsMixin; declare getVirtualCard: BelongsToGetAssociationMixin; declare getAccountingCategory: BelongsToGetAssociationMixin; // Association setters - declare setPaymentMethod: BelongsToSetAssociationMixin; + declare setPaymentMethod: BelongsToSetAssociationMixin; /** * Instance Methods @@ -338,11 +348,11 @@ class Expense extends Model, InferCreationAttributes & { category: string; - taxes: Array<{ type: string; rate: number; idNumber: string }>; + taxes: ExpenseTaxDefinition[]; grossAmount: number; } > { - const taxes = get(this.data, 'taxes', []) as Array<{ type: string; rate: number; idNumber: string }>; + const taxes = get(this.data, 'taxes', []) as ExpenseTaxDefinition[]; return { type: this.type, id: this.id, @@ -617,14 +627,7 @@ class Expense extends Model, InferCreationAttributes expense.verify(user))); }; - static computeTotalAmountForExpense = ( - items: Partial[], - taxes: { - type: string; - rate: number; - idNumber: string; - }[], - ): number => { + static computeTotalAmountForExpense = (items: Partial[], taxes: ExpenseTaxDefinition[]): number => { return Math.round( sumBy(items, item => { const amountInCents = Math.round(item.amount * (item.expenseCurrencyFxRate || 1)); diff --git a/server/models/Order.ts b/server/models/Order.ts index 6ba6a11d98c..f01ba7a5a02 100644 --- a/server/models/Order.ts +++ b/server/models/Order.ts @@ -54,10 +54,12 @@ interface OrderModelStaticInterface { } export type OrderTax = { - id: TaxType; + id: TaxType | `${TaxType}`; percentage: number; taxedCountry: string; taxerCountry: string; + taxIDNumber?: string; + taxIDNumberFrom?: string; }; export interface OrderModelInterface @@ -83,7 +85,7 @@ export interface OrderModelInterface tier?: Tier; /** @deprecated: We're using both `tier` and `Tier` depending on the places. The association is defined as `Tier` (uppercase). We should consolidate to one or the other. */ Tier?: Tier; - getTier: Promise; + getTier(): Promise; quantity: number; currency: SupportedCurrency; @@ -134,6 +136,8 @@ export interface OrderModelInterface getOrCreateMembers(): Promise<[MemberModelInterface, MemberModelInterface]>; getUser(): Promise; setPaymentMethod(paymentMethodData); + populate(): Promise; + getUserForActivity(): Promise; /** * Similar to what we do in `lockExpense`, this locks an order by setting a special flag in `data` diff --git a/server/models/Transaction.ts b/server/models/Transaction.ts index 86061f407d7..5bfda61d715 100644 --- a/server/models/Transaction.ts +++ b/server/models/Transaction.ts @@ -12,6 +12,7 @@ import { ModelStatic, Transaction as SequelizeTransaction, } from 'sequelize'; +import Stripe from 'stripe'; import { v4 as uuid } from 'uuid'; import activities from '../constants/activities'; @@ -26,16 +27,20 @@ import { import { shouldGenerateTransactionActivities } from '../lib/activities'; import { getFxRate } from '../lib/currency'; import { toNegative } from '../lib/math'; -import { calcFee, getHostFeeSharePercent, getPlatformTip } from '../lib/payments'; +import { calcFee, getHostFeeSharePercent } from '../lib/payments'; import { stripHTML } from '../lib/sanitize-html'; import { reportErrorToSentry } from '../lib/sentry'; import sequelize, { DataTypes, Op } from '../lib/sequelize'; import { exportToCSV, parseToBoolean } from '../lib/utils'; +import type { PaypalCapture, PaypalSale, PaypalTransaction } from '../types/paypal'; +import type { Transfer } from '../types/transferwise'; import Activity from './Activity'; import Collective from './Collective'; import CustomDataTypes from './DataTypes'; -import Order, { OrderModelInterface } from './Order'; +import type Expense from './Expense'; +import type { ExpenseTaxDefinition } from './Expense'; +import Order, { OrderModelInterface, OrderTax } from './Order'; import PaymentMethod, { PaymentMethodModelInterface } from './PaymentMethod'; import PayoutMethod, { PayoutMethodTypes } from './PayoutMethod'; import TransactionSettlement, { TransactionSettlementStatus } from './TransactionSettlement'; @@ -61,6 +66,43 @@ export const MERCHANT_ID_PATHS = { const debug = debugLib('models:Transaction'); +type Tax = OrderTax | ExpenseTaxDefinition; + +export type TransactionData = { + balanceTransaction?: Stripe.BalanceTransaction; + capture?: Partial; + charge?: Stripe.Charge; + dispute?: Stripe.Dispute; + expenseToHostFxRate?: number; + feesPayer?: Expense['feesPayer']; + hasPlatformTip?: boolean; + hostFeeMigration?: string; + hostFeeSharePercent?: number; + hostToPlatformFxRate?: number; + isManual?: boolean; + isPlatformRevenueDirectlyCollected?: boolean; + isRefundedFromOurSystem?: boolean; + oppositeTransactionFeesCurrencyFxRate?: number; + oppositeTransactionHostCurrencyFxRate?: number; + paymentProcessorFeeMigration?: string; + paypalResponse?: Record; + paypalSale?: Partial; + paypalTransaction?: Partial; + platformTip?: number; + platformTipInHostCurrency?: number; + preMigrationData?: TransactionData; + refund?: Stripe.Refund; + refundedFromDoubleTransactionsScript?: boolean; + refundReason?: string; + refundTransactionId?: TransactionInterface['id']; + review?: Stripe.Event; // Why not Stripe.Review? Who knows + tax?: Tax; + taxAmountRemovedFromMigration?: number; + taxMigration?: string; + taxRemovedFromMigration?: Tax; + transfer?: Transfer; +}; + export interface TransactionInterface extends Model, InferCreationAttributes> { id: CreationOptional; @@ -78,7 +120,7 @@ export interface TransactionInterface paymentProcessorFeeInHostCurrency: number | null; platformFeeInHostCurrency: number | null; taxAmount: number | null; - data: Record | null; + data: TransactionData | null; TransactionGroup: string; isRefund: boolean; isDebt: boolean; @@ -168,29 +210,25 @@ interface TransactionModelStaticInterface { assertAmountsLooselyEqual(a: number, b: number, message?: string): void; assertAmountsStrictlyEqual(a: number, b: number, message?: string): void; calculateNetAmountInHostCurrency(transaction: TransactionInterface): number; - validateContributionPayload(payload: Record): void; + validateContributionPayload(payload: TransactionCreationAttributes): void; getPaymentProcessorFeeVendor(service: string): Promise; getTaxVendor(taxId: string): Promise; createActivity( transaction: TransactionInterface, options?: { sequelizeTransaction?: SequelizeTransaction }, ): Promise; - createPlatformTipTransactions( - transaction: TransactionCreationAttributes, - host: Collective, - isDirectlyCollected?: boolean, - ): Promise; - createPlatformTipDebtTransactions( - args: { platformTipTransaction: TransactionInterface }, - host: Collective, - ): Promise; + createPlatformTipDebtTransactions(args: { + platformTipTransaction: TransactionInterface; + transaction: TransactionCreationAttributes; + }): Promise; createPaymentProcessorFeeTransactions( transaction: TransactionCreationAttributes, - data: Record | null, + data?: TransactionData, ): Promise<{ /** The original transaction, potentially modified if a payment processor fees was set */ transaction: TransactionInterface | TransactionCreationAttributes; @@ -199,7 +237,7 @@ interface TransactionModelStaticInterface { } | void>; createTaxTransactions( transaction: TransactionCreationAttributes, - data: Record | null, + data?: TransactionData, ): Promise<{ /** The original transaction, potentially modified if a tax was set */ transaction: TransactionInterface | TransactionCreationAttributes; @@ -208,34 +246,27 @@ interface TransactionModelStaticInterface { } | void>; createHostFeeTransactions( transaction: TransactionCreationAttributes, - host: Collective, - data?: Record, + data?: TransactionData, ): Promise<{ transaction: TransactionInterface | TransactionCreationAttributes; hostFeeTransaction: TransactionInterface; } | void>; - createHostFeeShareTransactions( - params: { - transaction: TransactionCreationAttributes; - hostFeeTransaction: TransactionInterface; - }, - host: Collective, - isDirectlyCollected: boolean, - ): Promise<{ + createHostFeeShareTransactions(params: { + transaction: TransactionCreationAttributes; + hostFeeTransaction: TransactionInterface; + }): Promise<{ hostFeeShareTransaction: TransactionInterface; hostFeeShareDebtTransaction: TransactionInterface; } | void>; createHostFeeShareDebtTransactions(params: { hostFeeShareTransaction: TransactionInterface; }): Promise; - createFromContributionPayload( - transaction: TransactionCreationAttributes, - opts?: { isPlatformRevenueDirectlyCollected?: boolean }, - ): Promise; + createFromContributionPayload(transaction: TransactionCreationAttributes): Promise; validate( transaction: TransactionInterface | TransactionCreationAttributes, opts?: { validateOppositeTransaction?: boolean; oppositeTransaction?: TransactionInterface }, ): void; + fetchHost(transaction: TransactionInterface | TransactionCreationAttributes): Promise; } const Transaction: ModelStatic & TransactionModelStaticInterface = sequelize.define( @@ -568,22 +599,24 @@ Transaction.prototype.paymentMethodProviderCollectiveId = function () { return this.type === 'DEBIT' ? this.CollectiveId : this.FromCollectiveId; }; -Transaction.prototype.getRefundTransaction = function () { +Transaction.prototype.getRefundTransaction = function (): Promise { if (!this.RefundTransactionId) { return null; } return Transaction.findByPk(this.RefundTransactionId); }; -Transaction.prototype.hasPlatformTip = function () { +Transaction.prototype.hasPlatformTip = function (): boolean { return Boolean( - (this.data?.hasPlatformTip || this.data?.isFeesOnTop) && + this.data?.hasPlatformTip && this.kind !== TransactionKind.PLATFORM_TIP && this.kind !== TransactionKind.PLATFORM_TIP_DEBT, ); }; -Transaction.prototype.getRelatedTransaction = function (options) { +Transaction.prototype.getRelatedTransaction = function ( + options: Pick, +): Promise { return Transaction.findOne({ where: { TransactionGroup: this.TransactionGroup, @@ -766,10 +799,7 @@ Transaction.exportCSV = (transactions, collectivesById) => { * and we should move paymentProcessorFee, platformFee, hostFee to the Order model * */ -Transaction.createDoubleEntry = async ( - transaction: TransactionCreationAttributes, - opts, -): Promise => { +Transaction.createDoubleEntry = async (transaction: TransactionCreationAttributes): Promise => { // Force transaction type based on amount sign if (transaction.amount > 0) { transaction.type = CREDIT; @@ -790,9 +820,36 @@ Transaction.createDoubleEntry = async ( transaction.TransactionGroup = transaction.TransactionGroup || uuid(); transaction.hostCurrencyFxRate = transaction.hostCurrencyFxRate || 1; + // Create Platform Tip transaction + if (transaction.data?.platformTip) { + // Separate donation transaction and remove platformTip from the main transaction + const result = await Transaction.createPlatformTipTransactions(transaction); + // Transaction was modified by createPlatformTipTransactions, we get it from the result + if (result && result.transaction) { + transaction = result.transaction; + } + } + + // Create Host Fee Transaction + if (transaction.hostFeeInHostCurrency) { + const result = await Transaction.createHostFeeTransactions(transaction); + if (result) { + if (result.hostFeeTransaction) { + await Transaction.createHostFeeShareTransactions({ + transaction: result.transaction, + hostFeeTransaction: result.hostFeeTransaction, + }); + } + // Transaction was modified by createHostFeeTransaction, we get it from the result + if (result.transaction) { + transaction = result.transaction; + } + } + } + // Create Tax transaction if (transaction.taxAmount && parseToBoolean(config.ledger.separateTaxes) === true) { - const result = await Transaction.createTaxTransactions(transaction, null); + const result = await Transaction.createTaxTransactions(transaction); if (result) { // Transaction was modified by createTaxTransactions, we get it from the result transaction = result.transaction; @@ -804,7 +861,7 @@ Transaction.createDoubleEntry = async ( transaction.paymentProcessorFeeInHostCurrency && parseToBoolean(config.ledger.separatePaymentProcessorFees) === true ) { - const result = await Transaction.createPaymentProcessorFeeTransactions(transaction, null); + const result = await Transaction.createPaymentProcessorFeeTransactions(transaction); if (result) { // Transaction was modified by paymentProcessorFeeTransactions, we get it from the result transaction = result.transaction; @@ -813,7 +870,7 @@ Transaction.createDoubleEntry = async ( // If FromCollectiveId = CollectiveId, we only create one transaction (DEBIT or CREDIT) if (transaction.FromCollectiveId === transaction.CollectiveId) { - return Transaction.create(transaction, opts) as Promise; + return Transaction.create(transaction) as Promise; } if (!isUndefined(transaction.amountInHostCurrency)) { @@ -894,7 +951,7 @@ Transaction.createDoubleEntry = async ( hostFeePercent, ); if (oppositeTransaction.hostFeeInHostCurrency) { - await Transaction.createHostFeeTransactions(oppositeTransaction, fromCollectiveHost); + await Transaction.createHostFeeTransactions(oppositeTransaction); } } } @@ -905,26 +962,32 @@ Transaction.createDoubleEntry = async ( // We first record the negative transaction // and only then we can create the transaction to add money somewhere else if (transaction.type === DEBIT) { - const t = await Transaction.create(transaction, opts); - await Transaction.create(oppositeTransaction, opts); - return t as TransactionInterface; + const t = await Transaction.create(transaction); + await Transaction.create(oppositeTransaction); + return t; } else { - await Transaction.create(oppositeTransaction, opts); - return (await Transaction.create(transaction, opts)) as TransactionInterface; + await Transaction.create(oppositeTransaction); + return Transaction.create(transaction); } }; /** * Record a debt transaction and its associated settlement */ -Transaction.createPlatformTipDebtTransactions = async ( - { platformTipTransaction }: { platformTipTransaction: TransactionInterface }, - host: Collective, -): Promise => { +Transaction.createPlatformTipDebtTransactions = async ({ + platformTipTransaction, + transaction, +}: { + platformTipTransaction: TransactionInterface; + transaction: TransactionCreationAttributes; +}): Promise => { if (platformTipTransaction.type === DEBIT) { throw new Error('createPlatformTipDebtTransactions must be given a CREDIT transaction'); } + // This should be the host of the original transaction + const host = await Transaction.fetchHost(transaction); + // Create debt transaction const platformTipDebtTransactionData = { // Copy base values from the original CREDIT PLATFORM_TIP @@ -970,38 +1033,36 @@ Transaction.createPlatformTipDebtTransactions = async ( * @param {boolean} Whether tip has been collected already (no debt needed) */ Transaction.createPlatformTipTransactions = async ( - transactionData: TransactionCreationAttributes, - host: Collective, - isDirectlyCollected: boolean = false, + transaction: TransactionCreationAttributes, ): Promise => { - const platformTip = getPlatformTip(transactionData); + const platformTip = transaction.data?.platformTip; if (!platformTip) { return; } // amount of the CREDIT should be in the same currency as the original transaction const amount = platformTip; - const currency = transactionData.currency; + const currency = transaction.currency; // amountInHostCurrency of the CREDIT should be in platform currency const hostCurrency = PLATFORM_TIP_TRANSACTION_PROPERTIES.currency; - const hostCurrencyFxRate = await Transaction.getFxRate(currency, hostCurrency, transactionData); + const hostCurrencyFxRate = await Transaction.getFxRate(currency, hostCurrency, transaction); const amountInHostCurrency = Math.round(amount * hostCurrencyFxRate); // we compute the Fx Rate between the original hostCurrency and the platform currency // it might be used later const hostToPlatformFxRate = await Transaction.getFxRate( - transactionData.hostCurrency, + transaction.hostCurrency, PLATFORM_TIP_TRANSACTION_PROPERTIES.currency, - transactionData, + transaction, ); const platformTipTransactionData = { - ...pick(transactionData, [ + ...pick(transaction, [ 'TransactionGroup', 'FromCollectiveId', 'OrderId', @@ -1030,33 +1091,33 @@ Transaction.createPlatformTipTransactions = async ( isDebt: false, data: { hostToPlatformFxRate, - settled: transactionData.data?.settled, }, }; - const platformTipTransaction = (await Transaction.createDoubleEntry( - platformTipTransactionData, - )) as TransactionInterface; + const platformTipTransaction = await Transaction.createDoubleEntry(platformTipTransactionData); let platformTipDebtTransaction; - if (!isDirectlyCollected) { - platformTipDebtTransaction = await Transaction.createPlatformTipDebtTransactions({ platformTipTransaction }, host); + if (!transaction.data.isPlatformRevenueDirectlyCollected) { + platformTipDebtTransaction = await Transaction.createPlatformTipDebtTransactions({ + platformTipTransaction, + transaction, + }); } // If we have platformTipInHostCurrency available, we trust it, otherwise we compute it const platformTipInHostCurrency = - transactionData.data?.platformTipInHostCurrency || - Math.round(platformTip * transactionData.hostCurrencyFxRate); + transaction.data?.platformTipInHostCurrency || Math.round(platformTip * transaction.hostCurrencyFxRate); // Recalculate amount - transactionData.amountInHostCurrency = Math.round(transactionData.amountInHostCurrency - platformTipInHostCurrency); - transactionData.amount = Math.round(transactionData.amount - platformTip); + transaction.amountInHostCurrency = Math.round(transaction.amountInHostCurrency - platformTipInHostCurrency); + transaction.amount = Math.round(transaction.amount - platformTip); // Reset the platformFee because we're accounting for this value in a separate set of transactions // This way of passing tips is deprecated but still used in some older tests - transactionData.platformFeeInHostCurrency = 0; + transaction.platformFeeInHostCurrency = 0; + transaction.netAmountInCollectiveCurrency = Transaction.calculateNetAmountInCollectiveCurrency(transaction); - return { transaction: transactionData, platformTipTransaction, platformTipDebtTransaction }; + return { transaction, platformTipTransaction, platformTipDebtTransaction }; }; Transaction.validateContributionPayload = (payload: TransactionCreationAttributes): void => { @@ -1087,13 +1148,14 @@ Transaction.validateContributionPayload = (payload: TransactionCreationAttribute Transaction.createHostFeeTransactions = async ( transaction: TransactionCreationAttributes, - host: Collective, - data, + data?: TransactionData, ): Promise<{ transaction: TransactionCreationAttributes; hostFeeTransaction: TransactionInterface } | void> => { if (!transaction.hostFeeInHostCurrency) { return; } + const host = await Transaction.fetchHost(transaction); + // The reference value is currently passed as "hostFeeInHostCurrency" const amountInHostCurrency = Math.abs(transaction.hostFeeInHostCurrency); const hostCurrency = transaction.hostCurrency; @@ -1132,6 +1194,7 @@ Transaction.createHostFeeTransactions = async ( // Reset the original host fee because we're now accounting for this value in a separate set of transactions transaction.hostFeeInHostCurrency = 0; + transaction.netAmountInCollectiveCurrency = Transaction.calculateNetAmountInCollectiveCurrency(transaction); return { transaction, hostFeeTransaction }; }; @@ -1240,10 +1303,6 @@ Transaction.getTaxVendor = memoize(async (taxId): Promise => { return Collective.findBySlug(vendorByTaxId[taxId] || vendorByTaxId['OTHER']); }); -interface Tax { - id?: string; -} - /** * For contributions, the contributor pays the full amount then the tax is debited from the collective balance (to the TAX vendor) * For expenses, the collective pays the full amount to the payee, then the payee is debited from the tax amount (to the TAX vendor) @@ -1336,25 +1395,24 @@ Transaction.createTaxTransactions = async ( return { transaction, taxTransaction }; }; -Transaction.createHostFeeShareTransactions = async ( - { - transaction, - hostFeeTransaction, - }: { - transaction: TransactionInterface | TransactionCreationAttributes; - hostFeeTransaction: TransactionInterface; - }, - host: Collective, - isDirectlyCollected: boolean = false, -): Promise<{ +Transaction.createHostFeeShareTransactions = async ({ + transaction, + hostFeeTransaction, +}: { + transaction: TransactionInterface | TransactionCreationAttributes; + hostFeeTransaction: TransactionInterface; +}): Promise<{ hostFeeShareTransaction: TransactionInterface; hostFeeShareDebtTransaction: TransactionInterface; } | void> => { - let order; - if (transaction.OrderId) { - order = await Order.findByPk(transaction.OrderId); + const host = await Transaction.fetchHost(transaction); + + let hostFeeSharePercent = transaction.data?.hostFeeSharePercent; + if (isNil(hostFeeSharePercent) && transaction.OrderId) { + const order = await Order.findByPk(transaction.OrderId); + hostFeeSharePercent = await getHostFeeSharePercent(order); } - const hostFeeSharePercent = await getHostFeeSharePercent(order, { host }); + if (!hostFeeSharePercent) { return; } @@ -1407,7 +1465,7 @@ Transaction.createHostFeeShareTransactions = async ( const hostFeeShareTransaction = await Transaction.createDoubleEntry(hostFeeShareTransactionData); let hostFeeShareDebtTransaction; - if (!isDirectlyCollected) { + if (!transaction.data.isPlatformRevenueDirectlyCollected) { hostFeeShareDebtTransaction = await Transaction.createHostFeeShareDebtTransactions({ hostFeeShareTransaction }); } @@ -1467,7 +1525,6 @@ Transaction.createHostFeeShareDebtTransactions = async ({ */ Transaction.createFromContributionPayload = async ( transaction: TransactionCreationAttributes, - opts = { isPlatformRevenueDirectlyCollected: false }, ): Promise => { try { Transaction.validateContributionPayload(transaction); @@ -1499,45 +1556,27 @@ Transaction.createFromContributionPayload = async ( transaction.paymentProcessorFeeInHostCurrency = toNegative(transaction.paymentProcessorFeeInHostCurrency) || 0; transaction.taxAmount = toNegative(transaction.taxAmount); - // Separate donation transaction and remove platformTip from the main transaction - const result = await Transaction.createPlatformTipTransactions( - transaction, - host, - Boolean(opts?.isPlatformRevenueDirectlyCollected), - ); - // Transaction was modified by createPlatformTipTransactions, we get it from the result - if (result && result.transaction) { - transaction = result.transaction; - } - - // Create Host Fee transaction - // TODO: move in createDoubleEntry? - if (transaction.hostFeeInHostCurrency) { - const result = await Transaction.createHostFeeTransactions(transaction, host); - if (result) { - if (result.hostFeeTransaction) { - const isAlreadyCollected = Boolean(opts?.isPlatformRevenueDirectlyCollected); - await Transaction.createHostFeeShareTransactions( - { - transaction: result.transaction, - hostFeeTransaction: result.hostFeeTransaction, - }, - host, - isAlreadyCollected, - ); - } - // Transaction was modified by createHostFeeTransaction, we get it from the result - if (result.transaction) { - transaction = result.transaction; - } - } - } - transaction.netAmountInCollectiveCurrency = Transaction.calculateNetAmountInCollectiveCurrency(transaction); return Transaction.createDoubleEntry(transaction); }; +Transaction.fetchHost = async ( + transaction: TransactionInterface | TransactionCreationAttributes, +): Promise => { + let host; + if (transaction.HostCollectiveId) { + host = await Collective.findByPk(transaction.HostCollectiveId); + } + if (!host) { + // throw new Error(`transaction.HostCollectiveId should always bet set`); + console.warn(`transaction.HostCollectiveId should always bet set`); + const collective = await Collective.findByPk(transaction.CollectiveId); + host = await collective.getHostCollective(); + } + return host; +}; + Transaction.createActivity = async ( transaction: TransactionInterface, options: { sequelizeTransaction?: SequelizeTransaction } = {}, @@ -1590,7 +1629,7 @@ Transaction.createActivity = async ( ); }; -Transaction.canHaveFees = function ({ kind }: { kind: TransactionKind }) { +Transaction.canHaveFees = function ({ kind }: { kind: TransactionKind }): boolean { return [CONTRIBUTION, EXPENSE, ADDED_FUNDS].includes(kind); }; @@ -1683,7 +1722,10 @@ Transaction.getFxRate = async function (fromCurrency, toCurrency, transaction) { return getFxRate(fromCurrency, toCurrency, transaction.createdAt || 'latest'); }; -Transaction.updateCurrency = async function (currency: SupportedCurrency, transaction: TransactionInterface) { +Transaction.updateCurrency = async function ( + currency: SupportedCurrency, + transaction: TransactionInterface, +): Promise { // Nothing to do if (currency === transaction.currency) { return transaction; diff --git a/server/paymentProviders/opencollective/collective.js b/server/paymentProviders/opencollective/collective.js index 16ff9820327..be9cdbd078e 100644 --- a/server/paymentProviders/opencollective/collective.js +++ b/server/paymentProviders/opencollective/collective.js @@ -55,7 +55,7 @@ paymentMethodProvider.processOrder = async order => { ); } - const hostFeeSharePercent = await getHostFeeSharePercent(order, { host }); + const hostFeeSharePercent = await getHostFeeSharePercent(order); const isSharedRevenue = !!hostFeeSharePercent; const amount = order.totalAmount; @@ -65,7 +65,7 @@ paymentMethodProvider.processOrder = async order => { const amountInHostCurrency = Math.round(order.totalAmount * hostCurrencyFxRate); // It will be usually zero but it's best to support it - const hostFee = await getHostFee(order, { host }); + const hostFee = await getHostFee(order); const hostFeeInHostCurrency = Math.round(hostFee * hostCurrencyFxRate); const platformTip = getPlatformTip(order); diff --git a/server/paymentProviders/opencollective/host.js b/server/paymentProviders/opencollective/host.js index 5dcc87b63d7..0988f6013f7 100644 --- a/server/paymentProviders/opencollective/host.js +++ b/server/paymentProviders/opencollective/host.js @@ -51,7 +51,7 @@ paymentMethodProvider.processOrder = async (order, options) => { throw new Error('Can only use the Host payment method to Add Funds to an hosted Collective.'); } - const hostFeeSharePercent = await getHostFeeSharePercent(order, { host }); + const hostFeeSharePercent = await getHostFeeSharePercent(order); const isSharedRevenue = !!hostFeeSharePercent; const amount = order.totalAmount; @@ -60,7 +60,7 @@ paymentMethodProvider.processOrder = async (order, options) => { const hostCurrencyFxRate = await getFxRate(currency, hostCurrency); const amountInHostCurrency = amount * hostCurrencyFxRate; - const hostFee = await getHostFee(order, { host }); + const hostFee = await getHostFee(order); const hostFeeInHostCurrency = Math.round(hostFee * hostCurrencyFxRate); const paymentProcessorFee = order.data?.paymentProcessorFee || 0; diff --git a/server/paymentProviders/opencollective/manual.js b/server/paymentProviders/opencollective/manual.js index 27c98eeb0d4..c4d6576949a 100644 --- a/server/paymentProviders/opencollective/manual.js +++ b/server/paymentProviders/opencollective/manual.js @@ -47,7 +47,7 @@ async function processOrder(order) { order.paymentMethod = { service: 'opencollective', type: 'manual' }; } - const hostFeeSharePercent = await getHostFeeSharePercent(order, { host }); + const hostFeeSharePercent = await getHostFeeSharePercent(order); const isSharedRevenue = !!hostFeeSharePercent; const amount = order.totalAmount; @@ -56,10 +56,10 @@ async function processOrder(order) { const hostCurrencyFxRate = await getFxRate(order.currency, hostCurrency); const amountInHostCurrency = Math.round(order.totalAmount * hostCurrencyFxRate); - const hostFee = await getHostFee(order, { host }); + const hostFee = await getHostFee(order); const hostFeeInHostCurrency = Math.round(hostFee * hostCurrencyFxRate); - const platformTipEligible = await isPlatformTipEligible(order, host); + const platformTipEligible = await isPlatformTipEligible(order); const platformTip = getPlatformTip(order); const platformTipInHostCurrency = Math.round(platformTip * hostCurrencyFxRate); @@ -102,14 +102,15 @@ async function processOrder(order) { * There's nothing more to do because it's up to the host/collective to see how * they want to actually refund the money. */ -const refundTransaction = async (transaction, user, _, opts = null) => { - return await createRefundTransaction(transaction, 0, null, user, opts?.TransactionGroup); +const refundTransaction = async (transaction, user, reason) => { + return createRefundTransaction(transaction, 0, { ...transaction.data, refundReason: reason }, user); }; /* Expected API of a Payment Method Type */ export default { features: { recurring: false, + isRecurringManagedExternally: false, waitToCharge: true, // don't process the order automatically. Wait for host to "mark it as paid" }, getBalance, diff --git a/server/paymentProviders/opencollective/prepaid.js b/server/paymentProviders/opencollective/prepaid.js index fbb9d0d124b..f1d830d3f65 100644 --- a/server/paymentProviders/opencollective/prepaid.js +++ b/server/paymentProviders/opencollective/prepaid.js @@ -90,7 +90,7 @@ async function processOrder(order) { throw new Error("This payment method doesn't have enough funds to complete this order"); } - const hostFeeSharePercent = await getHostFeeSharePercent(order, { host }); + const hostFeeSharePercent = await getHostFeeSharePercent(order); const isSharedRevenue = !!hostFeeSharePercent; const amount = order.totalAmount; @@ -99,11 +99,11 @@ async function processOrder(order) { const hostCurrencyFxRate = await getFxRate(currency, hostCurrency); const amountInHostCurrency = Math.round(amount * hostCurrencyFxRate); - const platformTipEligible = await isPlatformTipEligible(order, host); + const platformTipEligible = await isPlatformTipEligible(order); const platformTip = getPlatformTip(order); const platformTipInHostCurrency = Math.round(platformTip * hostCurrencyFxRate); - const hostFee = await getHostFee(order, { host }); + const hostFee = await getHostFee(order); const hostFeeInHostCurrency = Math.round(hostFee * hostCurrencyFxRate); // Use the above payment method to donate to Collective diff --git a/server/paymentProviders/opencollective/test.js b/server/paymentProviders/opencollective/test.js index 0728732e8f4..29c0e2964cb 100644 --- a/server/paymentProviders/opencollective/test.js +++ b/server/paymentProviders/opencollective/test.js @@ -25,7 +25,7 @@ paymentMethodProvider.processOrder = async order => { const host = await order.collective.getHostCollective(); - const hostFeeSharePercent = await getHostFeeSharePercent(order, { host }); + const hostFeeSharePercent = await getHostFeeSharePercent(order); const isSharedRevenue = !!hostFeeSharePercent; const amount = order.totalAmount; @@ -34,11 +34,11 @@ paymentMethodProvider.processOrder = async order => { const hostCurrencyFxRate = await getFxRate(currency, hostCurrency); const amountInHostCurrency = Math.round(amount * hostCurrencyFxRate); - const platformTipEligible = await isPlatformTipEligible(order, host); + const platformTipEligible = await isPlatformTipEligible(order); const platformTip = getPlatformTip(order); const platformTipInHostCurrency = Math.round(platformTip * hostCurrencyFxRate); - const hostFee = await getHostFee(order, { host }); + const hostFee = await getHostFee(order); const hostFeeInHostCurrency = Math.round(hostFee * hostCurrencyFxRate); const transactionPayload = { diff --git a/server/paymentProviders/paypal/payment.ts b/server/paymentProviders/paypal/payment.ts index ec6d44956b3..ec7ca8f3473 100644 --- a/server/paymentProviders/paypal/payment.ts +++ b/server/paymentProviders/paypal/payment.ts @@ -19,6 +19,7 @@ import { OrderModelInterface } from '../../models/Order'; import { TransactionInterface } from '../../models/Transaction'; import User from '../../models/User'; import { PaypalCapture, PaypalSale, PaypalTransaction } from '../../types/paypal'; +import { PaymentProviderService } from '../types'; import { paypalRequestV2 } from './api'; @@ -40,17 +41,17 @@ const recordTransaction = async ( throw new Error(`Cannot create transaction: collective id ${order.collective.id} doesn't have a host`); } const hostCurrency = host.currency; - const hostFeeSharePercent = await getHostFeeSharePercent(order, { host }); + const hostFeeSharePercent = await getHostFeeSharePercent(order); const isSharedRevenue = !!hostFeeSharePercent; const hostCurrencyFxRate = await getFxRate(currency, hostCurrency); const amountInHostCurrency = Math.round(amount * hostCurrencyFxRate); const paymentProcessorFeeInHostCurrency = Math.round(hostCurrencyFxRate * paypalFee); - const hostFee = await getHostFee(order, { host }); + const hostFee = await getHostFee(order); const hostFeeInHostCurrency = Math.round(hostFee * hostCurrencyFxRate); - const platformTipEligible = await isPlatformTipEligible(order, host); + const platformTipEligible = await isPlatformTipEligible(order); const platformTip = getPlatformTip(order); const platformTipInHostCurrency = Math.round(platformTip * hostCurrencyFxRate); @@ -303,4 +304,4 @@ export default { processOrder, refundTransaction: refundPaypalPaymentTransaction, refundTransactionOnlyInDatabase, -}; +} as PaymentProviderService; diff --git a/server/paymentProviders/paypal/webhook.ts b/server/paymentProviders/paypal/webhook.ts index 75b8f9f9762..fdae0d6a905 100644 --- a/server/paymentProviders/paypal/webhook.ts +++ b/server/paymentProviders/paypal/webhook.ts @@ -15,7 +15,7 @@ import { validateWebhookEvent } from '../../lib/paypal'; import { sendThankYouEmail } from '../../lib/recurring-contributions'; import { reportErrorToSentry, reportMessageToSentry } from '../../lib/sentry'; import models, { Op } from '../../models'; -import { PayoutWebhookRequest } from '../../types/paypal'; +import { PayoutWebhookRequest, PaypalCapture } from '../../types/paypal'; import { paypalRequestV2 } from './api'; import { findTransactionByPaypalId, recordPaypalCapture, recordPaypalSale } from './payment'; @@ -239,7 +239,7 @@ async function handleCaptureRefunded(req: Request): Promise { const refundLinks = []>refundDetails.links; const captureLink = refundLinks.find(l => l.rel === 'up' && l.method === 'GET'); const capturePath = captureLink.href.replace(/^.+\/v2\//, ''); // https://api.sandbox.paypal.com/v2/payments/captures/... -> payments/captures/... - const captureDetails = await paypalRequestV2(capturePath, host, 'GET'); + const captureDetails = (await paypalRequestV2(capturePath, host, 'GET')) as PaypalCapture; // Load associated transaction, make sure they're not refunded already const transaction = await models.Transaction.findOne({ diff --git a/server/paymentProviders/stripe/bacsdebit.ts b/server/paymentProviders/stripe/bacsdebit.ts index f47bf266f8c..302b8798e00 100644 --- a/server/paymentProviders/stripe/bacsdebit.ts +++ b/server/paymentProviders/stripe/bacsdebit.ts @@ -8,6 +8,7 @@ import { getApplicationFee } from '../../lib/payments'; import { reportMessageToSentry } from '../../lib/sentry'; import stripe, { convertToStripeAmount } from '../../lib/stripe'; import { OrderModelInterface } from '../../models/Order'; +import { PaymentProviderService } from '../types'; import { APPLICATION_FEE_INCOMPATIBLE_CURRENCIES, refundTransaction, refundTransactionOnlyInDatabase } from './common'; @@ -22,7 +23,7 @@ const processOrder = async (order: OrderModelInterface): Promise => { host && APPLICATION_FEE_INCOMPATIBLE_CURRENCIES.includes(toUpper(host.currency)) ? false : host?.settings?.isPlatformRevenueDirectlyCollected ?? true; - const applicationFee = await getApplicationFee(order, { host }); + const applicationFee = await getApplicationFee(order); const paymentIntentParams: Stripe.PaymentIntentCreateParams = { customer: order.paymentMethod.customerId, @@ -87,9 +88,10 @@ const processOrder = async (order: OrderModelInterface): Promise => { export default { features: { recurring: true, + isRecurringManagedExternally: false, waitToCharge: false, }, processOrder, refundTransaction, refundTransactionOnlyInDatabase, -}; +} as PaymentProviderService; diff --git a/server/paymentProviders/stripe/bancontact.ts b/server/paymentProviders/stripe/bancontact.ts index 169641e6319..83ad2b296cc 100644 --- a/server/paymentProviders/stripe/bancontact.ts +++ b/server/paymentProviders/stripe/bancontact.ts @@ -8,6 +8,7 @@ import { getApplicationFee } from '../../lib/payments'; import { reportMessageToSentry } from '../../lib/sentry'; import stripe, { convertToStripeAmount } from '../../lib/stripe'; import { OrderModelInterface } from '../../models/Order'; +import { PaymentProviderService } from '../types'; import { APPLICATION_FEE_INCOMPATIBLE_CURRENCIES, refundTransaction, refundTransactionOnlyInDatabase } from './common'; @@ -29,7 +30,7 @@ const processOrder = async (order: OrderModelInterface): Promise => { host && APPLICATION_FEE_INCOMPATIBLE_CURRENCIES.includes(toUpper(host.currency)) ? false : host?.settings?.isPlatformRevenueDirectlyCollected ?? true; - const applicationFee = await getApplicationFee(order, { host }); + const applicationFee = await getApplicationFee(order); const paymentIntentParams: Stripe.PaymentIntentCreateParams = { customer: order.paymentMethod.customerId, @@ -89,9 +90,10 @@ const processOrder = async (order: OrderModelInterface): Promise => { export default { features: { recurring: true, + isRecurringManagedExternally: false, waitToCharge: false, }, processOrder, refundTransaction, refundTransactionOnlyInDatabase, -}; +} as PaymentProviderService; diff --git a/server/paymentProviders/stripe/common.ts b/server/paymentProviders/stripe/common.ts index 0a2b082cc1f..7c61fdd4bab 100644 --- a/server/paymentProviders/stripe/common.ts +++ b/server/paymentProviders/stripe/common.ts @@ -22,7 +22,7 @@ import stripe, { convertFromStripeAmount, extractFees, retrieveChargeWithRefund import models, { Collective, ConnectedAccount } from '../../models'; import { OrderModelInterface } from '../../models/Order'; import PaymentMethod, { PaymentMethodModelInterface } from '../../models/PaymentMethod'; -import { TransactionInterface } from '../../models/Transaction'; +import { TransactionCreationAttributes, TransactionData, TransactionInterface } from '../../models/Transaction'; import User from '../../models/User'; export const APPLICATION_FEE_INCOMPATIBLE_CURRENCIES = ['BRL']; @@ -30,8 +30,8 @@ export const APPLICATION_FEE_INCOMPATIBLE_CURRENCIES = ['BRL']; /** Refund a given transaction */ export const refundTransaction = async ( transaction: TransactionInterface, - user: User, - options?: { checkRefundStatus: boolean }, + user?: User, + reason?: string, ): Promise => { /* What's going to be refunded */ const chargeId: string = result(transaction.data, 'charge.id'); @@ -53,11 +53,6 @@ export const refundTransaction = async ( { stripeAccount: hostStripeAccount.username }, ); - if (options?.checkRefundStatus && refund.status !== 'succeeded') { - await transaction.update({ data: { ...transaction.data, refund } }); - return null; - } - const charge = await stripe.charges.retrieve(chargeId, { stripeAccount: hostStripeAccount.username }); const refundBalance = await stripe.balanceTransactions.retrieve(refund.balance_transaction as string, { stripeAccount: hostStripeAccount.username, @@ -73,6 +68,7 @@ export const refundTransaction = async ( refund, balanceTransaction: refundBalance, // TODO: This is overwriting the original balanceTransaction with the refund balance transaction, which remove important info charge, + refundReason: reason, }, user, ); @@ -83,7 +79,8 @@ export const refundTransaction = async ( */ export const refundTransactionOnlyInDatabase = async ( transaction: TransactionInterface, - user: User, + user?: User, + reason?: string, ): Promise => { /* What's going to be refunded */ const chargeId = result(transaction.data, 'charge.id'); @@ -111,7 +108,7 @@ export const refundTransactionOnlyInDatabase = async ( return await createRefundTransaction( transaction, refund ? fees.stripeFee : 0, // With disputes, we get 1500 as a value but will not handle this - { ...transaction.data, charge, refund, balanceTransaction: refundBalance }, + { ...transaction.data, charge, refund, balanceTransaction: refundBalance, refundReason: reason }, user, ); }; @@ -131,7 +128,7 @@ export const createChargeTransactions = async ( ? false : host?.settings?.isPlatformRevenueDirectlyCollected ?? true; - const hostFeeSharePercent = await getHostFeeSharePercent(order, { host }); + const hostFeeSharePercent = await getHostFeeSharePercent(order); const isSharedRevenue = !!hostFeeSharePercent; const balanceTransaction = await stripe.balanceTransactions.retrieve(charge.balance_transaction as string, { stripeAccount: hostStripeAccount.username, @@ -148,12 +145,12 @@ export const createChargeTransactions = async ( const amountInHostCurrency = convertFromStripeAmount(balanceTransaction.currency, balanceTransaction.amount); const hostCurrencyFxRate = amountInHostCurrency / order.totalAmount; - const hostFee = await getHostFee(order, { host }); + const hostFee = await getHostFee(order); const hostFeeInHostCurrency = Math.round(hostFee * hostCurrencyFxRate); const fees = extractFees(balanceTransaction, balanceTransaction.currency); - const platformTipEligible = await isPlatformTipEligible(order, host); + const platformTipEligible = await isPlatformTipEligible(order); const platformTip = getPlatformTip(order); let platformTipInHostCurrency, platformFeeInHostCurrency; @@ -180,9 +177,9 @@ export const createChargeTransactions = async ( platformTip, platformTipInHostCurrency, hostFeeSharePercent, - settled: true, tax: order.data?.tax, - }; + isPlatformRevenueDirectlyCollected, + } as TransactionData; const transactionPayload = { CreatedByUserId: order.CreatedByUserId, @@ -193,21 +190,19 @@ export const createChargeTransactions = async ( OrderId: order.id, amount, currency, - hostCurrency, amountInHostCurrency, + hostCurrency, hostCurrencyFxRate, paymentProcessorFeeInHostCurrency, + hostFeeInHostCurrency, platformFeeInHostCurrency, taxAmount: order.taxAmount, description: order.description, - hostFeeInHostCurrency, data, clearedAt, - }; + } as TransactionCreationAttributes; - return models.Transaction.createFromContributionPayload(transactionPayload, { - isPlatformRevenueDirectlyCollected, - }); + return models.Transaction.createFromContributionPayload(transactionPayload); }; /** diff --git a/server/paymentProviders/stripe/creditcard.ts b/server/paymentProviders/stripe/creditcard.ts index 25f0495ce9f..4155f054e72 100644 --- a/server/paymentProviders/stripe/creditcard.ts +++ b/server/paymentProviders/stripe/creditcard.ts @@ -10,6 +10,7 @@ import { Collective } from '../../models'; import { OrderModelInterface } from '../../models/Order'; import { PaymentMethodModelInterface } from '../../models/PaymentMethod'; import User from '../../models/User'; +import { PaymentProviderService } from '../types'; import { APPLICATION_FEE_INCOMPATIBLE_CURRENCIES, @@ -37,7 +38,7 @@ const createChargeAndTransactions = async ( : host?.settings?.isPlatformRevenueDirectlyCollected ?? true; // Compute Application Fee (Shared Revenue + Platform Tip) - const applicationFee = await getApplicationFee(order, { host }); + const applicationFee = await getApplicationFee(order); // Make sure data is available (breaking in some old tests) order.data = order.data || {}; @@ -158,6 +159,7 @@ export const setupCreditCard = async ( export default { features: { recurring: true, + isRecurringManagedExternally: false, waitToCharge: false, }, @@ -243,4 +245,4 @@ export default { refundTransaction, refundTransactionOnlyInDatabase, -}; +} as PaymentProviderService; diff --git a/server/paymentProviders/stripe/payment-intent.ts b/server/paymentProviders/stripe/payment-intent.ts index 5095fe13418..2ac93629ba0 100644 --- a/server/paymentProviders/stripe/payment-intent.ts +++ b/server/paymentProviders/stripe/payment-intent.ts @@ -9,6 +9,7 @@ import { reportMessageToSentry } from '../../lib/sentry'; import stripe, { convertToStripeAmount } from '../../lib/stripe'; import models from '../../models'; import { OrderModelInterface } from '../../models/Order'; +import { PaymentProviderService } from '../types'; import { APPLICATION_FEE_INCOMPATIBLE_CURRENCIES, refundTransaction, refundTransactionOnlyInDatabase } from './common'; @@ -27,7 +28,7 @@ async function processNewOrder(order: OrderModelInterface) { host && APPLICATION_FEE_INCOMPATIBLE_CURRENCIES.includes(toUpper(host.currency)) ? false : host?.settings?.isPlatformRevenueDirectlyCollected ?? true; - const applicationFee = await getApplicationFee(order, { host }); + const applicationFee = await getApplicationFee(order); const paymentIntentParams: Stripe.PaymentIntentUpdateParams = { currency: order.currency, amount: convertToStripeAmount(order.currency, order.totalAmount), @@ -95,7 +96,7 @@ async function processRecurringOrder(order: OrderModelInterface) { host && APPLICATION_FEE_INCOMPATIBLE_CURRENCIES.includes(toUpper(host.currency)) ? false : host?.settings?.isPlatformRevenueDirectlyCollected ?? true; - const applicationFee = await getApplicationFee(order, { host }); + const applicationFee = await getApplicationFee(order); const paymentIntentParams: Stripe.PaymentIntentCreateParams = { currency: order.currency, amount: convertToStripeAmount(order.currency, order.totalAmount), @@ -154,9 +155,10 @@ async function processRecurringOrder(order: OrderModelInterface) { export default { features: { recurring: true, + isRecurringManagedExternally: false, waitToCharge: false, }, processOrder, refundTransaction, refundTransactionOnlyInDatabase, -}; +} as PaymentProviderService; diff --git a/server/paymentProviders/types.ts b/server/paymentProviders/types.ts index be8315ce8b7..6b6002f0fae 100644 --- a/server/paymentProviders/types.ts +++ b/server/paymentProviders/types.ts @@ -1,4 +1,6 @@ +import { SupportedCurrency } from '../constants/currencies'; import { OrderModelInterface } from '../models/Order'; +import { PaymentMethodModelInterface } from '../models/PaymentMethod'; import { TransactionInterface } from '../models/Transaction'; import User from '../models/User'; import VirtualCardModel from '../models/VirtualCard'; @@ -27,21 +29,30 @@ export interface PaymentProviderService { /** * Triggers the payment for this order and updates it accordingly */ - processOrder(order: OrderModelInterface): Promise; + processOrder( + order: OrderModelInterface, + options?: { isAddedFund?: boolean; invoiceTemplate?: string }, + ): Promise; /** * Refunds a transaction processed with this payment provider service */ - refundTransaction(transaction: TransactionInterface, user: User, reason?: string): Promise; + refundTransaction(transaction: TransactionInterface, user?: User, reason?: string): Promise; /** * Refunds a transaction processed with this payment provider service without calling the payment provider */ - refundTransactionOnlyInDatabase( + refundTransactionOnlyInDatabase?( transaction: TransactionInterface, - user: User, + user?: User, reason?: string, ): Promise; + + getBalance?: ( + paymentMethod: PaymentMethodModelInterface, + ) => Promise; + + updateBalance?: (paymentMethod: PaymentMethodModelInterface) => Promise; } export interface CardProviderService { diff --git a/test/cron/monthly/host-settlement.test.js b/test/cron/monthly/host-settlement.test.js index bccfdc626e0..b2f1a12eef8 100644 --- a/test/cron/monthly/host-settlement.test.js +++ b/test/cron/monthly/host-settlement.test.js @@ -4,7 +4,7 @@ import sinon, { useFakeTimers } from 'sinon'; import { run as invoicePlatformFees } from '../../../cron/monthly/host-settlement'; import { TransactionKind } from '../../../server/constants/transaction-kind'; -import { refundTransaction } from '../../../server/lib/payments'; +import { createRefundTransaction } from '../../../server/lib/payments'; import { getTaxesSummary } from '../../../server/lib/transactions'; import models, { sequelize } from '../../../server/models'; import { @@ -71,6 +71,9 @@ describe('cron/monthly/host-settlement', () => { hostCurrency: 'GBP', HostCollectiveId: gbpHost.id, createdAt: lastMonth, + data: { + hostFeeSharePercent: 15, + }, }; // Create Contributions const contribution1 = await fakeTransaction({ @@ -115,17 +118,16 @@ describe('cron/monthly/host-settlement', () => { // Create host fee share const hostFeeResults = await Promise.all( [contribution1, contribution2, contribution3, unsettledRefundedContribution, settledRefundedContribution].map( - transaction => models.Transaction.createHostFeeTransactions(transaction, gbpHost), + transaction => models.Transaction.createHostFeeTransactions(transaction), ), ); await Promise.all( hostFeeResults.map(({ transaction, hostFeeTransaction }) => - models.Transaction.createHostFeeShareTransactions( - { transaction: transaction, hostFeeTransaction: hostFeeTransaction }, - gbpHost, - false, - ), + models.Transaction.createHostFeeShareTransactions({ + transaction: transaction, + hostFeeTransaction: hostFeeTransaction, + }), ), ); @@ -191,8 +193,8 @@ describe('cron/monthly/host-settlement', () => { // Refund contributions that must be let clock = sinon.useFakeTimers(moment(lastMonth).add(1, 'day').toDate()); - await refundTransaction(unsettledRefundedContribution, user, null, { TransactionGroup: fakeUUID('00000008') }); - await refundTransaction(settledRefundedContribution, user, null, { TransactionGroup: fakeUUID('00000009') }); + await createRefundTransaction(unsettledRefundedContribution, 0, null, user, fakeUUID('00000008')); + await createRefundTransaction(settledRefundedContribution, 0, null, user, fakeUUID('00000009')); clock.restore(); // ---- EUR Host ---- diff --git a/test/server/graphql/v2/collection/TransactionCollectionQuery.test.ts b/test/server/graphql/v2/collection/TransactionCollectionQuery.test.ts index 2d43d441f5d..37732882ad9 100644 --- a/test/server/graphql/v2/collection/TransactionCollectionQuery.test.ts +++ b/test/server/graphql/v2/collection/TransactionCollectionQuery.test.ts @@ -1,5 +1,6 @@ import { expect } from 'chai'; import gql from 'fake-tag'; +import type Stripe from 'stripe'; import { PAYMENT_METHOD_SERVICE, PAYMENT_METHOD_TYPE } from '../../../../../server/constants/paymentMethods'; import { TransactionKind } from '../../../../../server/constants/transaction-kind'; @@ -134,7 +135,7 @@ describe('server/graphql/v2/collection/TransactionCollection', () => { kind: TransactionKind.CONTRIBUTION, amount: -15000, PaymentMethodId: creditCardPm.id, - data: { charge: { id: 'ch_123' } }, + data: { charge: { id: 'ch_123' } as Stripe.Charge }, OrderId: order.id, }), fakeTransaction({ diff --git a/test/server/graphql/v2/mutation/TransactionMutations.test.ts b/test/server/graphql/v2/mutation/TransactionMutations.test.ts index 51dfdc97dea..7dde96b452e 100644 --- a/test/server/graphql/v2/mutation/TransactionMutations.test.ts +++ b/test/server/graphql/v2/mutation/TransactionMutations.test.ts @@ -2,6 +2,7 @@ import { expect } from 'chai'; import gql from 'fake-tag'; import nock from 'nock'; import { createSandbox } from 'sinon'; +import Stripe from 'stripe'; import { SupportedCurrency } from '../../../../../server/constants/currencies'; import MemberRoles from '../../../../../server/constants/roles'; @@ -317,7 +318,7 @@ describe('refundTransaction legacy tests', () => { created: 1517834264, currency: currency, customer: 'cus_9sKDFZkPwuFAF8', - }; + } as Stripe.Charge; const balanceTransaction = { id: 'txn_1Bs9EEBYycQg1OMfTR33Y5Xr', object: 'balance_transaction', @@ -331,7 +332,7 @@ describe('refundTransaction legacy tests', () => { net: convertToStripeAmount(currency, 457500), status: 'pending', type: 'charge', - }; + } as Stripe.BalanceTransaction; /* eslint-enable camelcase */ const fees = extractFees(balanceTransaction, balanceTransaction.currency); const transactionPayload = { @@ -344,7 +345,7 @@ describe('refundTransaction legacy tests', () => { amount: order.totalAmount, taxAmount: order.taxAmount, currency: order.currency, - hostCurrency: balanceTransaction.currency, + hostCurrency: balanceTransaction.currency as SupportedCurrency, amountInHostCurrency: convertFromStripeAmount(balanceTransaction.currency, balanceTransaction.amount), hostCurrencyFxRate: order.totalAmount / convertFromStripeAmount(balanceTransaction.currency, balanceTransaction.amount), diff --git a/test/server/lib/payments-legacy.test.js b/test/server/lib/payments-legacy.test.js index e95d4dcdd8c..db086ee45ab 100644 --- a/test/server/lib/payments-legacy.test.js +++ b/test/server/lib/payments-legacy.test.js @@ -41,7 +41,7 @@ const SNAPSHOT_COLUMNS = [ 'description', ]; -describe('server/lib/payments', () => { +describe('server/lib/payments-legacy', () => { let host, user, user2, collective, order, collective2, sandbox, emailSendSpy; before(() => { @@ -426,8 +426,7 @@ describe('server/lib/payments', () => { hostFeeInHostCurrency: 250, paymentProcessorFeeInHostCurrency: 175, description: 'Monthly subscription to Webpack', - platformTipAmount: 500, - data: { charge: { id: 'ch_refunded_charge' } }, + data: { charge: { id: 'ch_refunded_charge' }, platformTip: 500 }, }); // Should have 6 transactions: diff --git a/test/server/lib/payments-legacy.test.js.snap b/test/server/lib/payments-legacy.test.js.snap index 983d1fb94d8..69c5a95b697 100644 --- a/test/server/lib/payments-legacy.test.js.snap +++ b/test/server/lib/payments-legacy.test.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`server/lib/payments createRefundTransaction should not create payment processor fee cover for contribution to the host itself 1`] = ` +exports[`server/lib/payments-legacy createRefundTransaction should not create payment processor fee cover for contribution to the host itself 1`] = ` " | kind | type | isRefund | isDebt | From | To | Host | amount | currency | platformFee | paymentFee | Settlement | description | | ------------ | ------ | -------- | ------ | ---- | ---- | ---- | ------ | -------- | ----------- | ---------- | ---------- | ------------------------------------------- | @@ -10,7 +10,7 @@ exports[`server/lib/payments createRefundTransaction should not create payment p | CONTRIBUTION | CREDIT | true | false | Host | User | NULL | 5000 | USD | 0 | 0 | | Refund of \\"Contribution to Open Collective\\" |" `; -exports[`server/lib/payments createRefundTransaction should refund platform fees on top when refunding original transaction 1`] = ` +exports[`server/lib/payments-legacy createRefundTransaction should refund platform fees on top when refunding original transaction 1`] = ` " | kind | type | isRefund | isDebt | From | To | Host | amount | currency | platformFee | paymentFee | Settlement | description | | ----------------------- | ------ | -------- | ------ | --------------- | --------------- | --------------- | ------ | -------- | ----------- | ---------- | ---------- | ------------------------------------------------------ | diff --git a/test/server/lib/payments.test.js b/test/server/lib/payments.test.js index b852f794569..4ff55681eb2 100644 --- a/test/server/lib/payments.test.js +++ b/test/server/lib/payments.test.js @@ -443,8 +443,7 @@ describe('server/lib/payments', () => { hostFeeInHostCurrency: 250, paymentProcessorFeeInHostCurrency: 175, description: 'Monthly subscription to Webpack', - platformTipAmount: 500, - data: { charge: { id: 'ch_refunded_charge' } }, + data: { charge: { id: 'ch_refunded_charge' }, platformTip: 500 }, }); // Should have 8 transactions: diff --git a/test/server/models/Transaction.test.js b/test/server/models/Transaction.test.js index 07cd4b06cd8..bc8d6c36906 100644 --- a/test/server/models/Transaction.test.js +++ b/test/server/models/Transaction.test.js @@ -232,12 +232,12 @@ describe('server/models/Transaction', () => { currency: 'USD', hostCurrency: 'USD', hostCurrencyFxRate: 1, - platformTipAmount: 1000, hostFeeInHostCurrency: 500, paymentProcessorFeeInHostCurrency: 300, type: 'CREDIT', createdAt: '2015-05-29T07:00:00.000Z', PaymentMethodId: 1, + data: { platformTip: 1000 }, }; const t = await Transaction.createFromContributionPayload(transactionPayload); @@ -278,7 +278,7 @@ describe('server/models/Transaction', () => { createdAt: '2015-05-29T07:00:00.000Z', PaymentMethodId: 1, OrderId: order.id, - platformTipAmount: 1000, + data: { platformTip: 1000 }, }; const createdTransaction = await Transaction.createFromContributionPayload(transactionPayload); @@ -339,13 +339,13 @@ describe('server/models/Transaction', () => { currency: 'EUR', hostCurrency: 'EUR', hostCurrencyFxRate: 1, - platformTipAmount: 1000, hostFeeInHostCurrency: 500, paymentProcessorFeeInHostCurrency: 200, type: 'CREDIT', createdAt: '2015-05-29T07:00:00.000Z', PaymentMethodId: 1, OrderId: order.id, + data: { platformTip: 1000 }, }; await Transaction.createFromContributionPayload(transactionPayload); @@ -396,13 +396,13 @@ describe('server/models/Transaction', () => { currency: 'EUR', hostCurrency: 'EUR', hostCurrencyFxRate: 1, - platformTipAmount: 0, hostFeeInHostCurrency: 500, paymentProcessorFeeInHostCurrency: 200, type: 'CREDIT', createdAt: '2015-05-29T07:00:00.000Z', PaymentMethodId: 1, OrderId: order.id, + data: { platformTip: 0 }, }; await Transaction.createFromContributionPayload(transactionPayload); diff --git a/test/server/paymentProviders/stripe/webhook.test.ts b/test/server/paymentProviders/stripe/webhook.test.ts index ad465774a57..de9f19065c7 100644 --- a/test/server/paymentProviders/stripe/webhook.test.ts +++ b/test/server/paymentProviders/stripe/webhook.test.ts @@ -60,7 +60,9 @@ describe('webhook', () => { CreatedByUserId: user.id, OrderId: order.id, amount: 10, - data: { charge: { id: (stripeMocks.webhook_dispute_created.data.object as Stripe.Dispute).charge } }, + data: { + charge: { id: (stripeMocks.webhook_dispute_created.data.object as Stripe.Dispute).charge } as Stripe.Charge, + }, }, { createDoubleEntry: true }, ); @@ -122,7 +124,9 @@ describe('webhook', () => { OrderId: order.id, HostCollectiveId: collective.id, amount: 10, - data: { charge: { id: (stripeMocks.webhook_dispute_created.data.object as Stripe.Dispute).charge } }, + data: { + charge: { id: (stripeMocks.webhook_dispute_created.data.object as Stripe.Dispute).charge } as Stripe.Charge, + }, }, { createDoubleEntry: true }, ); @@ -270,7 +274,9 @@ describe('webhook', () => { OrderId: order.id, amount: 10, data: { - charge: { payment_intent: (stripeMocks.webhook_review_opened.data.object as Stripe.Review).payment_intent }, + charge: { + payment_intent: (stripeMocks.webhook_review_opened.data.object as Stripe.Review).payment_intent, + } as Stripe.Charge, }, }, { createDoubleEntry: true }, @@ -327,7 +333,9 @@ describe('webhook', () => { OrderId: order.id, amount: 10, data: { - charge: { payment_intent: (stripeMocks.webhook_review_opened.data.object as Stripe.Review).payment_intent }, + charge: { + payment_intent: (stripeMocks.webhook_review_opened.data.object as Stripe.Review).payment_intent, + } as Stripe.Charge, }, }, { createDoubleEntry: true }, diff --git a/test/sql/ban-collectives.sql.test.js b/test/sql/ban-collectives.sql.test.js index a3705fa3c07..822f916f1e4 100644 --- a/test/sql/ban-collectives.sql.test.js +++ b/test/sql/ban-collectives.sql.test.js @@ -44,16 +44,18 @@ const createCollectiveWithData = async () => { PaymentMethodId: null, FromCollectiveId: user.collective.id, CollectiveId: collective.id, + HostCollectiveId: collective.host.id, CreatedByUserId: user.id, data: { platformTip: 100 }, }, { createDoubleEntry: true }, ); - const { platformTipTransaction } = await models.Transaction.createPlatformTipTransactions( - contributionTransaction, - collective.host, - ); + const platformTipTransaction = contributionTransaction.getRelatedTransaction({ + type: 'CREDIT', + kind: 'PLATFORM_TIP', + }); + const hostedTransaction = await fakeTransaction( { type: 'CREDIT', diff --git a/test/stories/ledger-legacy.test.ts b/test/stories/ledger-legacy.test.ts index ca66d4ed06a..ec4e275ccf9 100644 --- a/test/stories/ledger-legacy.test.ts +++ b/test/stories/ledger-legacy.test.ts @@ -624,7 +624,7 @@ describe('test/stories/ledger', () => { }); const paymentMethod = libPayments.findPaymentMethodProvider(order.paymentMethod); - await paymentMethod.refundTransaction(contributionTransaction, 0, null, null); + await paymentMethod.refundTransaction(contributionTransaction); await snapshotLedger(SNAPSHOT_COLUMNS); expect(await collective.getBalance()).to.eq(0); expect(await collective.getTotalAmountReceived()).to.eq(0); @@ -722,7 +722,9 @@ describe('test/stories/ledger', () => { await models.Transaction.update( { - data: { charge: { id: (stripeMocks.webhook_dispute_created.data.object as Stripe.Dispute).charge } }, + data: { + charge: { id: (stripeMocks.webhook_dispute_created.data.object as Stripe.Dispute).charge } as Stripe.Charge, + }, HostCollectiveId: host.id, }, { where: { OrderId: order.id } }, diff --git a/test/stories/ledger.test.ts b/test/stories/ledger.test.ts index ff0be84eda3..ae32be9ed00 100644 --- a/test/stories/ledger.test.ts +++ b/test/stories/ledger.test.ts @@ -730,7 +730,7 @@ describe('test/stories/ledger', () => { }); const paymentMethod = libPayments.findPaymentMethodProvider(order.paymentMethod); - await paymentMethod.refundTransaction(contributionTransaction, 0, null, null); + await paymentMethod.refundTransaction(contributionTransaction); await snapshotLedger(SNAPSHOT_COLUMNS); await sequelize.query(`REFRESH MATERIALIZED VIEW "CollectiveTransactionStats"`); @@ -886,7 +886,9 @@ describe('test/stories/ledger', () => { await models.Transaction.update( { - data: { charge: { id: (stripeMocks.webhook_dispute_created.data.object as Stripe.Dispute).charge } }, + data: { + charge: { id: (stripeMocks.webhook_dispute_created.data.object as Stripe.Dispute).charge } as Stripe.Charge, + }, HostCollectiveId: host.id, }, { where: { OrderId: order.id } },