Skip to content

Commit 37a43e2

Browse files
committed
feat: implement tip discrepancy detection and integrate it across the application
1 parent e5a95d4 commit 37a43e2

13 files changed

Lines changed: 211 additions & 27 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,13 +92,14 @@ See `docs/FEATURES.md` for comprehensive feature list.
9292

9393
## Detection Algorithms
9494

95-
See `docs/DETECTION.md` for algorithm details. Six algorithms:
95+
See `docs/DETECTION.md` for algorithm details. Seven algorithms:
9696
- Zombie Detection - forgotten recurring charges
9797
- Price Increase Detection - subscription price changes
9898
- Duplicate Detection - multiple services in same category
9999
- Auto-Cancellation Detection - stopped subscriptions
100100
- Resume Detection - reactivated subscriptions
101101
- Spending Anomaly Detection - unusual spending patterns
102+
- Tip Discrepancy Detection - bank amount higher than receipt total
102103

103104
## AI Integration
104105

crates/hone-core/src/db/alerts.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ impl Database {
223223
"duplicate" => AlertType::Duplicate,
224224
"resume" => AlertType::Resume,
225225
"spending_anomaly" => AlertType::SpendingAnomaly,
226+
"tip_discrepancy" => AlertType::TipDiscrepancy,
226227
_ => AlertType::Zombie,
227228
},
228229
subscription_id: row.get(2)?,
@@ -271,6 +272,7 @@ impl Database {
271272
"duplicate" => AlertType::Duplicate,
272273
"resume" => AlertType::Resume,
273274
"spending_anomaly" => AlertType::SpendingAnomaly,
275+
"tip_discrepancy" => AlertType::TipDiscrepancy,
274276
_ => AlertType::Zombie,
275277
},
276278
subscription_id: row.get(2)?,

crates/hone-core/src/db/import_history.rs

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ impl Database {
4545
price_increases_detected: i64,
4646
duplicates_detected: i64,
4747
receipts_matched: i64,
48+
spending_anomalies_detected: i64,
49+
tip_discrepancies_detected: i64,
4850
) -> Result<()> {
4951
let conn = self.conn()?;
5052
conn.execute(
@@ -62,7 +64,9 @@ impl Database {
6264
zombies_detected = ?,
6365
price_increases_detected = ?,
6466
duplicates_detected = ?,
65-
receipts_matched = ?
67+
receipts_matched = ?,
68+
spending_anomalies_detected = ?,
69+
tip_discrepancies_detected = ?
6670
WHERE id = ?
6771
"#,
6872
params![
@@ -79,6 +83,8 @@ impl Database {
7983
price_increases_detected,
8084
duplicates_detected,
8185
receipts_matched,
86+
spending_anomalies_detected,
87+
tip_discrepancies_detected,
8288
session_id,
8389
],
8490
)?;
@@ -250,6 +256,7 @@ impl Database {
250256
s.tagged_by_bank_category, s.tagged_fallback,
251257
s.subscriptions_found, s.zombies_detected, s.price_increases_detected,
252258
s.duplicates_detected, s.receipts_matched,
259+
s.spending_anomalies_detected, s.tip_discrepancies_detected,
253260
s.user_email, s.ollama_model,
254261
s.status, s.processing_phase, s.processing_current, s.processing_total, s.processing_error,
255262
s.tagging_duration_ms, s.normalizing_duration_ms, s.matching_duration_ms,
@@ -404,24 +411,26 @@ impl Database {
404411
price_increases_detected: row.get(15)?,
405412
duplicates_detected: row.get(16)?,
406413
receipts_matched: row.get(17)?,
407-
user_email: row.get(18)?,
408-
ollama_model: row.get(19)?,
414+
spending_anomalies_detected: row.get(18)?,
415+
tip_discrepancies_detected: row.get(19)?,
416+
user_email: row.get(20)?,
417+
ollama_model: row.get(21)?,
409418
status: status_str
410419
.as_deref()
411420
.and_then(|s| s.parse().ok())
412421
.unwrap_or(ImportStatus::Pending),
413422
processing_phase: row.get(21)?,
414423
processing_current: row.get::<_, Option<i64>>(22)?.unwrap_or(0),
415-
processing_total: row.get::<_, Option<i64>>(23)?.unwrap_or(0),
416-
processing_error: row.get(24)?,
417-
tagging_duration_ms: row.get(25)?,
418-
normalizing_duration_ms: row.get(26)?,
419-
matching_duration_ms: row.get(27)?,
420-
detecting_duration_ms: row.get(28)?,
421-
total_duration_ms: row.get(29)?,
424+
processing_total: row.get::<_, Option<i64>>(25)?.unwrap_or(0),
425+
processing_error: row.get(26)?,
426+
tagging_duration_ms: row.get(27)?,
427+
normalizing_duration_ms: row.get(28)?,
428+
matching_duration_ms: row.get(29)?,
429+
detecting_duration_ms: row.get(30)?,
430+
total_duration_ms: row.get(31)?,
422431
created_at: parse_datetime(&created_at_str),
423432
},
424-
account_name: row.get(31)?,
433+
account_name: row.get(33)?,
425434
})
426435
}
427436

@@ -583,9 +592,11 @@ impl Database {
583592
price_increases_detected,
584593
duplicates_detected,
585594
receipts_matched,
586-
): (i64, i64, i64, i64, i64) = conn.query_row(
595+
spending_anomalies_detected,
596+
tip_discrepancies_detected,
597+
): (i64, i64, i64, i64, i64, i64, i64) = conn.query_row(
587598
r#"SELECT subscriptions_found, zombies_detected, price_increases_detected,
588-
duplicates_detected, receipts_matched
599+
duplicates_detected, receipts_matched, spending_anomalies_detected, tip_discrepancies_detected
589600
FROM import_sessions WHERE id = ?"#,
590601
params![session_id],
591602
|row| {
@@ -595,6 +606,8 @@ impl Database {
595606
row.get(2)?,
596607
row.get(3)?,
597608
row.get(4)?,
609+
row.get(5)?,
610+
row.get(6)?,
598611
))
599612
},
600613
)?;
@@ -637,6 +650,8 @@ impl Database {
637650
price_increases_detected,
638651
duplicates_detected,
639652
receipts_matched,
653+
spending_anomalies_detected,
654+
tip_discrepancies_detected,
640655
sample_transactions,
641656
})
642657
}
@@ -660,6 +675,8 @@ impl Database {
660675
"price_increases_detected": snapshot.price_increases_detected,
661676
"duplicates_detected": snapshot.duplicates_detected,
662677
"receipts_matched": snapshot.receipts_matched,
678+
"spending_anomalies_detected": snapshot.spending_anomalies_detected,
679+
"tip_discrepancies_detected": snapshot.tip_discrepancies_detected,
663680
})
664681
.to_string();
665682

crates/hone-core/src/db/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,8 @@ impl Database {
601601
price_increases_detected INTEGER DEFAULT 0,
602602
duplicates_detected INTEGER DEFAULT 0,
603603
receipts_matched INTEGER DEFAULT 0,
604+
spending_anomalies_detected INTEGER DEFAULT 0,
605+
tip_discrepancies_detected INTEGER DEFAULT 0,
604606
-- Metadata
605607
user_email TEXT,
606608
ollama_model TEXT, -- Ollama model used for tagging/normalization (if any)

crates/hone-core/src/db/receipts.rs

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,11 +203,32 @@ impl Database {
203203

204204
/// Link receipt to transaction
205205
pub fn link_receipt_to_transaction(&self, receipt_id: i64, transaction_id: i64) -> Result<()> {
206-
let conn = self.conn()?;
207-
conn.execute(
206+
let mut conn = self.conn()?;
207+
208+
// Get receipt total to update transaction's expected_amount
209+
let receipt_total: Option<f64> = conn
210+
.query_row(
211+
"SELECT receipt_total FROM receipts WHERE id = ?",
212+
params![receipt_id],
213+
|row| row.get(0),
214+
)
215+
.optional()?;
216+
217+
let tx = conn.transaction()?;
218+
219+
tx.execute(
208220
"UPDATE receipts SET transaction_id = ?, status = 'matched' WHERE id = ?",
209221
params![transaction_id, receipt_id],
210222
)?;
223+
224+
if let Some(total) = receipt_total {
225+
tx.execute(
226+
"UPDATE transactions SET expected_amount = ? WHERE id = ?",
227+
params![total, transaction_id],
228+
)?;
229+
}
230+
231+
tx.commit()?;
211232
Ok(())
212233
}
213234

crates/hone-core/src/detect.rs

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ pub struct DetectionConfig {
5959
/// Days after which an acknowledgment is considered stale and the subscription
6060
/// may be flagged as a zombie again (0 = never stale)
6161
pub acknowledgment_stale_days: i64,
62+
/// Threshold for tip discrepancy detection (absolute dollars)
63+
pub tip_discrepancy_threshold: f64,
6264
}
6365

6466
impl Default for DetectionConfig {
@@ -79,6 +81,7 @@ impl Default for DetectionConfig {
7981
spending_anomaly_min_baseline: 50.0, // Only flag if baseline >= $50/month
8082
// Re-acknowledgment defaults
8183
acknowledgment_stale_days: 90, // Re-check after 90 days (~quarterly)
84+
tip_discrepancy_threshold: 0.50, // Flag if diff > $0.50
8285
}
8386
}
8487
}
@@ -93,6 +96,7 @@ pub struct DetectionResults {
9396
pub auto_cancelled: usize,
9497
pub resumes_detected: usize,
9598
pub spending_anomalies_detected: usize,
99+
pub tip_discrepancies_detected: usize,
96100
}
97101

98102
/// Main detector that runs all algorithms
@@ -204,10 +208,11 @@ impl<'a> WasteDetector<'a> {
204208
let spending_anomalies_detected = self
205209
.detect_spending_anomalies_with_progress(progress)
206210
.await?;
211+
let tip_discrepancies_detected = self.detect_tip_discrepancies()?;
207212

208213
info!(
209-
"Detection complete: {} subscriptions, {} auto-cancelled, {} resumed, {} zombies, {} price increases, {} duplicates, {} spending anomalies",
210-
subscriptions_found, auto_cancelled, resumes_detected, zombies_detected, price_increases_detected, duplicates_detected, spending_anomalies_detected
214+
"Detection complete: {} subscriptions, {} auto-cancelled, {} resumed, {} zombies, {} price increases, {} duplicates, {} spending anomalies, {} tip discrepancies",
215+
subscriptions_found, auto_cancelled, resumes_detected, zombies_detected, price_increases_detected, duplicates_detected, spending_anomalies_detected, tip_discrepancies_detected
211216
);
212217

213218
Ok(DetectionResults {
@@ -218,6 +223,7 @@ impl<'a> WasteDetector<'a> {
218223
auto_cancelled,
219224
resumes_detected,
220225
spending_anomalies_detected,
226+
tip_discrepancies_detected,
221227
})
222228
}
223229

@@ -829,6 +835,38 @@ impl<'a> WasteDetector<'a> {
829835
Ok(count)
830836
}
831837

838+
/// Detect transactions where the amount is higher than the receipt total
839+
fn detect_tip_discrepancies(&self) -> Result<usize> {
840+
let transactions = self.db.list_transactions(None, 10000, 0)?;
841+
let mut count = 0;
842+
843+
for tx in transactions {
844+
let expected = match tx.expected_amount {
845+
Some(e) => e.abs(),
846+
None => continue,
847+
};
848+
849+
let actual = tx.amount.abs();
850+
let diff = actual - expected;
851+
852+
if diff > self.config.tip_discrepancy_threshold {
853+
let message = format!(
854+
"{} charge of ${:.2} is higher than receipt total of ${:.2} (potential tip: ${:.2})",
855+
tx.merchant_normalized.as_deref().unwrap_or(&tx.description),
856+
actual,
857+
expected,
858+
diff
859+
);
860+
861+
self.db
862+
.create_alert(AlertType::TipDiscrepancy, None, Some(&message))?;
863+
count += 1;
864+
}
865+
}
866+
867+
Ok(count)
868+
}
869+
832870
/// Detect spending anomalies
833871
///
834872
/// Compares current month spending by category against a 3-month rolling baseline.
@@ -2091,4 +2129,62 @@ mod tests {
20912129
assert_eq!(config.smart_min_transactions, 2);
20922130
assert_eq!(config.ollama_confidence_threshold, 0.7);
20932131
}
2132+
2133+
#[tokio::test]
2134+
async fn test_detect_tip_discrepancy() {
2135+
let db = Database::in_memory().unwrap();
2136+
2137+
// Create account
2138+
let account_id = db
2139+
.upsert_account("Test Account", crate::models::Bank::Chase, None)
2140+
.unwrap();
2141+
2142+
// Create a transaction with an expected amount (e.g. from a receipt)
2143+
// Bank amount: $55.00
2144+
// Receipt total: $45.00
2145+
// Expected amount: $45.00
2146+
let tx_id = db
2147+
.insert_transaction(
2148+
account_id,
2149+
&crate::models::NewTransaction {
2150+
date: Utc::now().date_naive(),
2151+
description: "RESTO BAR".to_string(),
2152+
amount: -55.00,
2153+
category: Some("Dining".to_string()),
2154+
import_hash: "tip_test_hash".to_string(),
2155+
original_data: None,
2156+
import_format: None,
2157+
card_member: None,
2158+
payment_method: None,
2159+
},
2160+
)
2161+
.unwrap()
2162+
.unwrap();
2163+
2164+
// Manually set expected_amount as if a receipt was linked
2165+
let conn = db.conn().unwrap();
2166+
conn.execute(
2167+
"UPDATE transactions SET expected_amount = 45.00 WHERE id = ?",
2168+
[tx_id],
2169+
)
2170+
.unwrap();
2171+
2172+
let detector = WasteDetector::new(&db);
2173+
let results = detector.detect_all().await.unwrap();
2174+
2175+
// Should have detected 1 tip discrepancy
2176+
assert_eq!(results.tip_discrepancies_detected, 1);
2177+
2178+
// Verify a tip discrepancy alert was created
2179+
let alerts = db.list_alerts(false).unwrap();
2180+
let tip_alert = alerts
2181+
.iter()
2182+
.find(|a| a.alert_type == AlertType::TipDiscrepancy)
2183+
.expect("Should have created a tip discrepancy alert");
2184+
2185+
assert!(tip_alert.message.as_ref().unwrap().contains("RESTO BAR"));
2186+
assert!(tip_alert.message.as_ref().unwrap().contains("$55.00"));
2187+
assert!(tip_alert.message.as_ref().unwrap().contains("$45.00"));
2188+
assert!(tip_alert.message.as_ref().unwrap().contains("potential tip: $10.00"));
2189+
}
20942190
}

crates/hone-core/src/models.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,8 @@ pub enum AlertType {
315315
Resume,
316316
/// Spending in a category changed dramatically vs baseline
317317
SpendingAnomaly,
318+
/// Transaction amount is higher than receipt total (e.g. unrecorded tip)
319+
TipDiscrepancy,
318320
}
319321

320322
impl AlertType {
@@ -325,6 +327,7 @@ impl AlertType {
325327
Self::Duplicate => "duplicate",
326328
Self::Resume => "resume",
327329
Self::SpendingAnomaly => "spending_anomaly",
330+
Self::TipDiscrepancy => "tip_discrepancy",
328331
}
329332
}
330333

@@ -335,6 +338,7 @@ impl AlertType {
335338
Self::Duplicate => "Duplicate Service",
336339
Self::Resume => "Subscription Resumed",
337340
Self::SpendingAnomaly => "Spending Change",
341+
Self::TipDiscrepancy => "Tip Discrepancy",
338342
}
339343
}
340344

@@ -345,6 +349,7 @@ impl AlertType {
345349
Self::Duplicate => "You have multiple services in this category",
346350
Self::Resume => "A subscription you cancelled has started charging again",
347351
Self::SpendingAnomaly => "Your spending in this category changed significantly",
352+
Self::TipDiscrepancy => "This transaction is higher than your receipt total",
348353
}
349354
}
350355
}
@@ -1512,6 +1517,8 @@ pub struct ImportSession {
15121517
pub price_increases_detected: i64,
15131518
pub duplicates_detected: i64,
15141519
pub receipts_matched: i64,
1520+
pub spending_anomalies_detected: i64,
1521+
pub tip_discrepancies_detected: i64,
15151522
// Metadata
15161523
pub user_email: Option<String>,
15171524
pub ollama_model: Option<String>,
@@ -1754,6 +1761,8 @@ pub struct ReprocessSnapshot {
17541761
pub price_increases_detected: i64,
17551762
pub duplicates_detected: i64,
17561763
pub receipts_matched: i64,
1764+
pub spending_anomalies_detected: i64,
1765+
pub tip_discrepancies_detected: i64,
17571766
/// Sample of transactions with their current tags for change detection
17581767
pub sample_transactions: Vec<TransactionTagSnapshot>,
17591768
}
@@ -1930,6 +1939,8 @@ pub struct DetectionResultsDiff {
19301939
pub price_increases_diff: i64,
19311940
pub duplicates_diff: i64,
19321941
pub receipts_matched_diff: i64,
1942+
pub spending_anomalies_diff: i64,
1943+
pub tip_discrepancies_diff: i64,
19331944
}
19341945

19351946
/// A transaction's tag difference between two runs

0 commit comments

Comments
 (0)