Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add sync with Splitwise #804

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions src/plugins/splitwise/ZenmoneyManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<provider>
<id>splitwise</id>
<company>15881</company>
<description>
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)
</description>
<version>1.0.0</version>
<build>1</build>
<modular>true</modular>
<files>
<js>index.js</js>
<preferences>preferences.xml</preferences>
</files>
<codeRequired>false</codeRequired>
</provider>
146 changes: 146 additions & 0 deletions src/plugins/splitwise/__tests__/converters.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
67 changes: 67 additions & 0 deletions src/plugins/splitwise/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -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: '[email protected]',
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)
})
})
65 changes: 65 additions & 0 deletions src/plugins/splitwise/converters.ts
Original file line number Diff line number Diff line change
@@ -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<string, number> = {}
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
}
}
Loading