Skip to content

Commit 70402b8

Browse files
feat: bill auto-detect patterns now mark bills paid on import (#274)
The matcher (BillService::matchTransactionToBill) existed since the initial commit but had zero callers — the form's promise of auto-linking never ran. Imports (CSV/OFX/QIF, both pipelines) and bank sync now call autoMatchPaidFromImport after persisting rows: a debit matching a bill's pattern (description or vendor), within 10% of the amount, on the bill's account, and dated within the due window marks the bill paid with the transaction LINKED (never duplicated). Marking paid advances the due date, which inherently prevents same-period duplicates and historical re-imports from double-advancing. Import summary reports the count. 8 new unit tests + live verification; help text and docs updated.
1 parent ca6af6e commit 70402b8

14 files changed

Lines changed: 339 additions & 69 deletions

File tree

budget/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2525
- Deep links into the app (`#/transactions`, `#/bills`, …) are now honored on page load, including a pre-filled transaction search (`#/transactions?search=…`)
2626

2727
### Fixed
28+
- **Bill auto-detect patterns now actually work** ([#274](https://github.com/otherworld-dev/Budget/issues/274)): the bill form has always offered a "pattern to match in transaction descriptions", but no import path ever ran the matcher. Imports and bank syncs now mark a bill as paid when a transaction matches its pattern (amount within 10%, account agreement, dated within the bill's due window) — the transaction is linked, never duplicated, the due date advances, and the Bills Calendar ticks the month off. Same-period duplicates and historical re-imports cannot double-advance a bill; the import summary reports how many bills were marked paid
2829
- Transaction search is now case-insensitive on PostgreSQL and SQLite (plain `LIKE` is case-sensitive there — searching "rent" missed "Rent"; MySQL/MariaDB installs were unaffected)
2930
- Bank sync connections no longer get permanently stuck after a failed sync: one transient provider failure (bridge outage, lapsed SimpleFIN subscription, network error) set the connection to an error state that every later sync — manual and the daily background job — refused to touch ("Connection is not active"), with no path back. Failed connections are now retried, so they self-heal once the provider recovers, and the real provider error is surfaced instead of the status gate ([#277](https://github.com/otherworld-dev/Budget/issues/277))
3031

budget/js/budget-app.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

budget/js/budget-app.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

budget/lib/Service/BankSync/BankSyncService.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ public function __construct(
3131
private \OCA\Budget\Db\DismissedImportMapper $dismissedImportMapper,
3232
private \OCA\Budget\Service\Import\ImportRuleApplicator $ruleApplicator,
3333
private \OCA\Budget\Service\TransactionTagService $transactionTagService,
34+
private \OCA\Budget\Service\BillService $billService,
3435
private IL10N $l,
3536
private LoggerInterface $logger
3637
) {
@@ -198,6 +199,7 @@ public function sync(string $userId, int $connectionId, bool $force = false): ar
198199
}
199200

200201
$mappingsByExternalId = [];
202+
$createdForBillMatch = [];
201203
foreach ($enabledMappings as $m) {
202204
$mappingsByExternalId[$m->getExternalAccountId()] = $m;
203205
}
@@ -319,6 +321,7 @@ public function sync(string $userId, int $connectionId, bool $force = false): ar
319321
// application) is swallowed by the catch below, and the
320322
// persisted row must still get its balance recompute.
321323
$createdAny = true;
324+
$createdForBillMatch[] = $createdTx;
322325

323326
// Apply deferred tag actions from import rules
324327
if (!empty($txData['_deferred_tags'])) {
@@ -407,6 +410,16 @@ public function sync(string $userId, int $connectionId, bool $force = false): ar
407410
];
408411
}
409412

413+
// Auto-mark bills paid from matching synced transactions (#274).
414+
// Best-effort: a matching failure must never fail the sync.
415+
if (!empty($createdForBillMatch)) {
416+
try {
417+
$this->billService->autoMatchPaidFromImport($userId, $createdForBillMatch);
418+
} catch (\Exception $e) {
419+
$this->logger->warning('Bill auto-match after sync failed: ' . $e->getMessage(), ['app' => 'budget']);
420+
}
421+
}
422+
410423
// Update connection sync status
411424
$connection->setLastSyncAt(date('Y-m-d H:i:s'));
412425
$connection->setLastError($totalErrors > 0 ? $this->l->t('Sync completed with %1$s error(s)', [$totalErrors]) : null);

budget/lib/Service/BillService.php

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -847,6 +847,113 @@ public function matchTransactionToBill(string $userId, string $description, floa
847847
return null;
848848
}
849849

850+
/**
851+
* Auto-mark bills paid from freshly imported transactions (#274).
852+
*
853+
* For each imported debit that matches an active bill's auto-detect
854+
* pattern (amount within 10%, account matching when the bill has one,
855+
* transaction date within the bill's current due window), the bill is
856+
* marked paid with the transaction LINKED — no duplicate money movement
857+
* is ever recorded. Marking paid advances the due date, so a second
858+
* same-period transaction in the batch falls outside the new window and
859+
* cannot double-advance the bill.
860+
*
861+
* @param \OCA\Budget\Db\Transaction[] $transactions freshly created rows
862+
* @return int number of bills marked paid
863+
*/
864+
public function autoMatchPaidFromImport(string $userId, array $transactions): int {
865+
if (empty($transactions)) {
866+
return 0;
867+
}
868+
869+
$bills = array_filter(
870+
$this->findActive($userId),
871+
fn(Bill $bill) => !($bill->getIsTransfer() ?? false)
872+
&& !empty($bill->getAutoDetectPattern())
873+
&& $bill->getNextDueDate() !== null
874+
);
875+
if (empty($bills)) {
876+
return 0;
877+
}
878+
879+
$marked = 0;
880+
foreach ($transactions as $transaction) {
881+
if ($transaction->getType() !== 'debit') {
882+
continue;
883+
}
884+
if (($transaction->getStatus() ?? 'cleared') === 'scheduled') {
885+
continue;
886+
}
887+
888+
foreach ($bills as $key => $bill) {
889+
if (!$this->importedTransactionMatchesBill($bill, $transaction)) {
890+
continue;
891+
}
892+
893+
try {
894+
$this->markPaid($bill->getId(), $userId, $transaction->getDate(), false, $transaction->getId());
895+
$marked++;
896+
// Reload: the due date advanced (or the bill deactivated),
897+
// which is what keeps this loop from double-advancing
898+
$bills[$key] = $this->find($bill->getId(), $userId);
899+
if (!$bills[$key]->getIsActive() || $bills[$key]->getNextDueDate() === null) {
900+
unset($bills[$key]);
901+
}
902+
} catch (\Exception $e) {
903+
$this->logger->warning(
904+
"Auto-match failed marking bill {$bill->getId()} paid from imported transaction {$transaction->getId()}: {$e->getMessage()}"
905+
);
906+
}
907+
break; // one bill per transaction
908+
}
909+
}
910+
911+
return $marked;
912+
}
913+
914+
/**
915+
* Match an imported transaction against a bill: pattern in description
916+
* or vendor, amount within 10%, account agreement, and the transaction
917+
* date inside the bill's current due window (so historical re-imports
918+
* can't advance bills through future periods).
919+
*/
920+
private function importedTransactionMatchesBill(Bill $bill, \OCA\Budget\Db\Transaction $transaction): bool {
921+
$pattern = (string) $bill->getAutoDetectPattern();
922+
$haystack = $transaction->getDescription() . ' ' . ($transaction->getVendor() ?? '');
923+
if (stripos($haystack, $pattern) === false) {
924+
return false;
925+
}
926+
927+
$billAmount = (float) $bill->getAmount();
928+
if ($billAmount <= 0 || abs((float) $transaction->getAmount() - $billAmount) > $billAmount * 0.1) {
929+
return false;
930+
}
931+
932+
if ($bill->getAccountId() !== null && $bill->getAccountId() !== $transaction->getAccountId()) {
933+
return false;
934+
}
935+
936+
$dueDate = $bill->getNextDueDate();
937+
$daysOff = abs((strtotime($transaction->getDate()) - strtotime($dueDate)) / 86400);
938+
939+
return $daysOff <= $this->dueDateToleranceDays($bill->getFrequency());
940+
}
941+
942+
/**
943+
* How far an imported payment may sit from the due date and still count
944+
* as paying THIS occurrence — roughly half the recurrence interval,
945+
* capped so monthly+ bills accept payments up to two weeks early/late.
946+
*/
947+
private function dueDateToleranceDays(?string $frequency): int {
948+
return match ($frequency) {
949+
'daily' => 1,
950+
'weekly' => 3,
951+
'biweekly' => 6,
952+
'semi-monthly' => 7,
953+
default => 15, // monthly, quarterly, semi-annually, yearly, custom, one-time
954+
};
955+
}
956+
850957
/**
851958
* Attempt to auto-pay a bill and handle success/failure.
852959
*

budget/lib/Service/ImportService.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ public function __construct(
5454
TagSetService $tagSetService,
5555
TransactionTagService $transactionTagService,
5656
ImportAccountLinkService $accountLinkService,
57+
private \OCA\Budget\Service\BillService $billService,
5758
IL10N $l
5859
) {
5960
$this->appData = $appData;
@@ -721,6 +722,7 @@ private function executeMultiAccountImport(string $userId, string $fileId, strin
721722
$accountResults = [];
722723
$hashCounts = [];
723724
$touchedAccounts = [];
725+
$createdForBillMatch = [];
724726

725727
try {
726728
foreach ($parsedData['accounts'] as $sourceAccount) {
@@ -785,6 +787,7 @@ private function executeMultiAccountImport(string $userId, string $fileId, strin
785787
deferBalanceUpdate: true
786788
);
787789
$touchedAccounts[(int)$destAccountId] = true;
790+
$createdForBillMatch[] = $createdTx;
788791

789792
// Apply deferred tag actions from import rules
790793
if (!empty($transaction['_deferred_tags'])) {
@@ -845,6 +848,15 @@ private function executeMultiAccountImport(string $userId, string $fileId, strin
845848

846849
$totalProcessed = array_sum(array_map(fn($a) => count($a['transactions']), $parsedData['accounts']));
847850

851+
// Auto-mark bills paid from matching imported transactions (#274).
852+
// Best-effort: a matching failure must never fail the import.
853+
$billsMarkedPaid = 0;
854+
try {
855+
$billsMarkedPaid = $this->billService->autoMatchPaidFromImport($userId, $createdForBillMatch);
856+
} catch (\Exception $e) {
857+
// ignore
858+
}
859+
848860
// Remember the routing so the next same-format import can pre-fill it.
849861
// Best-effort: never let this break a completed import.
850862
try {
@@ -860,6 +872,7 @@ private function executeMultiAccountImport(string $userId, string $fileId, strin
860872
'totalProcessed' => $totalProcessed,
861873
'accountResults' => array_values($accountResults),
862874
'transfersLinked' => $transfersLinked,
875+
'billsMarkedPaid' => $billsMarkedPaid,
863876
];
864877
}
865878

@@ -904,6 +917,7 @@ private function executeSingleAccountImport(string $userId, string $fileId, stri
904917
$transferLinkIds = [];
905918
$hashCounts = [];
906919
$touchedAccounts = [];
920+
$createdForBillMatch = [];
907921

908922
// Detect date format from all rows before processing individually (unless preset set it)
909923
if (!$preset) {
@@ -996,6 +1010,7 @@ private function executeSingleAccountImport(string $userId, string $fileId, stri
9961010
deferBalanceUpdate: true
9971011
);
9981012
$touchedAccounts[$txAccountId] = true;
1013+
$createdForBillMatch[] = $createdTx;
9991014

10001015
// Apply tags from preset (e.g., Toshl Tags)
10011016
if ($preset && !empty($transaction['_tagNames']) && !empty($transaction['categoryId'])) {
@@ -1079,6 +1094,15 @@ private function executeSingleAccountImport(string $userId, string $fileId, stri
10791094
}
10801095
}
10811096

1097+
// Auto-mark bills paid from matching imported transactions (#274).
1098+
// Best-effort: a matching failure must never fail the import.
1099+
$billsMarkedPaid = 0;
1100+
try {
1101+
$billsMarkedPaid = $this->billService->autoMatchPaidFromImport($userId, $createdForBillMatch);
1102+
} catch (\Exception $e) {
1103+
// ignore
1104+
}
1105+
10821106
if ($hasAccountColumn) {
10831107
$result = [
10841108
'imported' => $imported,
@@ -1087,13 +1111,15 @@ private function executeSingleAccountImport(string $userId, string $fileId, stri
10871111
'totalProcessed' => count($data),
10881112
'accountResults' => array_values($perAccountResults),
10891113
'accountsCreated' => $accountsCreated,
1114+
'billsMarkedPaid' => $billsMarkedPaid,
10901115
];
10911116
} else {
10921117
$result = [
10931118
'imported' => $imported,
10941119
'skipped' => $skipped,
10951120
'errors' => $errors,
10961121
'totalProcessed' => count($data),
1122+
'billsMarkedPaid' => $billsMarkedPaid,
10971123
'accountResults' => [[
10981124
'destinationAccountId' => $accountId,
10991125
'destinationAccountName' => $account->getName(),

budget/src/modules/import/ImportModule.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1463,6 +1463,14 @@ export default class ImportModule {
14631463

14641464
if (response.ok) {
14651465
showSuccess(t('budget', 'Successfully imported {imported} transactions ({skipped} skipped)', { imported: result.imported, skipped: result.skipped }));
1466+
if (result.billsMarkedPaid > 0) {
1467+
showSuccess(n(
1468+
'budget',
1469+
'%n bill was automatically marked as paid from matching transactions',
1470+
'%n bills were automatically marked as paid from matching transactions',
1471+
result.billsMarkedPaid
1472+
));
1473+
}
14661474
if (result.errors && result.errors.length > 0) {
14671475
// Partial failure (e.g. a mapped destination account was
14681476
// deleted) — must not masquerade as a full success

budget/templates/index.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5401,7 +5401,7 @@ class="app-navigation-search-clear icon-close"
54015401
<div class="form-group">
54025402
<label for="bill-auto-pattern"><?php p($l->t('Auto-detect Pattern')); ?></label>
54035403
<input type="text" id="bill-auto-pattern" aria-describedby="bill-auto-pattern-help" maxlength="255" placeholder="<?php p($l->t('e.g., NETFLIX, SPOTIFY')); ?>">
5404-
<small id="bill-auto-pattern-help" class="form-text"><?php p($l->t('Pattern to match in transaction descriptions for auto-linking')); ?></small>
5404+
<small id="bill-auto-pattern-help" class="form-text"><?php p($l->t('Imported transactions matching this pattern (similar amount, near the due date) automatically mark the bill as paid')); ?></small>
54055405
</div>
54065406

54075407
<div class="form-group">

budget/tests/.phpunit.result.cache

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

budget/tests/Unit/Service/BankSync/BankSyncServiceTest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ protected function setUp(): void {
6767
$dismissedImportMapper,
6868
$ruleApplicator,
6969
$transactionTagService,
70+
$this->createMock(\OCA\Budget\Service\BillService::class),
7071
$this->l,
7172
$this->logger
7273
);

0 commit comments

Comments
 (0)