// ==UserScript==
// @name NeoFinancial export transactions as CSV
// @namespace Violentmonkey Scripts
// @match https://member.neofinancial.com/*
// @grant GM.xmlHttpRequest
// @version 1.4.1
// @license MIT
// @author eaglesemanation
// @description Adds a button to transactions page that exports all transactions into a CSV file. Developed for use with "Actual" budgeting tool, will probably work fine with any other importer.
// ==/UserScript==
/**
* Type returned by GraphQL api for credit transactions
*
* @typedef {Object} Transaction
* @property {string} description
* @property {string} currency
* @property {string} type
* @property {string} status
* @property {string} category
* @property {number} amountCents
* @property {string} authorizationProcessedAt
* @property {MerchantDetails?} merchantDetails
* @property {SourceInformation?} sourceInformation
* @property {string?} transferContactName
* @property {string?} etransferContactName
* @property {string?} billPayVendorName
*/
/**
* @typedef {Object} MerchantDetails
* @property {string} description
* @property {string} category
*/
/**
* @typedef {Object} SourceInformation
* @property {string} friendlyName
*/
/**
* GraphQL query for credit account transactions
*/
const creditTransactionQuery = `
query TransactionsList($input: CursorQueryInput!, $creditAccountId: ObjectID!) {
user {
creditAccount(id: $creditAccountId) {
creditTransactionList(input: $input) {
cursor
hasNextPage
results {
description
currency
type
status
category
merchantDetails {
description
category
}
sourceInformation {
friendlyName
}
amountCents
authorizationProcessedAt
}
}
}
}
}
`;
/**
* @param {string} accountId - credit account ID
* @param {[any]?} filters
* @returns {Promise<[Transaction]>}
*/
async function creditTransactions(accountId, filters) {
if (!filters) {
filters = [];
}
let transactions = [];
let hasNextPage = true;
let cursor = undefined;
while (hasNextPage) {
let respJson = await GM.xmlHttpRequest({
url: "https://api.production.neofinancial.com/graphql",
method: "POST",
responseType: "json",
headers: {
"content-type": "application/json",
},
data: JSON.stringify({
operationName: "TransactionsList",
query: creditTransactionQuery,
variables: {
creditAccountId: accountId,
input: {
cursor: cursor,
filter: filters,
limit: 1000,
sort: {
direction: "DESC",
field: "authorizationProcessedAt",
},
},
},
}),
});
let resp = JSON.parse(respJson.responseText);
let transactionList = resp.data.user.creditAccount.creditTransactionList;
hasNextPage = transactionList.hasNextPage;
cursor = transactionList.cursor;
transactions = transactions.concat(transactionList.results);
}
return transactions;
}
/**
* GraphQL query for savings account transactions
*/
const savingsTransactionQuery = `
fragment SavingsTransactionPurchaseFragment on SavingsTransactionPurchase {
merchantDetails {
description
category
}
redemptions {
totalRedeemed
totalCount
}
dispute {
id
status
}
}
fragment SavingsTransactionFundsTransferFragment on SavingsTransactionFundsTransfer {
etransferContactName: transferContactName
}
fragment SavingsTransactionETransferFragment on SavingsTransactionETransfer {
transferContactName
}
fragment SavingsTransactionBillPaymentFragment on SavingsTransactionBillPayment {
billPayVendorName
}
fragment SavingsTransactionFeeFragment on SavingsTransactionFee {
parentTransactionId
}
query FilteredSortedSavingsTransactionList($input: CursorQueryInput!, $savingsAccountId: ObjectID!) {
user {
savingsAccount(id: $savingsAccountId) {
savingsTransactionList(input: $input) {
cursor
hasNextPage
results {
id
amountCents
authorizationProcessedAt
category
currency
description
type
status
completedAt
...SavingsTransactionPurchaseFragment
...SavingsTransactionFundsTransferFragment
...SavingsTransactionETransferFragment
...SavingsTransactionBillPaymentFragment
...SavingsTransactionFeeFragment
}
}
}
}
}
`;
/**
* @param {string} accountId - savings account ID
* @param {[any]?} filters
* @returns {Promise<[Transaction]>}
*/
async function savingsTransactions(accountId, filters) {
if (!filters) {
filters = [];
}
let transactions = [];
let hasNextPage = true;
let cursor = undefined;
while (hasNextPage) {
let respJson = await GM.xmlHttpRequest({
url: "https://api.production.neofinancial.com/graphql",
method: "POST",
responseType: "json",
headers: {
"content-type": "application/json",
},
data: JSON.stringify({
operationName: "FilteredSortedSavingsTransactionList",
query: savingsTransactionQuery,
variables: {
savingsAccountId: accountId,
input: {
cursor: cursor,
filter: filters,
limit: 1000,
sort: {
direction: "DESC",
field: "authorizationProcessedAt",
},
},
},
}),
});
let resp = JSON.parse(respJson.responseText);
let transactionList = resp.data.user.savingsAccount.savingsTransactionList;
hasNextPage = transactionList.hasNextPage;
cursor = transactionList.cursor;
transactions = transactions.concat(transactionList.results);
}
return transactions;
}
/**
* GraphQL query for figuring out custom name of savings account
*/
const accountPersonalizationQuery = `
query SavingsAccountPersonalization($savingsAccountId: ObjectID!) {
user {
savingsAccount(id: $savingsAccountId) {
accountPersonalization {
customizedName
}
}
}
}
`;
/**
* @param {string} accountId - savings account ID
* @returns {Promise<string>}
*/
async function savingsAccountName(accountId) {
let respJson = await GM.xmlHttpRequest({
url: "https://api.production.neofinancial.com/graphql",
method: "POST",
responseType: "json",
headers: {
"content-type": "application/json",
},
data: JSON.stringify({
operationName: "SavingsAccountPersonalization",
query: accountPersonalizationQuery,
variables: {
savingsAccountId: accountId,
},
}),
});
let resp = JSON.parse(respJson.responseText);
return resp.data.user.savingsAccount.accountPersonalization.customizedName;
}
/**
* @param {[Transaction]} transactions
* @returns {Blob}
*/
function transactionsToCsvBlob(transactions) {
let csv = `"Date","Payee","Notes","Category","Amount"\n`;
for (const transaction of transactions) {
let date = new Date(transaction.authorizationProcessedAt);
// JS Date type is absolutly horible, I hope Temporal API will be better
let dateStr = `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`;
let payee = null;
let category = transaction.category;
// Assume that transaction is a purchase by default
let amountCents = -transaction.amountCents;
switch (transaction.category) {
case "PURCHASE":
payee = transaction.merchantDetails.description;
category = transaction.merchantDetails.category;
break;
case "NEO_STORE_PURCHASE":
payee = "Neo Financial";
category = "PURCHASE";
break;
case "REWARDS_ACCOUNT_CASH_OUT":
payee = "Neo Financial";
category = "PAYMENT";
amountCents = transaction.amountCents;
break;
case "REFUND":
payee = transaction.merchantDetails.description;
category = transaction.merchantDetails.category;
amountCents = transaction.amountCents;
break;
case "PAYMENT":
payee = transaction.sourceInformation.friendlyName;
amountCents = transaction.amountCents;
break;
case "WITHDRAWAL":
if (transaction.description.search(/Payment to Credit/) !== -1) {
payee = "Neo Credit";
} else if (transaction.merchantDetails) {
payee = transaction.merchantDetails.description;
category = transaction.merchantDetails.category;
} else {
payee = transaction.transferContactName;
}
break;
case "TRANSFER":
if (transaction.transferContactName) {
payee = transaction.transferContactName;
} else if (transaction.etransferContactName) {
payee = transaction.etransferContactName;
}
amountCents = transaction.amountCents;
break;
case "INTEREST":
payee = "Neo Financial";
category = "PAYMENT";
amountCents = transaction.amountCents;
break;
case "DEPOSIT":
if (transaction.description.search(/Reward Cashed Out/) !== -1) {
payee = "Neo Financial";
category = "PAYMENT";
}
amountCents = transaction.amountCents;
break;
default:
console.log(
`[csv-export] ${dateStr} transaction [${transaction.category}] has unexpected category. Object logged below. Skipping`,
);
console.log(transaction);
continue;
}
if (!payee) {
console.log(
`[csv-export] ${dateStr} transaction [${transaction.category}] could not figure out payee. Object logged below. Skipping`,
);
console.log(transaction);
continue;
}
let amountCentsStr = amountCents.toString();
let amount = "";
// Insert decimal separator into a string to avoid any shenanigans with floating point numbers
if (amountCentsStr.length > 2) {
amount =
amountCentsStr.substring(0, amountCentsStr.length - 2) +
"." +
amountCentsStr.substring(amountCentsStr.length - 2);
} else if (amountCents < 10) {
amount = `0.0${amountCentsStr}`;
} else {
amount = `0.${amountCentsStr}`;
}
let notes = transaction.description;
let entry = `"${dateStr}","${payee}","${notes}","${category}","${amount}"`;
// Transaction is not affecting balance, skipping
if (!["CONFIRMED", "AUTHORIZED"].includes(transaction.status)) {
// Catching unhandled status values.
if (!["DECLINED"].includes(transaction.status)) {
console.log(
`[csv-export] ${dateStr} transaction [${transaction.category}] from "${payee}" has unexpected status: ${transaction.status}. Object logged below. Skipping`,
);
console.log(transaction);
}
continue;
}
csv += `${entry}\n`;
}
// Signifies to some apps that file is UTF-8 encoded
const BOM = "\uFEFF";
return new Blob([BOM, csv], { type: "text/csv;charset=utf-8" });
}
// ID for quickly verifying if button was already injected
const exportCsvId = "export-transactions-csv";
/**
* Copied style of a credit payment button
*
* @type {CSSStyleDeclaration}
*/
const buttonStyle = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
position: "relative",
boxSizing: "border-box",
backgroundColor: "transparent",
outline: "0",
border: "0",
margin: "0",
borderRadius: "4px",
padding: "0rem 1rem",
cursor: "pointer",
userSelect: "none",
verticalAlign: "middle",
color: "inherit",
fontFamily: `TTCommons, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif`,
fontWeight: "600",
fontSize: "1rem",
lineHeight: "1.25",
letterSpacing: "0.02857em",
minWidth: "64px",
borderRadius: "4px",
backgroundColor: "#EDEEEF",
color: "#000000",
height: "32px",
width: "100%",
};
/**
* Inserts a CSV export button next to transaction filters button
*
* @param {Element?} filtersElement
* @param {AccountInfo?} accountInfo
*/
function addDownloadButtons(filtersElement, accountInfo) {
if (!filtersElement || !accountInfo) {
return;
}
let csvExportRow = document.createElement("div");
csvExportRow.id = exportCsvId;
csvExportRow.style.display = "flex";
csvExportRow.style.alignItems = "center";
csvExportRow.style.gap = "1em";
let csvExportText = document.createElement("div");
csvExportText.innerText = "Export as CSV:";
csvExportText.style.fontFamily = buttonStyle.fontFamily;
csvExportText.style.fontWeight = "400";
csvExportRow.appendChild(csvExportText);
const now = new Date();
const buttons = [
{
text: "This Month",
fromDate: new Date(now.getFullYear(), now.getMonth(), 1),
},
{
text: "Last 3 Months",
fromDate: new Date(now.getFullYear(), now.getMonth() - 3, 1),
},
{
text: "All",
fromDate: null,
},
];
for (const button of buttons) {
let filters = [];
if (button.fromDate) {
filters = [
{
field: "authorizationProcessedAt",
operator: "GTE",
type: "DATE",
value: button.fromDate.toISOString(),
},
];
}
let exportButton = document.createElement("button");
exportButton.innerText = button.text;
exportButton.onclick = saveBlobToFileCallback(
accountInfo,
filters,
button.fromDate,
);
Object.assign(exportButton.style, buttonStyle);
let exportButtonBox = document.createElement("div");
exportButtonBox.className = "MuiBox-root";
exportButtonBox.appendChild(exportButton);
csvExportRow.appendChild(exportButtonBox);
}
filtersElement.insertBefore(csvExportRow, filtersElement.children[0]);
filtersElement.style.alignItems = "center";
filtersElement.style.justifyContent = "space-between";
filtersElement.style.gap = "1em";
}
/**
* @callback TransactionCallback
* @param {string} accountId
* @param {[any]} filters
* @returns {Promise<[Transaction]>}
*/
/**
* Creates a wraper function that calls to transaction callback, then downloads resulting blob as a file by
* injecting anchor element into a body, clicking it and removing it.
*
* @param {AccountInfo} accountInfo
* @param {[any]?} filters
* @param {Date?} fromDate
* @return {Function}
*/
function saveBlobToFileCallback(accountInfo, filters, fromDate) {
if (!filters) {
filters = [];
}
return async () => {
console.log("[csv-export] Fetching Transactions");
let blob = transactionsToCsvBlob(
await accountInfo.transactionsCallback(accountInfo.id, filters),
);
console.log("[csv-export] Writing transactions into a file");
let blobUrl = URL.createObjectURL(blob);
let now = new Date();
let nowStr = `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()}`;
let timeFrame = "";
if (fromDate) {
timeFrame += `From ${fromDate.getFullYear()}-${fromDate.getMonth() + 1}-${fromDate.getDate()} `;
}
timeFrame += `Up to ${nowStr}`;
let link = document.createElement("a");
link.href = blobUrl;
link.download = `Neo ${accountInfo.name} Transactions ${timeFrame}.csv`;
link.style.display = "none";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(blobUrl);
};
}
/**
* Identifies if current page is a transactions page and returns apropriate button callback
*
* @typedef {Object} PageInfo
* @property {boolean} isTransactionsPage
* @property {string?} transactionFiltersQuery - CSS query for a place where to put a button
* @property {AccountInfo?} accountInfo
*/
/**
* @typedef {Object} AccountInfo
* @property {string} id
* @property {string} name
* @property {"credit"|"savings"} type
* @property {TransactionCallback} transactionsCallback
*/
/**
* @returns {Promise<PageInfo>}
*/
async function detectPageType() {
/**
* @type {PageInfo}
*/
let pageInfo = {
isTransactionsPage: false,
transactionFiltersQuery: null,
accountInfo: null,
};
let pathParts = window.location.pathname.split("/");
if (pathParts[pathParts.length - 1] !== "transactions") {
return pageInfo;
} else {
pageInfo.isTransactionsPage = true;
}
let accountsIdx = pathParts.findIndex((v) => v === "accounts");
let accountType = pathParts[accountsIdx + 1];
let accountId = pathParts[accountsIdx + 2];
// Handling different types of accounts
if (accountType === "credit") {
pageInfo.transactionFiltersQuery = `div:has(> button > svg > path[d="M3 17v2h6v-2zM3 5v2h10V5zm10 16v-2h8v-2h-8v-2h-2v6zM7 9v2H3v2h4v2h2V9zm14 4v-2H11v2zm-6-4h2V7h4V5h-4V3h-2z"])`;
pageInfo.accountInfo = {
id: accountId,
type: accountType,
// Looks like credit account cannot have custom name, hardcoding it
name: "Credit",
transactionsCallback: creditTransactions,
};
} else if (accountType === "savings") {
pageInfo.transactionFiltersQuery = `div:has(> button > svg > path[d="M3 17v2h6v-2zM3 5v2h10V5zm10 16v-2h8v-2h-8v-2h-2v6zM7 9v2H3v2h4v2h2V9zm14 4v-2H11v2zm-6-4h2V7h4V5h-4V3h-2z"])`;
pageInfo.accountInfo = {
id: accountId,
type: accountType,
name: await savingsAccountName(accountId),
transactionsCallback: savingsTransactions,
};
}
if (pageInfo.accountInfo !== null) {
console.log(`[csv-export] Transactions history page of type '${accountType}' detected`);
}
return pageInfo;
}
/**
* Keeps button shown after rerenders and href changes
* @returns {Promise<void>}
*/
async function keepButtonShown() {
// Early exit, to avoid unnecessary requests if already injected
if (document.querySelector(`div#${exportCsvId}`)) {
return;
}
const pageInfo = await detectPageType();
if (!pageInfo.isTransactionsPage) {
return;
}
const transactionFilters = document.querySelector(
pageInfo.transactionFiltersQuery,
);
if (!transactionFilters) {
console.log("[csv-export] Could not find UI element specifying transaction history filters, aborting");
return;
}
// Intentional duplicate, avoiding race condidion on detectPageType call
if (document.querySelector(`div#${exportCsvId}`)) {
return;
}
addDownloadButtons(transactionFilters, pageInfo.accountInfo);
}
(async function() {
// Keeping track of DOM modifications to detect when "Transactions Filter" button will reappear
const observer = new MutationObserver(async (mutations) => {
for (const _ of mutations) {
await keepButtonShown();
}
});
observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
// Try running on load if there are no mutations for some reason
window.addEventListener("load", async () => {
await keepButtonShown();
});
})();