Skip to content

Commit

Permalink
enhancement(TransactionsImport): Iterate on ON_HOLD status (#10665)
Browse files Browse the repository at this point in the history
  • Loading branch information
Betree authored Jan 30, 2025
1 parent 8bab229 commit e9c99ba
Show file tree
Hide file tree
Showing 8 changed files with 133 additions and 70 deletions.
11 changes: 8 additions & 3 deletions server/graphql/loaders/transactions-import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ type TransactionsImportStats = {
expenses: number;
orders: number;
processed: number;
pending: number;
onHold: number;
invalid: number;
};

export const generateTransactionsImportStatsLoader = () => {
Expand All @@ -21,9 +24,10 @@ export const generateTransactionsImportStatsLoader = () => {
COUNT(row.id) FILTER (WHERE "status" = 'IGNORED' OR "status" = 'LINKED') AS processed,
COUNT(row.id) FILTER (WHERE "status" = 'LINKED' AND "ExpenseId" IS NULL AND "OrderId" IS NULL) AS invalid,
COUNT(row.id) FILTER (WHERE "status" = 'IGNORED') AS ignored,
COUNT(row.id) FILTER (WHERE "status" = 'ON_HOLD') AS on_hold,
COUNT(row.id) FILTER (WHERE "status" = 'ON_HOLD') AS "onHold",
COUNT(row.id) FILTER (WHERE "ExpenseId" IS NOT NULL) AS expenses,
COUNT(row.id) FILTER (WHERE "OrderId" IS NOT NULL) AS orders
COUNT(row.id) FILTER (WHERE "OrderId" IS NOT NULL) AS orders,
COUNT(row.id) FILTER (WHERE "status" = 'PENDING') AS pending
FROM "TransactionsImportsRows" row
WHERE row."TransactionsImportId" IN (:importIds)
GROUP BY row."TransactionsImportId"
Expand All @@ -43,7 +47,8 @@ export const generateTransactionsImportStatsLoader = () => {
expenses: result['expenses'] || 0,
orders: result['orders'] || 0,
processed: result['processed'] || 0,
onHold: result['on_hold'] || 0,
pending: result['pending'] || 0,
onHold: result['onHold'] || 0,
invalid: result['invalid'] || 0,
};
});
Expand Down
48 changes: 30 additions & 18 deletions server/graphql/schemaV2.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -7178,11 +7178,6 @@ type TransactionsImportRow {
"""
sourceId: NonEmptyString!

"""
Whether the row has been dismissed
"""
isDismissed: Boolean! @deprecated(reason: "2025-01-17: isDismissed is deprecated, use status instead")

"""
The status of the row
"""
Expand Down Expand Up @@ -7285,6 +7280,11 @@ type TransactionsImportStats {
"""
onHold: Int!

"""
Number of rows that are pending
"""
pending: Int!

"""
Number of rows that are invalid (e.g. linked but without an expense or order)
"""
Expand Down Expand Up @@ -20862,7 +20862,7 @@ type Mutation {
): TransactionsImport!

"""
Update transactions import rows to set new values or mark them as dismissed
Update transactions import rows to set new values or perform actions on them
"""
updateTransactionsImportRows(
"""
Expand All @@ -20876,15 +20876,10 @@ type Mutation {
rows: [TransactionsImportRowUpdateInput!]

"""
Whether to ignore all non-processed rows
"""
dismissAll: Boolean

"""
Whether to restore all dismissed rows
Action to perform on all non-processed rows
"""
restoreAll: Boolean
): TransactionsImport!
action: TransactionsImportRowAction!
): TransactionsImportEditResponse!

"""
Delete an import and all its associated rows
Expand Down Expand Up @@ -23126,11 +23121,18 @@ input TransactionsImportRowCreateInput {
The raw value of the row
"""
rawValue: JSONObject
}

type TransactionsImportEditResponse {
"""
Updated import
"""
import: TransactionsImport!

"""
Whether the row is dismissed
The rows updated by the mutation
"""
isDismissed: Boolean! = false
rows: [TransactionsImportRow]!
}

input TransactionsImportRowUpdateInput {
Expand Down Expand Up @@ -23160,9 +23162,9 @@ input TransactionsImportRowUpdateInput {
amount: AmountInput

"""
Whether the row is dismissed
To update the status of the row. Will be ignored if the status is not applicable (e.g. trying to ignore a row that is already linked)
"""
isDismissed: Boolean = false
status: TransactionsImportRowStatus

"""
The order associated with the row
Expand All @@ -23180,6 +23182,16 @@ input TransactionsImportRowUpdateInput {
note: String
}

"""
Action to perform on transactions import rows
"""
enum TransactionsImportRowAction {
DISMISS_ALL
RESTORE_ALL
PUT_ON_HOLD_ALL
UPDATE_ROWS
}

"""
Input type for UpdateType
"""
Expand Down
14 changes: 14 additions & 0 deletions server/graphql/v2/enum/TransactionsImportRowAction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { GraphQLEnumType } from 'graphql';

export const TransactionsImportRowActionTypes = [
'DISMISS_ALL',
'RESTORE_ALL',
'PUT_ON_HOLD_ALL',
'UPDATE_ROWS',
] as const;

export const GraphQLTransactionsImportRowAction = new GraphQLEnumType({
name: 'TransactionsImportRowAction',
description: 'Action to perform on transactions import rows',
values: TransactionsImportRowActionTypes.reduce((acc, type) => ({ ...acc, [type]: { value: type } }), {}),
});
7 changes: 1 addition & 6 deletions server/graphql/v2/input/TransactionsImportRowCreateInput.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { GraphQLBoolean, GraphQLInputObjectType, GraphQLNonNull, GraphQLString } from 'graphql';
import { GraphQLInputObjectType, GraphQLNonNull, GraphQLString } from 'graphql';
import { GraphQLDateTime, GraphQLJSONObject, GraphQLNonEmptyString } from 'graphql-scalars';

import { GraphQLAmountInput } from './AmountInput';
Expand Down Expand Up @@ -26,10 +26,5 @@ export const GraphQLTransactionsImportRowCreateInput = new GraphQLInputObjectTyp
type: GraphQLJSONObject,
description: 'The raw value of the row',
},
isDismissed: {
type: new GraphQLNonNull(GraphQLBoolean),
description: 'Whether the row is dismissed',
defaultValue: false,
},
}),
});
14 changes: 8 additions & 6 deletions server/graphql/v2/input/TransactionsImportRowUpdateInput.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { GraphQLBoolean, GraphQLInputObjectType, GraphQLNonNull, GraphQLString } from 'graphql';
import { GraphQLInputObjectType, GraphQLNonNull, GraphQLString } from 'graphql';
import { GraphQLDateTime, GraphQLNonEmptyString } from 'graphql-scalars';

import { GraphQLTransactionsImportRowStatus, TransactionsImportRowStatus } from '../enum/TransactionsImportRowStatus';

import { AmountInputType, GraphQLAmountInput } from './AmountInput';
import { ExpenseReferenceInputFields, GraphQLExpenseReferenceInput } from './ExpenseReferenceInput';
import { GraphQLOrderReferenceInput, OrderReferenceInputGraphQLType } from './OrderReferenceInput';
Expand All @@ -11,7 +13,7 @@ export type TransactionImportRowGraphQLType = {
description?: string | null;
date?: string | null;
amount?: AmountInputType | null;
isDismissed?: boolean | null;
status?: TransactionsImportRowStatus | null;
order?: OrderReferenceInputGraphQLType | null;
expense: ExpenseReferenceInputFields | null;
note?: string | null;
Expand Down Expand Up @@ -40,10 +42,10 @@ export const GraphQLTransactionsImportRowUpdateInput = new GraphQLInputObjectTyp
type: GraphQLAmountInput,
description: 'The amount of the row',
},
isDismissed: {
type: GraphQLBoolean,
description: 'Whether the row is dismissed',
defaultValue: false,
status: {
type: GraphQLTransactionsImportRowStatus,
description:
'To update the status of the row. Will be ignored if the status is not applicable (e.g. trying to ignore a row that is already linked)',
},
order: {
type: GraphQLOrderReferenceInput,
Expand Down
97 changes: 67 additions & 30 deletions server/graphql/v2/mutation/TransactionImportsMutations.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import config from 'config';
import type { Request } from 'express';
import { GraphQLBoolean, GraphQLList, GraphQLNonNull } from 'graphql';
import { GraphQLBoolean, GraphQLList, GraphQLNonNull, GraphQLObjectType } from 'graphql';
import { GraphQLJSONObject, GraphQLNonEmptyString } from 'graphql-scalars';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.js';
import { isNil, omit, pick } from 'lodash';
import { omit, pick } from 'lodash';

import { disconnectPlaidAccount } from '../../../lib/plaid/connect';
import RateLimit from '../../../lib/rate-limit';
Expand All @@ -18,6 +18,10 @@ import {
} from '../../../models';
import { checkRemoteUserCanUseTransactions } from '../../common/scope-check';
import { NotFound, RateLimitExceeded, Unauthorized, ValidationFailed } from '../../errors';
import {
GraphQLTransactionsImportRowAction,
TransactionsImportRowActionTypes,
} from '../enum/TransactionsImportRowAction';
import { GraphQLTransactionsImportType } from '../enum/TransactionsImportType';
import { idDecode } from '../identifiers';
import { fetchAccountWithReference, GraphQLAccountReferenceInput } from '../input/AccountReferenceInput';
Expand All @@ -30,6 +34,7 @@ import {
TransactionImportRowGraphQLType,
} from '../input/TransactionsImportRowUpdateInput';
import { GraphQLTransactionsImport } from '../object/TransactionsImport';
import { GraphQLTransactionsImportRow } from '../object/TransactionsImportRow';

const transactionImportsMutations = {
createTransactionsImport: {
Expand Down Expand Up @@ -195,8 +200,22 @@ const transactionImportsMutations = {
},
},
updateTransactionsImportRows: {
type: new GraphQLNonNull(GraphQLTransactionsImport),
description: 'Update transactions import rows to set new values or mark them as dismissed',
type: new GraphQLNonNull(
new GraphQLObjectType({
name: 'TransactionsImportEditResponse',
fields: {
import: {
type: new GraphQLNonNull(GraphQLTransactionsImport),
description: 'Updated import',
},
rows: {
type: new GraphQLNonNull(new GraphQLList(GraphQLTransactionsImportRow)),
description: 'The rows updated by the mutation',
},
},
}),
),
description: 'Update transactions import rows to set new values or perform actions on them',
args: {
id: {
type: new GraphQLNonNull(GraphQLNonEmptyString),
Expand All @@ -206,22 +225,17 @@ const transactionImportsMutations = {
type: new GraphQLList(new GraphQLNonNull(GraphQLTransactionsImportRowUpdateInput)),
description: 'Rows to update',
},
dismissAll: {
type: GraphQLBoolean,
description: 'Whether to ignore all non-processed rows',
},
restoreAll: {
type: GraphQLBoolean,
description: 'Whether to restore all dismissed rows',
action: {
type: new GraphQLNonNull(GraphQLTransactionsImportRowAction),
description: 'Action to perform on all non-processed rows',
},
},
resolve: async (
_: void,
args: {
id: string;
rows?: TransactionImportRowGraphQLType[];
dismissAll?: boolean;
restoreAll?: boolean;
action: (typeof TransactionsImportRowActionTypes)[number];
},
req: Request,
) => {
Expand All @@ -236,13 +250,17 @@ const transactionImportsMutations = {
throw new Unauthorized('You need to be an admin of the account to update a row');
}

// Preload orders
return sequelize.transaction(async transaction => {
const allRowsIds = args.rows?.map(row => idDecode(row.id, 'transactions-import-row')) || [];
const updatedImport = await sequelize.transaction(async transaction => {
// Update rows
if (args.rows?.length) {
if (args.action === 'UPDATE_ROWS') {
if (!allRowsIds.length) {
throw new ValidationFailed('You must provide at least one row to update');
}

await Promise.all(
args.rows.map(async row => {
const rowId = idDecode(row.id, 'transactions-import-row');
args.rows.map(async (row, index) => {
const rowId = allRowsIds[index];
const where = { id: rowId, TransactionsImportId: importId };
let values: Parameters<typeof TransactionsImportRow.update>[0] = pick(row, [
'sourceId',
Expand Down Expand Up @@ -277,11 +295,9 @@ const transactionImportsMutations = {

values['ExpenseId'] = expense.id;
values['status'] = 'LINKED';
} else if (!isNil(row.isDismissed)) {
values['status'] = row.isDismissed ? 'IGNORED' : 'PENDING';
if (row.isDismissed) {
where['status'] = { [Op.not]: 'LINKED' };
}
} else if (row.status) {
values['status'] = row.status;
where['status'] = { [Op.not]: 'LINKED' }; // Cannot change the status of a LINKED row
}

// For plaid imports, users can't change imported data
Expand All @@ -290,43 +306,64 @@ const transactionImportsMutations = {
}

const [updatedCount] = await TransactionsImportRow.update(values, { where, transaction });

if (!updatedCount) {
throw new NotFound(`Row not found: ${row.id}`);
}
}),
);
} else if (args.dismissAll) {
} else if (args.action === 'DISMISS_ALL') {
await TransactionsImportRow.update(
{ status: 'IGNORED' },
{
transaction,
where: {
TransactionsImportId: importId,
status: { [Op.not]: 'IGNORED' },
status: { [Op.not]: ['LINKED', 'ON_HOLD'] },
ExpenseId: null,
OrderId: null,
...(allRowsIds.length ? { id: { [Op.in]: allRowsIds } } : {}),
},
transaction,
},
);
} else if (args.restoreAll) {
} else if (args.action === 'RESTORE_ALL') {
await TransactionsImportRow.update(
{ status: 'PENDING' },
{
transaction,
where: {
TransactionsImportId: importId,
status: 'IGNORED',
...(allRowsIds.length ? { id: { [Op.in]: allRowsIds } } : {}),
},
},
);
} else if (args.action === 'PUT_ON_HOLD_ALL') {
await TransactionsImportRow.update(
{ status: 'ON_HOLD' },
{
transaction,
where: {
TransactionsImportId: importId,
status: { [Op.not]: ['LINKED', 'ON_HOLD'] },
...(allRowsIds.length ? { id: { [Op.in]: allRowsIds } } : {}),
},
},
);
} else {
throw new ValidationFailed('You must provide at least one row to update or dismiss/restore all rows');
}

// Update import
return transactionsImport.update({ updatedAt: new Date() }, { transaction });
});

return {
import: updatedImport,
rows: await TransactionsImportRow.findAll({
where: {
TransactionsImportId: importId,
...(allRowsIds.length ? { id: { [Op.in]: allRowsIds } } : {}),
},
}),
};
},
},
deleteTransactionsImport: {
Expand Down
Loading

0 comments on commit e9c99ba

Please sign in to comment.