diff --git a/.changeset/feat-transfer-accounts.md b/.changeset/feat-transfer-accounts.md new file mode 100644 index 0000000..e1e7548 --- /dev/null +++ b/.changeset/feat-transfer-accounts.md @@ -0,0 +1,5 @@ +--- +"@tim-smart/actualbudget-sync": minor +--- + +feat: (UP) add --transfer-accounts flag for cross-user 2UP deduplication diff --git a/README.md b/README.md index df9c36b..5ee8e6a 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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 diff --git a/src/Actual.ts b/src/Actual.ts index c0e6e02..88a3c26 100644 --- a/src/Actual.ts +++ b/src/Actual.ts @@ -2,6 +2,7 @@ * @since 1.0.0 */ import { + Array, Config, Data, Effect, @@ -156,7 +157,21 @@ export class Actual extends ServiceMap.Service()("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((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( diff --git a/src/Bank/Up.test.ts b/src/Bank/Up.test.ts index 87a813c..fac325f 100644 --- a/src/Bank/Up.test.ts +++ b/src/Bank/Up.test.ts @@ -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>([]) + + 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", + ) + }), +) diff --git a/src/Sync.ts b/src/Sync.ts index 727626b..70b7532 100644 --- a/src/Sync.ts +++ b/src/Sync.ts @@ -8,6 +8,7 @@ import { Duration, Effect, FiberSet, + Option, pipe, } from "effect" import { @@ -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 @@ -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) @@ -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 @@ -224,9 +237,24 @@ export const run = Effect.fnUntraced(function* (options: { yield* FiberSet.awaitEmpty(fibers) for (const [actualAccountId, transactions] of newTransactions) { + let toImport: Array = [] + 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) diff --git a/src/main.ts b/src/main.ts index 97524ef..33e861c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -63,9 +63,17 @@ const clearedOnly = Flag.boolean("cleared-only").pipe( Flag.withAlias("C"), ) +const transferAccounts = Flag.keyValuePair("transfer-accounts").pipe( + Flag.optional, + Flag.withDescription( + "Accounts used for transfer resolution only (not synced), in the format 'actual-account-id=bank-account-id'", + ), +) + const actualsync = Command.make("actualsync", { bank, accounts, + transferAccounts, categorize, categories, timezone, @@ -73,7 +81,15 @@ const actualsync = Command.make("actualsync", { clearedOnly, }).pipe( Command.withHandler( - ({ accounts, categorize, categories, bank, syncDuration, clearedOnly }) => + ({ + accounts, + transferAccounts, + categorize, + categories, + bank, + syncDuration, + clearedOnly, + }) => Sync.run({ accounts: Object.entries(accounts).map( ([actualAccountId, bankAccountId]) => ({ @@ -81,6 +97,14 @@ const actualsync = Command.make("actualsync", { bankAccountId, }), ), + transferAccounts: Option.getOrUndefined( + Option.map(transferAccounts, (ta) => + Object.entries(ta).map(([actualAccountId, bankAccountId]) => ({ + actualAccountId, + bankAccountId, + })), + ), + ), categorize, categoryMapping: Option.getOrUndefined( Option.map(categories, (categoriesOption) =>