Skip to content
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
5 changes: 5 additions & 0 deletions .changeset/feat-transfer-accounts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tim-smart/actualbudget-sync": minor
---

feat: (UP) add --transfer-accounts flag for cross-user 2UP deduplication
39 changes: 32 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,30 @@ spec:
completions: 1
```

## Up Bank — Shared Accounts (2UP)

If you and a partner both use Up Bank and share a joint account (2UP), you will typically run two separate sync jobs — one per Up user token. Transfers between personal accounts (e.g. one partner sending money to the other) will appear in both feeds.

To avoid duplicate transactions, pass the other person's account mapping via `--transfer-accounts`. These accounts are used only to resolve the transfer payee; their transactions are not fetched.

```bash
# Partner A's sync
actualsync --bank up \
--accounts 'actual-a-id=up-a-id' \
--accounts 'actual-joint-id=up-joint-id' \
--transfer-accounts 'actual-b-id=up-b-id'

# Partner B's sync
actualsync --bank up \
--accounts 'actual-b-id=up-b-id' \
--accounts 'actual-joint-id=up-joint-id' \
--transfer-accounts 'actual-a-id=up-a-id'
```

When a transfer between the two personal accounts is detected, the sync imports it as a proper Actual Budget transfer on the first run and skips the counterpart on the second run, preventing duplicates.

> If you previously had Actual Budget rules classifying `$username` payees as transfers, remove them — the sync now handles this automatically.

## Development / Debugging

A VSCode launch configuration is included for running the Up Bank sync locally. To use it:
Expand All @@ -142,13 +166,14 @@ USAGE
actualsync [flags]

FLAGS
--bank choice Which bank to use
--accounts key=value Accounts to sync, in the format 'actual-account-id=bank-account-id'
--categorize, -c If the bank supports categorization, try to categorize transactions
--categories key=value Requires --categorize to have any effect. Maps the banks values to actual values with the format 'bank-category=actual-category'
--timezone string The timezone to use to display transaction timestamps. Defaults to the bank timezone.
--sync-days integer Number of days to sync (default: 30)
--cleared-only, -C Only sync cleared transactions
--bank choice Which bank to use
--accounts key=value Accounts to sync, in the format 'actual-account-id=bank-account-id'
--transfer-accounts key=value Accounts used for transfer resolution only (not synced), in the format 'actual-account-id=bank-account-id'
--categorize, -c If the bank supports categorization, try to categorize transactions
--categories key=value Requires --categorize to have any effect. Maps the banks values to actual values with the format 'bank-category=actual-category'
--timezone string The timezone to use to display transaction timestamps. Defaults to the bank timezone.
--sync-days integer Number of days to sync (default: 30)
--cleared-only, -C Only sync cleared transactions

GLOBAL FLAGS
--help, -h Show help information
Expand Down
17 changes: 16 additions & 1 deletion src/Actual.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* @since 1.0.0
*/
import {
Array,
Config,
Data,
Effect,
Expand Down Expand Up @@ -156,7 +157,21 @@ export class Actual extends ServiceMap.Service<Actual>()("Actual", {
)
}

return { use, query, findImported } as const
// Find an auto-created transfer counterpart (no imported_id) matching the
// given account and amount. Used to detect when the other side of a
// cross-user transfer has already been imported by a separate sync run.
const findTransferCounterpart = (accountId: string, amount: number) =>
query<TransactionEntity>((q) =>
q("transactions").select(["*"]).filter({ account: accountId, amount }),
).pipe(
Effect.map(
Array.findFirst(
(t) => t.transfer_id != null && t.imported_id == null,
),
),
)

return { use, query, findImported, findTransferCounterpart } as const
}),
}) {
static layer = Layer.effect(this)(this.make).pipe(
Expand Down
208 changes: 208 additions & 0 deletions src/Bank/Up.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -805,3 +805,211 @@ it.effect(
)
}),
)

// ---------------------------------------------------------------------------
// Test 6a — transferAccounts: joint account transferring to a partner's
// personal account that is listed only in transferAccounts.
//
// The joint account pays out to personal-b (e.g. splitting expenses). Run A
// syncs [personal-a, joint] and lists personal-b as a transfer-only account.
// The joint → personal-b transaction must resolve to "pb-payee", and
// personal-b's endpoint must never be fetched.
// ---------------------------------------------------------------------------

it.effect(
"transferAccounts (joint→personal): joint transfer to partner account resolves payee without fetching it",
() =>
Effect.gen(function* () {
const requestedUrls = yield* Ref.make<ReadonlyArray<string>>([])

const crossPayees = [
{
id: "pa-payee",
name: "Personal A",
transfer_acct: "actual-personal-a",
},
{
id: "pb-payee",
name: "Personal B",
transfer_acct: "actual-personal-b",
},
{ id: "joint-payee", name: "Joint", transfer_acct: "actual-joint" },
]

const jointTxns = [
makeTransaction("joint-to-pb", {
description: "Transfer to Personal B",
amountBaseUnits: -20000,
settledAt: "2024-01-20T10:00:00+11:00",
transferAccountId: "personal-b",
}),
makeTransaction("joint-groceries", {
description: "Groceries",
amountBaseUnits: -5000,
settledAt: "2024-01-21T10:00:00+11:00",
}),
]

const layer = makeUpTestLayer((req) =>
Effect.gen(function* () {
yield* Ref.update(requestedUrls, (urls) => [...urls, req.url])
return HttpClientResponse.fromWeb(
req,
makePage(req.url.includes("/joint/") ? jointTxns : [], null),
)
}),
)

const results = yield* runCollect({
accounts: [
{ bankAccountId: "personal-a", actualAccountId: "actual-personal-a" },
{ bankAccountId: "joint", actualAccountId: "actual-joint" },
],
transferAccounts: [
{ bankAccountId: "personal-b", actualAccountId: "actual-personal-b" },
],
syncDuration: Duration.days(30),
categorize: false,
categories: testCategories,
payees: crossPayees,
}).pipe(Effect.provide(layer))

const jointAcc = results.find(
(r) => r.actualAccountId === "actual-joint",
)!

// joint → personal-b resolves to "pb-payee" via transferAccounts
const transferTx = jointAcc.transactions.find(
(t) => "payee" in t && (t as { payee: string }).payee === "pb-payee",
)
assert.exists(
transferTx,
"joint→personal-b should resolve to pb-payee via transferAccounts",
)
assert.equal(transferTx!.amount, -20000)

// Unrelated joint transaction still falls back to payee_name
const groceryTx = jointAcc.transactions.find(
(t) => payeeName(t) === "Groceries",
)
assert.exists(groceryTx, "non-transfer transaction should use payee_name")

const urls = yield* Ref.get(requestedUrls)
assert.isFalse(
urls.some((url) => url.includes("/personal-b/")),
"personal-b is in transferAccounts only — its endpoint must not be fetched",
)
assert.isTrue(
urls.some((url) => url.includes("/personal-a/")),
"personal-a is in accounts — its endpoint must be fetched",
)
}),
)

// ---------------------------------------------------------------------------
// Test 6b — transferAccounts: personal account transferring directly to a
// partner's personal account listed only in transferAccounts.
//
// Partner A sends money to Partner B (personal-a → personal-b). Run A syncs
// [personal-a, joint] and lists personal-b as a transfer-only account.
// The personal-a → personal-b transaction must resolve to "pb-payee".
// ---------------------------------------------------------------------------

it.effect(
"transferAccounts (personal→personal): direct transfer to partner account resolves payee",
() =>
Effect.gen(function* () {
const crossPayees = [
{
id: "pa-payee",
name: "Personal A",
transfer_acct: "actual-personal-a",
},
{
id: "pb-payee",
name: "Personal B",
transfer_acct: "actual-personal-b",
},
{ id: "joint-payee", name: "Joint", transfer_acct: "actual-joint" },
]

const personalATxns = [
// Direct transfer from personal-a to personal-b
makeTransaction("pa-to-pb", {
description: "Transfer to Partner",
amountBaseUnits: -50000,
settledAt: "2024-01-20T10:00:00+11:00",
transferAccountId: "personal-b",
}),
makeTransaction("pa-coffee", {
description: "Coffee",
amountBaseUnits: -450,
settledAt: "2024-01-21T10:00:00+11:00",
}),
]

const layer = makeUpTestLayer((req) =>
Effect.succeed(
HttpClientResponse.fromWeb(
req,
makePage(
req.url.includes("/personal-a/") ? personalATxns : [],
null,
),
),
),
)

const results = yield* runCollect({
accounts: [
{ bankAccountId: "personal-a", actualAccountId: "actual-personal-a" },
{ bankAccountId: "joint", actualAccountId: "actual-joint" },
],
transferAccounts: [
{ bankAccountId: "personal-b", actualAccountId: "actual-personal-b" },
],
syncDuration: Duration.days(30),
categorize: false,
categories: testCategories,
payees: crossPayees,
}).pipe(Effect.provide(layer))

const personalAAcc = results.find(
(r) => r.actualAccountId === "actual-personal-a",
)!

// personal-a → personal-b resolves to "pb-payee" via transferAccounts
const transferTx = personalAAcc.transactions.find(
(t) => "payee" in t && (t as { payee: string }).payee === "pb-payee",
)
assert.exists(
transferTx,
"personal-a→personal-b should resolve to pb-payee via transferAccounts",
)
assert.equal(transferTx!.amount, -50000)

// Without transferAccounts this same transaction would have fallen back
// to payee_name — verify the fallback behaviour for contrast
const resultsNoTransfer = yield* runCollect({
accounts: [
{ bankAccountId: "personal-a", actualAccountId: "actual-personal-a" },
{ bankAccountId: "joint", actualAccountId: "actual-joint" },
],
syncDuration: Duration.days(30),
categorize: false,
categories: testCategories,
payees: crossPayees,
}).pipe(Effect.provide(layer))

const personalANoTransfer = resultsNoTransfer.find(
(r) => r.actualAccountId === "actual-personal-a",
)!
const fallbackTx = personalANoTransfer.transactions.find(
(t) => payeeName(t) === "Transfer to Partner",
)
assert.exists(
fallbackTx,
"without transferAccounts, the same transaction should fall back to payee_name",
)
}),
)
32 changes: 30 additions & 2 deletions src/Sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
Duration,
Effect,
FiberSet,
Option,
pipe,
} from "effect"
import {
Expand All @@ -26,6 +27,10 @@ export const runCollect = Effect.fnUntraced(function* (options: {
readonly bankAccountId: string
readonly actualAccountId: string
}>
readonly transferAccounts?: ReadonlyArray<{
readonly bankAccountId: string
readonly actualAccountId: string
}>
readonly categorize: boolean
readonly categoryMapping?: ReadonlyArray<{
readonly bankCategory: string
Expand Down Expand Up @@ -56,8 +61,12 @@ export const runCollect = Effect.fnUntraced(function* (options: {
return category ? category.id : undefined
}

const allAccountsForTransfer = [
...options.accounts,
...(options.transferAccounts ?? []),
]
const transferAccountId = (transaction: AccountTransaction) => {
const transferToAccount = options.accounts.find(
const transferToAccount = allAccountsForTransfer.find(
({ bankAccountId }) => bankAccountId === transaction.transfer,
)?.actualAccountId
return options.payees.find((it) => it.transfer_acct === transferToAccount)
Expand Down Expand Up @@ -137,6 +146,10 @@ export const run = Effect.fnUntraced(function* (options: {
readonly bankAccountId: string
readonly actualAccountId: string
}>
readonly transferAccounts?: ReadonlyArray<{
readonly bankAccountId: string
readonly actualAccountId: string
}>
readonly categorize: boolean
readonly categoryMapping?: ReadonlyArray<{
readonly bankCategory: string
Expand Down Expand Up @@ -224,9 +237,24 @@ export const run = Effect.fnUntraced(function* (options: {
yield* FiberSet.awaitEmpty(fibers)

for (const [actualAccountId, transactions] of newTransactions) {
let toImport: Array<ImportTransaction> = []
for (const transaction of transactions) {
// If this is a transfer transaction, check whether the other sync run
// already imported the opposite side — Actual will have auto-created a
// counterpart in this account. If one exists, skip importing to avoid
// creating a second transfer pair (and duplicate counterparts).
if ("payee" in transaction) {
const counterpart = yield* actual.findTransferCounterpart(
actualAccountId,
transaction.amount,
)
if (Option.isSome(counterpart)) continue
}
toImport.push(transaction)
}
yield* FiberSet.run(
fibers,
actual.use((_) => _.importTransactions(actualAccountId, transactions)),
actual.use((_) => _.importTransactions(actualAccountId, toImport)),
)
}
yield* FiberSet.awaitEmpty(fibers)
Expand Down
Loading
Loading