Skip to content

Commit

Permalink
Update lunchmoney extension
Browse files Browse the repository at this point in the history
- Merge branch \'contributions/merge-1738318514950\'
- Pull contributions
- update Changelogs
- Merge pull request raycast#1 from gfanton/feat/edit-form
- feat: Use account_display_name
- feat(tags): Track when the tags field is focused to change order or Form action
- chore(transaction_form): Remove non necessary useMemo and use concat instead of spread for faster merge
- chore(transaction_form): Move Categories and RecurringItems rendering outside of component to avoid extra re-render
- style: Remove useless quotes
- chore: lint
- feat(form): add tag
- chore: typo
- fix: update form
- wip: add lunchmoney edit form
- doc: Update changelog
- chore: Update metadata
- feat: Add status to keywords
- fix: Fix month boundary parsing
- feat: Move transactions into List.Section by date
- feat: Move category tag at the end
- feat: Add transaction category to List Item
  • Loading branch information
johnoppenheimer committed Jan 31, 2025
1 parent c4e1871 commit eeea332
Show file tree
Hide file tree
Showing 6 changed files with 404 additions and 19 deletions.
8 changes: 8 additions & 0 deletions extensions/lunchmoney/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
# LunchMoney Changelog

## [Edit page, fixes and light improvements] - {PR_MERGE_DATE}

- Add edit transaction pages
- Add more info and changes some info order to a transaction row
- Add transaction status to the List.Item keyword for easier filtering
- Fix month parsing
- Group transactions by day

## [Initial Version] - 2024-11-06
Binary file added extensions/lunchmoney/metadata/lunchmoney-2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions extensions/lunchmoney/src/format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import * as lunchMoney from "./lunchmoney";

export const getFormatedAmount = (transaction: lunchMoney.Transaction): string =>
Intl.NumberFormat("en-US", { style: "currency", currency: transaction.currency }).format(transaction.to_base);
68 changes: 68 additions & 0 deletions extensions/lunchmoney/src/lunchmoney.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export interface Transaction {
is_income: boolean;
display_name?: string;
display_note: string | null;
account_display_name: string;

// recurring
recurring_id: number | null;
Expand Down Expand Up @@ -145,7 +146,49 @@ export interface Category {
created_at: string;
is_group: boolean;
group_id?: number;
children?: Category[];
}

export interface SummarizedTransaction {
id: number;
date: string;
amount: string;
currency: string;
payee: string;
category_id?: number;
recurring_id?: number;
to_base: number;
}

export interface RecurringItem {
id: number;
start_date?: string;
end_date?: string;
payee: string;
currency: string;
created_at: string;
updated_at: string;
billing_date: string;
original_name?: string;
description?: string;
plaid_account_id?: number;
asset_id?: number;
source: "manual" | "transaction" | "system" | "null";
notes?: string;
amount: string;
category_id?: number;
category_group_id?: number;
is_income: boolean;
exclude_from_totals: boolean;
granularity: "day" | "week" | "month" | "year";
quantity?: number;
occurrences: Record<string, SummarizedTransaction[]>;
transactions_within_range?: SummarizedTransaction[];
missing_dates_within_range?: string[];
date?: string;
to_base: number;
}

export interface DraftTransaction {
date: string;
category_id?: number;
Expand All @@ -162,6 +205,8 @@ export interface DraftTransaction {
export interface Tag {
id: number;
name: string;
description: string;
archived: boolean;
}

export type TransactionsEndpointArguments = {
Expand All @@ -188,10 +233,16 @@ export const getTransactions = async (args?: TransactionsEndpointArguments): Pro
return (await response.json()).transactions;
};

export const getTransaction = async (transactionId: number): Promise<Transaction> => {
const response = await client.get<Transaction>(`v1/transactions/${transactionId}`);
return response.json();
};

export type UpdateTransactionResponse = {
updated: boolean;
split?: number[];
};

export const updateTransaction = async (
transactionId: number,
args: TransactionUpdate,
Expand All @@ -201,3 +252,20 @@ export const updateTransaction = async (
});
return response.json();
};

export const getCategories = async (): Promise<Category[]> => {
const response = await client.get<{ categories: Category[] }>("v1/categories", {
searchParams: { format: "nested" },
});
return (await response.json()).categories;
};

export const getTags = async (): Promise<Tag[]> => {
const response = await client.get<Tag[]>("v1/tags");
return response.json();
};

export const getRecurringItems = async (): Promise<RecurringItem[]> => {
const response = await client.get<RecurringItem[]>(`v1/recurring_items`);
return response.json();
};
119 changes: 100 additions & 19 deletions extensions/lunchmoney/src/transactions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import { ActionPanel, List, Action, Icon, Color, Image, showToast, Toast } from
import { useCachedPromise } from "@raycast/utils";
import { match, P } from "ts-pattern";
import * as lunchMoney from "./lunchmoney";
import { EditTransactionForm } from "./transactions_form";
import { useMemo, useState } from "react";
import { eachMonthOfInterval, endOfMonth, format, startOfYear } from "date-fns";
import { compareDesc, eachMonthOfInterval, endOfMonth, format, parse, startOfMonth, startOfYear } from "date-fns";
import { alphabetical, group, sift, sort } from "radash";
import { getFormatedAmount } from "./format";

const getTransactionIcon = (transaction: lunchMoney.Transaction) =>
export const getTransactionIcon = (transaction: lunchMoney.Transaction) =>
match(transaction)
.returnType<Image>()
.with({ status: lunchMoney.TransactionStatus.CLEARED, recurring_type: P.nullish }, () => ({
Expand All @@ -29,7 +31,7 @@ const getTransactionIcon = (transaction: lunchMoney.Transaction) =>
}))
.otherwise(() => ({ source: Icon.Circle }));

const getTransactionSubtitle = (transaction: lunchMoney.Transaction) =>
export const getTransactionSubtitle = (transaction: lunchMoney.Transaction) =>
match(transaction)
.returnType<string>()
.with(
Expand All @@ -41,31 +43,45 @@ const getTransactionSubtitle = (transaction: lunchMoney.Transaction) =>
function TransactionListItem({
transaction,
onValidate,
onEdit,
}: {
transaction: lunchMoney.Transaction;
onValidate: (transaction: lunchMoney.Transaction) => void;
onEdit: (transaction: lunchMoney.Transaction, update: lunchMoney.TransactionUpdate) => void;
}) {
const validate = async () => {
onValidate(transaction);
};

return (
<List.Item
title={`${Intl.NumberFormat("en-US", { style: "currency", currency: transaction.currency }).format(transaction.to_base)}`}
title={getFormatedAmount(transaction)}
subtitle={getTransactionSubtitle(transaction)}
icon={getTransactionIcon(transaction)}
accessories={sift([
{ text: `${transaction.plaid_account_name ?? transaction.asset_name ?? ""}` },
{ text: format(transaction.date, "PP"), tooltip: transaction.date },
{ text: transaction.account_display_name },
transaction.is_group ? { icon: Icon.Folder, tooltip: "Group" } : undefined,
...(transaction.tags?.map((tag) => ({ tag: tag.name })) ?? []),
transaction.category_name ? { tag: transaction.category_name, icon: Icon.Tag } : undefined,
])}
keywords={sift([
transaction.status,
transaction.payee,
transaction.recurring_payee,
transaction.notes,
transaction.display_note,
])}
keywords={sift([transaction.payee, transaction.recurring_payee, transaction.notes, transaction.display_note])}
actions={
<ActionPanel>
{transaction.status != lunchMoney.TransactionStatus.CLEARED && !transaction.is_pending && (
<Action title="Validate" icon={Icon.CheckCircle} onAction={validate} />
)}
<Action.Push
title="Edit Transaction"
shortcut={{ modifiers: [], key: "arrowRight" }}
icon={Icon.Pencil}
target={<EditTransactionForm transaction={transaction} onEdit={onEdit} />}
/>
<Action.OpenInBrowser
title="View Payee in Lunch Money"
url={`https://my.lunchmoney.app/transactions/${format(transaction.date, "yyyy/MM")}?match=all&payee_exact=${encodeURIComponent(transaction.payee)}&time=month`}
Expand All @@ -76,7 +92,8 @@ function TransactionListItem({
);
}

const groupAndSortTransactions = (transactions: lunchMoney.Transaction[]) => {
/// Sorts transactions by date, then by to_base
const groupAndSortTransactionsByBase = (transactions: lunchMoney.Transaction[]) => {
const transactionsByDay = group(transactions, (t) => t.date);

const sortedTransactions: lunchMoney.Transaction[] = [];
Expand All @@ -91,6 +108,24 @@ const groupAndSortTransactions = (transactions: lunchMoney.Transaction[]) => {
return sortedTransactions;
};

/// Sorts transactions by date, then by created_at
const groupAndSortTransactionsByCreatedAt = (
transactions: lunchMoney.Transaction[],
): Record<string, lunchMoney.Transaction[]> => {
const transactionsByDay = group(transactions, (t) => t.date);

const sortedTransactions: Record<string, lunchMoney.Transaction[]> = {};
const days = alphabetical(Object.keys(transactionsByDay), (k) => k, "desc");

for (const day of days) {
const transactions = transactionsByDay[day];
if (transactions != null) {
sortedTransactions[day] = transactions.toSorted((a, b) => compareDesc(a.created_at, b.created_at));
}
}
return sortedTransactions;
};

function TransactionsDropdown({ value, onChange }: { value: string; onChange: (value: string) => void }) {
const months = eachMonthOfInterval({
start: startOfYear(new Date()),
Expand All @@ -113,12 +148,12 @@ function TransactionsDropdown({ value, onChange }: { value: string; onChange: (v
}

export default function Command() {
const [month, setMonth] = useState(() => format(new Date(), "yyyy-MM-dd"));
const [month, setMonth] = useState(() => format(startOfMonth(new Date()), "yyyy-MM-dd"));
const { data, isLoading, mutate } = useCachedPromise(lunchMoney.getTransactions, [
{ start_date: month, end_date: format(endOfMonth(month), "yyyy-MM-dd") },
{ start_date: month, end_date: format(endOfMonth(parse(month, "yyyy-MM-dd", new Date())), "yyyy-MM-dd") },
]);

const [pendingTransactions, transactions] = useMemo(() => {
const [pendingTransactions, transactionsGroups] = useMemo(() => {
const [pendingTransactions, transactions] = (data ?? []).reduce(
function groupTransactions(acc, transaction) {
if (transaction.status === lunchMoney.TransactionStatus.PENDING || transaction.is_pending) {
Expand All @@ -131,8 +166,8 @@ export default function Command() {
[[], []] as [lunchMoney.Transaction[], lunchMoney.Transaction[]],
);

return [groupAndSortTransactions(pendingTransactions), alphabetical(transactions, (t) => t.date, "desc")];
}, [data?.map((t) => `${t.id}:${t.status}`).join(",")]);
return [groupAndSortTransactionsByBase(pendingTransactions), groupAndSortTransactionsByCreatedAt(transactions)];
}, [data]);

const onValidate = async (transaction: lunchMoney.Transaction) => {
const toast = await showToast({
Expand Down Expand Up @@ -169,18 +204,64 @@ export default function Command() {
}
};

const onEdit = async (transaction: lunchMoney.Transaction, update: lunchMoney.TransactionUpdate) => {
const toast = await showToast({
title: "Updating Transaction",
style: Toast.Style.Animated,
});

try {
await mutate(lunchMoney.updateTransaction(transaction.id, update), {
optimisticUpdate: (currentData) => {
if (!currentData) return currentData;
return currentData.map((tx) => {
if (tx.id === transaction.id) {
tx.payee = update.payee ? update.payee : transaction.payee;
tx.status = update.status ? update.status : transaction.status;
tx.notes = update.notes ? update.notes : transaction.notes;
tx.category_id = update.category_id ? update.category_id : transaction.category_id;
tx.date = update.date ? update.date : transaction.date;
}
return tx;
});
},
});

toast.style = Toast.Style.Success;
toast.title = "Transaction updated";
} catch (error) {
toast.style = Toast.Style.Failure;
toast.title = "Failed to update transaction";
if (error instanceof Error) {
toast.message = error.message;
}
}
};

return (
<List isLoading={isLoading} searchBarAccessory={<TransactionsDropdown value={month} onChange={setMonth} />}>
<List.Section title="Pending Transactions">
{pendingTransactions.map((transaction) => (
<TransactionListItem key={String(transaction.id)} transaction={transaction} onValidate={onValidate} />
))}
</List.Section>
<List.Section title="Transactions">
{transactions.map((transaction) => (
<TransactionListItem key={String(transaction.id)} transaction={transaction} onValidate={onValidate} />
<TransactionListItem
key={String(transaction.id)}
transaction={transaction}
onValidate={onValidate}
onEdit={onEdit}
/>
))}
</List.Section>
{Object.entries(transactionsGroups).map(([month, transactions]) => (
<List.Section key={month} title={format(new Date(month), "PP")}>
{transactions.map((transaction) => (
<TransactionListItem
key={String(transaction.id)}
transaction={transaction}
onValidate={onValidate}
onEdit={onEdit}
/>
))}
</List.Section>
))}
</List>
);
}
Loading

0 comments on commit eeea332

Please sign in to comment.