From 49ec00ee53a82d65f6e013327295f0a15d9b7dbc Mon Sep 17 00:00:00 2001 From: Aleksandr Beliakov Date: Thu, 9 Jan 2025 14:00:04 +0800 Subject: [PATCH 1/5] Add sync with Splitwise --- src/plugins/splitwise/ZenmoneyManifest.xml | 49 +++++++ .../splitwise/__tests__/converters.test.ts | 126 ++++++++++++++++++ src/plugins/splitwise/__tests__/index.test.ts | 67 ++++++++++ src/plugins/splitwise/converters.ts | 51 +++++++ src/plugins/splitwise/fetchApi.ts | 52 ++++++++ src/plugins/splitwise/index.ts | 33 +++++ src/plugins/splitwise/models.ts | 50 +++++++ src/plugins/splitwise/preferences.xml | 23 ++++ 8 files changed, 451 insertions(+) create mode 100644 src/plugins/splitwise/ZenmoneyManifest.xml create mode 100644 src/plugins/splitwise/__tests__/converters.test.ts create mode 100644 src/plugins/splitwise/__tests__/index.test.ts create mode 100644 src/plugins/splitwise/converters.ts create mode 100644 src/plugins/splitwise/fetchApi.ts create mode 100644 src/plugins/splitwise/index.ts create mode 100644 src/plugins/splitwise/models.ts create mode 100644 src/plugins/splitwise/preferences.xml diff --git a/src/plugins/splitwise/ZenmoneyManifest.xml b/src/plugins/splitwise/ZenmoneyManifest.xml new file mode 100644 index 00000000..42ab260c --- /dev/null +++ b/src/plugins/splitwise/ZenmoneyManifest.xml @@ -0,0 +1,49 @@ + + + splitwise + 15881 + + Synchronization plugin for Splitwise (https://splitwise.com). + Input your API token from https://secure.splitwise.com/apps. + + This plugin synchronizes your Splitwise expenses with ZenMoney. + + ## How it works + + 1. The plugin creates separate accounts for each currency used in your Splitwise expenses + 2. All expenses where you owe money will be imported as transactions with negative amounts + 3. All expenses where others owe you money will be reflected in the account balance + + ## Transaction handling + + When you pay for a shared expense: + 1. The original payment will appear in ZenMoney from your payment method (card/cash) + 2. After syncing with Splitwise, your share of the expense will appear as a transaction + 3. You should convert the original payment to a transfer: + - Source: Your payment account (card/cash) + - Destination: The corresponding Splitwise currency account + + This way, your Splitwise balance will accurately reflect what you owe and what others owe you. + + ## Example + + You paid $100 for dinner with a friend (split 50/50): + 1. Original $100 payment appears from your card + 2. Splitwise sync adds a -$50 expense (your share) + 3. Convert the $100 card payment to: + - Transfer from: Your card + - Transfer to: Splitwise USD account + + Final result: + - Your card: -$100 + - Splitwise USD: +$50 (friend owes you) + + 1.0.0 + 1 + true + + index.js + preferences.xml + + false + \ No newline at end of file diff --git a/src/plugins/splitwise/__tests__/converters.test.ts b/src/plugins/splitwise/__tests__/converters.test.ts new file mode 100644 index 00000000..13a57a8d --- /dev/null +++ b/src/plugins/splitwise/__tests__/converters.test.ts @@ -0,0 +1,126 @@ +import { convertAccounts, convertTransaction } from '../converters' +import { SplitwiseExpense } from '../models' +import { AccountType } from '../../../types/zenmoney' + +describe('convertAccounts', () => { + it('should create accounts only for currencies where user owes money', () => { + const expenses: SplitwiseExpense[] = [ + { + id: 1, + description: 'Lunch', + details: '', + payment: false, + cost: '100.00', + date: '2024-01-08', + created_at: '2024-01-08T15:08:03Z', + updated_at: '2024-01-08T15:08:03Z', + currency_code: 'USD', + users: [ + { user_id: 1, paid_share: '0', owed_share: '50.00' }, + { user_id: 2, paid_share: '100.00', owed_share: '50.00' } + ] + }, + { + id: 2, + description: 'Coffee', + details: '', + payment: false, + cost: '10.00', + date: '2024-01-08', + created_at: '2024-01-08T15:08:03Z', + updated_at: '2024-01-08T15:08:03Z', + currency_code: 'EUR', + users: [ + { user_id: 1, paid_share: '10.00', owed_share: '0' }, + { user_id: 2, paid_share: '0', owed_share: '10.00' } + ] + } + ] + + const balances = { + USD: -50, + EUR: 10 + } + + const accounts = convertAccounts(expenses, balances) + expect(accounts).toHaveLength(2) + expect(accounts).toEqual([ + { + id: 'USD', + type: AccountType.checking, + title: 'Splitwise USD', + instrument: 'USD', + balance: -50, + syncIds: ['SWUSD0'] + }, + { + id: 'EUR', + type: AccountType.checking, + title: 'Splitwise EUR', + instrument: 'EUR', + balance: 10, + syncIds: ['SWEUR0'] + } + ]) + }) +}) + +describe('convertTransaction', () => { + it('should convert expense to transaction when user owes money', () => { + const expense: SplitwiseExpense = { + id: 1, + description: 'Lunch', + details: '', + payment: false, + cost: '100.00', + date: '2024-01-08', + created_at: '2024-01-08T15:08:03Z', + updated_at: '2024-01-08T15:08:03Z', + currency_code: 'USD', + users: [ + { user_id: 1, paid_share: '0', owed_share: '50.00' }, + { user_id: 2, paid_share: '100.00', owed_share: '50.00' } + ] + } + + const transaction = convertTransaction(expense, 1) + expect(transaction).toEqual({ + hold: false, + date: new Date('2024-01-08'), + movements: [{ + id: '1', + account: { id: 'USD' }, + sum: -50, + fee: 0, + invoice: null + }], + merchant: { + fullTitle: 'Lunch', + mcc: null, + location: null + }, + comment: 'Lunch' + }) + }) + + it('should return null when user does not owe money', () => { + const expense: SplitwiseExpense = { + id: 1, + description: 'Coffee', + details: '', + payment: false, + cost: '10.00', + date: '2024-01-08', + created_at: '2024-01-08T15:08:03Z', + updated_at: '2024-01-08T15:08:03Z', + currency_code: 'EUR', + users: [ + { user_id: 1, paid_share: '10.00', owed_share: '0' }, + { user_id: 2, paid_share: '0', owed_share: '10.00' } + ] + } + + const transaction = convertTransaction(expense, 1) + expect(transaction).toBeNull() + }) +}) diff --git a/src/plugins/splitwise/__tests__/index.test.ts b/src/plugins/splitwise/__tests__/index.test.ts new file mode 100644 index 00000000..dbcb1df4 --- /dev/null +++ b/src/plugins/splitwise/__tests__/index.test.ts @@ -0,0 +1,67 @@ +import { scrape } from '../index' +import { fetchCurrentUser, fetchExpenses, fetchBalances } from '../fetchApi' +import { SplitwiseExpense, SplitwiseUser } from '../models' + +jest.mock('../fetchApi') + +describe('scrape', () => { + beforeEach(() => { + jest.resetAllMocks() + }) + + it('should fetch and convert expenses', async () => { + const mockUser: SplitwiseUser = { + id: 1, + first_name: 'Test', + last_name: 'User', + email: 'test@example.com', + registration_status: 'confirmed', + picture: { + small: '', + medium: '', + large: '' + }, + default_currency: 'USD', + locale: 'en' + } + + const mockExpenses: SplitwiseExpense[] = [{ + id: 1, + description: 'Lunch', + details: '', + payment: false, + cost: '100.00', + date: '2024-01-08', + created_at: '2024-01-08T15:08:03Z', + updated_at: '2024-01-08T15:08:03Z', + currency_code: 'USD', + users: [ + { user_id: 1, paid_share: '0', owed_share: '50.00' }, + { user_id: 2, paid_share: '100.00', owed_share: '50.00' } + ] + }] + + const mockBalances = { + USD: -50 + } + + ;(fetchCurrentUser as jest.Mock).mockResolvedValue(mockUser) + ;(fetchExpenses as jest.Mock).mockResolvedValue(mockExpenses) + ;(fetchBalances as jest.Mock).mockResolvedValue(mockBalances) + + const result = await scrape({ + preferences: { + token: 'test-token', + startDate: '2024-01-01' + }, + fromDate: new Date('2024-01-01'), + toDate: new Date('2024-01-31'), + isFirstRun: true, + isInBackground: false + }) + + expect(result.accounts).toHaveLength(1) + expect(result.transactions).toHaveLength(1) + expect(fetchExpenses).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/plugins/splitwise/converters.ts b/src/plugins/splitwise/converters.ts new file mode 100644 index 00000000..77996674 --- /dev/null +++ b/src/plugins/splitwise/converters.ts @@ -0,0 +1,51 @@ +import { AccountType, Account, Transaction } from '../../types/zenmoney' +import { SplitwiseExpense } from './models' + +export function convertAccounts (expenses: SplitwiseExpense[], balances: Record): Account[] { + // Get currencies only from expenses where user was involved + const currencies = new Set(expenses + .filter((expense) => expense.users.some((user) => parseFloat(user.owed_share) > 0)) + .map((expense) => expense.currency_code)) + + return Array.from(currencies).map((currency) => ({ + id: currency, + type: AccountType.checking, + title: `Splitwise ${currency}`, + instrument: currency, + balance: balances[currency] ?? 0, + syncIds: [`SW${currency.padEnd(4, '0')}`] + })) +} + +export function convertTransaction (expense: SplitwiseExpense, currentUserId: number): Transaction | null { + const currentUser = expense.users.find((user) => user.user_id === currentUserId) + + if (!currentUser) { + return null + } + + const owedShare = parseFloat(currentUser.owed_share) + + // Only create transaction if user owes money + if (owedShare <= 0) { + return null + } + + return { + hold: false, + date: new Date(expense.date), + movements: [{ + id: expense.id.toString(), + account: { id: expense.currency_code }, + sum: -owedShare, // Negative because it's an expense + fee: 0, + invoice: null + }], + merchant: { + fullTitle: expense.description, + mcc: null, + location: null + }, + comment: expense.description + } +} diff --git a/src/plugins/splitwise/fetchApi.ts b/src/plugins/splitwise/fetchApi.ts new file mode 100644 index 00000000..d7effc37 --- /dev/null +++ b/src/plugins/splitwise/fetchApi.ts @@ -0,0 +1,52 @@ +import { Auth, SplitwiseExpense, SplitwiseUser } from './models' + +async function fetchApi (path: string, auth: Auth, params: Record = {}): Promise { + const response = await fetch(`https://secure.splitwise.com/api/v3.0${path}`, { + method: 'GET', + headers: { + Authorization: `Bearer ${auth.token}` + } + }) + + if (response.status === 401) { + throw new Error('Unauthorized: Invalid token') + } + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + return await response.json() +} + +export async function fetchCurrentUser (auth: Auth): Promise { + const response = await fetchApi('/get_current_user', auth) + return (response as { user: SplitwiseUser }).user +} + +export async function fetchExpenses (auth: Auth, fromDate: Date, toDate: Date): Promise { + const response = await fetchApi('/get_expenses', auth, { + dated_after: fromDate.toISOString().split('T')[0], + limit: 0 + }) + const expenses = (response as { expenses: SplitwiseExpense[] }).expenses + return expenses.length > 0 ? expenses : [] +} + +export async function fetchBalances (auth: Auth): Promise> { + const response = await fetchApi('/get_groups', auth) + const groups = (response as { groups: Array<{ group_id: number, members: Array<{ balance: Array<{ currency_code: string, amount: string }> }> }> }).groups + const balances: Record = {} + for (const group of groups) { + for (const member of group.members) { + for (const balance of member.balance) { + if (balance.currency_code !== '') { + const amount = parseFloat(balance.amount) + balances[balance.currency_code] = (balances[balance.currency_code] ?? 0) + amount + } + } + } + } + + return balances +} diff --git a/src/plugins/splitwise/index.ts b/src/plugins/splitwise/index.ts new file mode 100644 index 00000000..028a6a6c --- /dev/null +++ b/src/plugins/splitwise/index.ts @@ -0,0 +1,33 @@ +import { ScrapeFunc, Transaction } from '../../types/zenmoney' +import { fetchExpenses, fetchCurrentUser, fetchBalances } from './fetchApi' +import { convertAccounts, convertTransaction } from './converters' +import { Auth, Preferences } from './models' + +export const scrape: ScrapeFunc = async ({ preferences, fromDate, toDate }) => { + const auth: Auth = { + token: preferences.token, + startDate: preferences.startDate + } + toDate = toDate ?? new Date() + fromDate = fromDate ?? new Date(preferences.startDate) + + const currentUser = await fetchCurrentUser(auth) + + // Get all expenses to create accounts + const allExpenses = await fetchExpenses(auth, new Date(preferences.startDate), toDate) + const accounts = convertAccounts(allExpenses, await fetchBalances(auth)) + + // Get expenses for the sync period + const apiExpenses = fromDate.getTime() === new Date(preferences.startDate).getTime() + ? allExpenses // Reuse expenses if it's initial sync + : await fetchExpenses(auth, fromDate, toDate) + + const transactions = apiExpenses + .map((expense) => convertTransaction(expense, currentUser.id)) + .filter((transaction): transaction is Transaction => transaction !== null) + + return { + accounts, + transactions + } +} diff --git a/src/plugins/splitwise/models.ts b/src/plugins/splitwise/models.ts new file mode 100644 index 00000000..233adf10 --- /dev/null +++ b/src/plugins/splitwise/models.ts @@ -0,0 +1,50 @@ +export interface Preferences { + token: string + startDate: string +} + +export interface Auth { + token: string + startDate: string +} + +export interface SplitwiseExpense { + id: number + description: string + details: string + payment: boolean + cost: string + date: string + created_at: string + updated_at: string + currency_code: string + users: Array<{ + user_id: number + paid_share: string + owed_share: string + }> +} + +export interface SplitwiseUser { + id: number + first_name: string + last_name: string + email: string + registration_status: string + picture: { + small: string + medium: string + large: string + } + default_currency: string + locale: string + balance?: Record +} + +export interface SplitwiseGroup { + id: number + name: string + group_type: string + members: SplitwiseUser[] + currency_code: string +} diff --git a/src/plugins/splitwise/preferences.xml b/src/plugins/splitwise/preferences.xml new file mode 100644 index 00000000..cc68345b --- /dev/null +++ b/src/plugins/splitwise/preferences.xml @@ -0,0 +1,23 @@ + + + + + From a322944f6d120fe0146f9f148d212b1f1daa1963 Mon Sep 17 00:00:00 2001 From: Aleksandr Beliakov Date: Thu, 9 Jan 2025 14:23:54 +0800 Subject: [PATCH 2/5] Fix date issues and update description --- src/plugins/splitwise/ZenmoneyManifest.xml | 1 + src/plugins/splitwise/converters.ts | 13 +++++++++- src/plugins/splitwise/fetchApi.ts | 29 ++++++++++++++++++---- src/plugins/splitwise/index.ts | 27 +++++++++++++++----- 4 files changed, 58 insertions(+), 12 deletions(-) diff --git a/src/plugins/splitwise/ZenmoneyManifest.xml b/src/plugins/splitwise/ZenmoneyManifest.xml index 42ab260c..2699c79b 100644 --- a/src/plugins/splitwise/ZenmoneyManifest.xml +++ b/src/plugins/splitwise/ZenmoneyManifest.xml @@ -7,6 +7,7 @@ Input your API token from https://secure.splitwise.com/apps. This plugin synchronizes your Splitwise expenses with ZenMoney. + After first sync update Splitwise Account balances manually. ## How it works diff --git a/src/plugins/splitwise/converters.ts b/src/plugins/splitwise/converters.ts index 77996674..6ef5e850 100644 --- a/src/plugins/splitwise/converters.ts +++ b/src/plugins/splitwise/converters.ts @@ -1,12 +1,23 @@ import { AccountType, Account, Transaction } from '../../types/zenmoney' import { SplitwiseExpense } from './models' -export function convertAccounts (expenses: SplitwiseExpense[], balances: Record): Account[] { +export function convertAccounts (expenses: SplitwiseExpense[]): Account[] { // Get currencies only from expenses where user was involved const currencies = new Set(expenses .filter((expense) => expense.users.some((user) => parseFloat(user.owed_share) > 0)) .map((expense) => expense.currency_code)) + // Calculate balances from expenses + const balances: Record = {} + for (const expense of expenses) { + for (const user of expense.users) { + const owedShare = parseFloat(user.owed_share) + if (owedShare > 0) { + balances[expense.currency_code] = (balances[expense.currency_code] ?? 0) - owedShare + } + } + } + return Array.from(currencies).map((currency) => ({ id: currency, type: AccountType.checking, diff --git a/src/plugins/splitwise/fetchApi.ts b/src/plugins/splitwise/fetchApi.ts index d7effc37..d2e860aa 100644 --- a/src/plugins/splitwise/fetchApi.ts +++ b/src/plugins/splitwise/fetchApi.ts @@ -1,7 +1,10 @@ import { Auth, SplitwiseExpense, SplitwiseUser } from './models' async function fetchApi (path: string, auth: Auth, params: Record = {}): Promise { - const response = await fetch(`https://secure.splitwise.com/api/v3.0${path}`, { + const queryString = new URLSearchParams(params as Record).toString() + const url = `https://secure.splitwise.com/api/v3.0${path}${queryString ? '?' + queryString : ''}` + + const response = await fetch(url, { method: 'GET', headers: { Authorization: `Bearer ${auth.token}` @@ -16,7 +19,20 @@ async function fetchApi (path: string, auth: Auth, params: Record { @@ -25,10 +41,13 @@ export async function fetchCurrentUser (auth: Auth): Promise { } export async function fetchExpenses (auth: Auth, fromDate: Date, toDate: Date): Promise { - const response = await fetchApi('/get_expenses', auth, { - dated_after: fromDate.toISOString().split('T')[0], + const params: Record = { + dated_after: formatDate(fromDate), + dated_before: formatDate(toDate), limit: 0 - }) + } + + const response = await fetchApi('/get_expenses', auth, params) const expenses = (response as { expenses: SplitwiseExpense[] }).expenses return expenses.length > 0 ? expenses : [] } diff --git a/src/plugins/splitwise/index.ts b/src/plugins/splitwise/index.ts index 028a6a6c..6abdc68b 100644 --- a/src/plugins/splitwise/index.ts +++ b/src/plugins/splitwise/index.ts @@ -1,24 +1,39 @@ import { ScrapeFunc, Transaction } from '../../types/zenmoney' -import { fetchExpenses, fetchCurrentUser, fetchBalances } from './fetchApi' +import { fetchExpenses, fetchCurrentUser } from './fetchApi' import { convertAccounts, convertTransaction } from './converters' import { Auth, Preferences } from './models' +import { InvalidPreferencesError } from '../../errors' export const scrape: ScrapeFunc = async ({ preferences, fromDate, toDate }) => { + if (!preferences.token) { + throw new InvalidPreferencesError('Token is required') + } + const auth: Auth = { token: preferences.token, - startDate: preferences.startDate + startDate: preferences.startDate ?? '2000-01-01' } + + // Ensure valid dates toDate = toDate ?? new Date() - fromDate = fromDate ?? new Date(preferences.startDate) + fromDate = fromDate ?? new Date(auth.startDate) + + // Validate dates + if (isNaN(fromDate.getTime())) { + fromDate = new Date('2000-01-01') + } + if (isNaN(toDate.getTime())) { + toDate = new Date() + } const currentUser = await fetchCurrentUser(auth) // Get all expenses to create accounts - const allExpenses = await fetchExpenses(auth, new Date(preferences.startDate), toDate) - const accounts = convertAccounts(allExpenses, await fetchBalances(auth)) + const allExpenses = await fetchExpenses(auth, fromDate, toDate) + const accounts = convertAccounts(allExpenses) // Get expenses for the sync period - const apiExpenses = fromDate.getTime() === new Date(preferences.startDate).getTime() + const apiExpenses = fromDate.getTime() === new Date(auth.startDate).getTime() ? allExpenses // Reuse expenses if it's initial sync : await fetchExpenses(auth, fromDate, toDate) From 8f5c30f8b911b562bc414b2c060b340c6f38949c Mon Sep 17 00:00:00 2001 From: Aleksandr Beliakov Date: Thu, 9 Jan 2025 14:28:02 +0800 Subject: [PATCH 3/5] Dont use toDate --- src/plugins/splitwise/fetchApi.ts | 3 +-- src/plugins/splitwise/index.ts | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/plugins/splitwise/fetchApi.ts b/src/plugins/splitwise/fetchApi.ts index d2e860aa..20c91f71 100644 --- a/src/plugins/splitwise/fetchApi.ts +++ b/src/plugins/splitwise/fetchApi.ts @@ -40,10 +40,9 @@ export async function fetchCurrentUser (auth: Auth): Promise { return (response as { user: SplitwiseUser }).user } -export async function fetchExpenses (auth: Auth, fromDate: Date, toDate: Date): Promise { +export async function fetchExpenses (auth: Auth, fromDate: Date): Promise { const params: Record = { dated_after: formatDate(fromDate), - dated_before: formatDate(toDate), limit: 0 } diff --git a/src/plugins/splitwise/index.ts b/src/plugins/splitwise/index.ts index 6abdc68b..02521c57 100644 --- a/src/plugins/splitwise/index.ts +++ b/src/plugins/splitwise/index.ts @@ -29,13 +29,13 @@ export const scrape: ScrapeFunc = async ({ preferences, fromDate, t const currentUser = await fetchCurrentUser(auth) // Get all expenses to create accounts - const allExpenses = await fetchExpenses(auth, fromDate, toDate) + const allExpenses = await fetchExpenses(auth, fromDate) const accounts = convertAccounts(allExpenses) // Get expenses for the sync period const apiExpenses = fromDate.getTime() === new Date(auth.startDate).getTime() ? allExpenses // Reuse expenses if it's initial sync - : await fetchExpenses(auth, fromDate, toDate) + : await fetchExpenses(auth, fromDate) const transactions = apiExpenses .map((expense) => convertTransaction(expense, currentUser.id)) From ffebca93edc7aab438dd979ff07ae0261794c4ab Mon Sep 17 00:00:00 2001 From: Aleksandr Beliakov Date: Thu, 9 Jan 2025 14:31:12 +0800 Subject: [PATCH 4/5] Handle deleted or modified splitwise transactions --- src/plugins/splitwise/converters.ts | 9 ++++++--- src/plugins/splitwise/index.ts | 6 +++--- src/plugins/splitwise/models.ts | 1 + 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/plugins/splitwise/converters.ts b/src/plugins/splitwise/converters.ts index 6ef5e850..f78cb1c3 100644 --- a/src/plugins/splitwise/converters.ts +++ b/src/plugins/splitwise/converters.ts @@ -29,15 +29,18 @@ export function convertAccounts (expenses: SplitwiseExpense[]): Account[] { } export function convertTransaction (expense: SplitwiseExpense, currentUserId: number): Transaction | null { - const currentUser = expense.users.find((user) => user.user_id === currentUserId) + // Skip deleted expenses - ZenMoney will handle them automatically + if (expense.deleted_at) { + return null + } + const currentUser = expense.users.find((user) => user.user_id === currentUserId) if (!currentUser) { return null } const owedShare = parseFloat(currentUser.owed_share) - - // Only create transaction if user owes money + // Skip if user doesn't owe money if (owedShare <= 0) { return null } diff --git a/src/plugins/splitwise/index.ts b/src/plugins/splitwise/index.ts index 02521c57..6608c308 100644 --- a/src/plugins/splitwise/index.ts +++ b/src/plugins/splitwise/index.ts @@ -28,13 +28,13 @@ export const scrape: ScrapeFunc = async ({ preferences, fromDate, t const currentUser = await fetchCurrentUser(auth) - // Get all expenses to create accounts + // Get all expenses including deleted ones const allExpenses = await fetchExpenses(auth, fromDate) - const accounts = convertAccounts(allExpenses) + const accounts = convertAccounts(allExpenses.filter(e => !e.deleted_at)) // Get expenses for the sync period const apiExpenses = fromDate.getTime() === new Date(auth.startDate).getTime() - ? allExpenses // Reuse expenses if it's initial sync + ? allExpenses : await fetchExpenses(auth, fromDate) const transactions = apiExpenses diff --git a/src/plugins/splitwise/models.ts b/src/plugins/splitwise/models.ts index 233adf10..f5b99566 100644 --- a/src/plugins/splitwise/models.ts +++ b/src/plugins/splitwise/models.ts @@ -17,6 +17,7 @@ export interface SplitwiseExpense { date: string created_at: string updated_at: string + deleted_at?: string currency_code: string users: Array<{ user_id: number From 4bfae603fcf25068b32dcc17372512f5aaa7a60f Mon Sep 17 00:00:00 2001 From: Aleksandr Beliakov Date: Thu, 9 Jan 2025 14:45:41 +0800 Subject: [PATCH 5/5] Linter and tests fixes --- .../splitwise/__tests__/converters.test.ts | 50 +++++++++++++------ src/plugins/splitwise/converters.ts | 2 +- src/plugins/splitwise/fetchApi.ts | 6 +-- src/plugins/splitwise/index.ts | 4 +- 4 files changed, 40 insertions(+), 22 deletions(-) diff --git a/src/plugins/splitwise/__tests__/converters.test.ts b/src/plugins/splitwise/__tests__/converters.test.ts index 13a57a8d..c3f33232 100644 --- a/src/plugins/splitwise/__tests__/converters.test.ts +++ b/src/plugins/splitwise/__tests__/converters.test.ts @@ -3,7 +3,7 @@ import { SplitwiseExpense } from '../models' import { AccountType } from '../../../types/zenmoney' describe('convertAccounts', () => { - it('should create accounts only for currencies where user owes money', () => { + it('should create accounts for all currencies with owed money', () => { const expenses: SplitwiseExpense[] = [ { id: 1, @@ -37,12 +37,7 @@ describe('convertAccounts', () => { } ] - const balances = { - USD: -50, - EUR: 10 - } - - const accounts = convertAccounts(expenses, balances) + const accounts = convertAccounts(expenses) expect(accounts).toHaveLength(2) expect(accounts).toEqual([ { @@ -50,7 +45,7 @@ describe('convertAccounts', () => { type: AccountType.checking, title: 'Splitwise USD', instrument: 'USD', - balance: -50, + balance: -100, syncIds: ['SWUSD0'] }, { @@ -58,11 +53,35 @@ describe('convertAccounts', () => { type: AccountType.checking, title: 'Splitwise EUR', instrument: 'EUR', - balance: 10, + balance: -10, syncIds: ['SWEUR0'] } ]) }) + + it('should skip deleted expenses when creating accounts', () => { + const expenses: SplitwiseExpense[] = [ + { + id: 1, + description: 'Lunch', + details: '', + payment: false, + cost: '100.00', + date: '2024-01-08', + created_at: '2024-01-08T15:08:03Z', + updated_at: '2024-01-08T15:08:03Z', + deleted_at: '2024-01-09T10:00:00Z', + currency_code: 'USD', + users: [ + { user_id: 1, paid_share: '0', owed_share: '50.00' }, + { user_id: 2, paid_share: '100.00', owed_share: '50.00' } + ] + } + ] + + const accounts = convertAccounts(expenses.filter(e => e.deleted_at == null)) + expect(accounts).toHaveLength(0) + }) }) describe('convertTransaction', () => { @@ -103,20 +122,21 @@ describe('convertTransaction', () => { }) }) - it('should return null when user does not owe money', () => { + it('should return null for deleted expenses', () => { const expense: SplitwiseExpense = { id: 1, - description: 'Coffee', + description: 'Lunch', details: '', payment: false, - cost: '10.00', + cost: '100.00', date: '2024-01-08', created_at: '2024-01-08T15:08:03Z', updated_at: '2024-01-08T15:08:03Z', - currency_code: 'EUR', + deleted_at: '2024-01-09T10:00:00Z', + currency_code: 'USD', users: [ - { user_id: 1, paid_share: '10.00', owed_share: '0' }, - { user_id: 2, paid_share: '0', owed_share: '10.00' } + { user_id: 1, paid_share: '0', owed_share: '50.00' }, + { user_id: 2, paid_share: '100.00', owed_share: '50.00' } ] } diff --git a/src/plugins/splitwise/converters.ts b/src/plugins/splitwise/converters.ts index f78cb1c3..b537360c 100644 --- a/src/plugins/splitwise/converters.ts +++ b/src/plugins/splitwise/converters.ts @@ -30,7 +30,7 @@ export function convertAccounts (expenses: SplitwiseExpense[]): Account[] { export function convertTransaction (expense: SplitwiseExpense, currentUserId: number): Transaction | null { // Skip deleted expenses - ZenMoney will handle them automatically - if (expense.deleted_at) { + if (expense.deleted_at != null) { return null } diff --git a/src/plugins/splitwise/fetchApi.ts b/src/plugins/splitwise/fetchApi.ts index 20c91f71..9d134474 100644 --- a/src/plugins/splitwise/fetchApi.ts +++ b/src/plugins/splitwise/fetchApi.ts @@ -2,7 +2,7 @@ import { Auth, SplitwiseExpense, SplitwiseUser } from './models' async function fetchApi (path: string, auth: Auth, params: Record = {}): Promise { const queryString = new URLSearchParams(params as Record).toString() - const url = `https://secure.splitwise.com/api/v3.0${path}${queryString ? '?' + queryString : ''}` + const url = `https://secure.splitwise.com/api/v3.0${path}${(queryString !== '') ? '?' + queryString : ''}` const response = await fetch(url, { method: 'GET', @@ -19,7 +19,7 @@ async function fetchApi (path: string, auth: Auth, params: Record = async ({ preferences, fromDate, toDate }) => { - if (!preferences.token) { + if (preferences.token === '') { throw new InvalidPreferencesError('Token is required') } @@ -30,7 +30,7 @@ export const scrape: ScrapeFunc = async ({ preferences, fromDate, t // Get all expenses including deleted ones const allExpenses = await fetchExpenses(auth, fromDate) - const accounts = convertAccounts(allExpenses.filter(e => !e.deleted_at)) + const accounts = convertAccounts(allExpenses.filter(e => e.deleted_at == null)) // Get expenses for the sync period const apiExpenses = fromDate.getTime() === new Date(auth.startDate).getTime()