diff --git a/src/plugins/splitwise/ZenmoneyManifest.xml b/src/plugins/splitwise/ZenmoneyManifest.xml new file mode 100644 index 00000000..2699c79b --- /dev/null +++ b/src/plugins/splitwise/ZenmoneyManifest.xml @@ -0,0 +1,50 @@ + + + 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. + After first sync update Splitwise Account balances manually. + + ## 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..c3f33232 --- /dev/null +++ b/src/plugins/splitwise/__tests__/converters.test.ts @@ -0,0 +1,146 @@ +import { convertAccounts, convertTransaction } from '../converters' +import { SplitwiseExpense } from '../models' +import { AccountType } from '../../../types/zenmoney' + +describe('convertAccounts', () => { + it('should create accounts for all currencies with owed 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 accounts = convertAccounts(expenses) + expect(accounts).toHaveLength(2) + expect(accounts).toEqual([ + { + id: 'USD', + type: AccountType.checking, + title: 'Splitwise USD', + instrument: 'USD', + balance: -100, + syncIds: ['SWUSD0'] + }, + { + id: 'EUR', + type: AccountType.checking, + title: 'Splitwise EUR', + instrument: 'EUR', + 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', () => { + 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 for deleted expenses', () => { + 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', + 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 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..b537360c --- /dev/null +++ b/src/plugins/splitwise/converters.ts @@ -0,0 +1,65 @@ +import { AccountType, Account, Transaction } from '../../types/zenmoney' +import { SplitwiseExpense } from './models' + +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, + title: `Splitwise ${currency}`, + instrument: currency, + balance: balances[currency] ?? 0, + syncIds: [`SW${currency.padEnd(4, '0')}`] + })) +} + +export function convertTransaction (expense: SplitwiseExpense, currentUserId: number): Transaction | null { + // Skip deleted expenses - ZenMoney will handle them automatically + if (expense.deleted_at != null) { + return null + } + + const currentUser = expense.users.find((user) => user.user_id === currentUserId) + if (!currentUser) { + return null + } + + const owedShare = parseFloat(currentUser.owed_share) + // Skip if user doesn't owe 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..9d134474 --- /dev/null +++ b/src/plugins/splitwise/fetchApi.ts @@ -0,0 +1,68 @@ +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 response = await fetch(url, { + 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() +} + +function formatDate (date: Date): string { + // Ensure we have a valid date + if (!(date instanceof Date) || isNaN(date.getTime())) { + date = new Date() + } + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +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): Promise { + const params: Record = { + dated_after: formatDate(fromDate), + limit: 0 + } + + const response = await fetchApi('/get_expenses', auth, params) + 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..b2e73958 --- /dev/null +++ b/src/plugins/splitwise/index.ts @@ -0,0 +1,48 @@ +import { ScrapeFunc, Transaction } from '../../types/zenmoney' +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 ?? '2000-01-01' + } + + // Ensure valid dates + toDate = toDate ?? new Date() + 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 including deleted ones + const allExpenses = await fetchExpenses(auth, fromDate) + 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() + ? allExpenses + : await fetchExpenses(auth, fromDate) + + 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..f5b99566 --- /dev/null +++ b/src/plugins/splitwise/models.ts @@ -0,0 +1,51 @@ +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 + deleted_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 @@ + + + + +