Skip to main content

rustledger_ops/
merchants.rs

1//! Built-in merchant dictionary for transaction categorization.
2//!
3//! Contains ~75 common merchant patterns covering groceries, dining,
4//! transport, shopping, subscriptions, utilities, health, and more.
5//! These are compiled into the binary and serve as a low-priority
6//! fallback when user rules don't match.
7//!
8//! Users can override any merchant pattern by adding a higher-priority
9//! rule in their `importers.toml`.
10
11/// A single merchant dictionary entry.
12#[derive(Debug)]
13pub struct MerchantEntry {
14    /// Regex pattern to match against payee/narration (case-insensitive).
15    pub pattern: &'static str,
16    /// The account to assign when this pattern matches.
17    pub account: &'static str,
18    /// Human-readable category name.
19    pub category: &'static str,
20}
21
22/// Built-in merchant patterns for common transaction categorization.
23///
24/// See `data/merchants.csv` for the reference data these patterns are based on.
25pub static MERCHANT_PATTERNS: &[MerchantEntry] = &[
26    // ===== Groceries =====
27    MerchantEntry {
28        pattern: r"WHOLE\s*FOODS",
29        account: "Expenses:Groceries",
30        category: "Groceries",
31    },
32    MerchantEntry {
33        pattern: r"TRADER\s*JOE",
34        account: "Expenses:Groceries",
35        category: "Groceries",
36    },
37    MerchantEntry {
38        pattern: "KROGER",
39        account: "Expenses:Groceries",
40        category: "Groceries",
41    },
42    MerchantEntry {
43        pattern: "SAFEWAY",
44        account: "Expenses:Groceries",
45        category: "Groceries",
46    },
47    MerchantEntry {
48        pattern: "PUBLIX",
49        account: "Expenses:Groceries",
50        category: "Groceries",
51    },
52    MerchantEntry {
53        pattern: "ALDI",
54        account: "Expenses:Groceries",
55        category: "Groceries",
56    },
57    MerchantEntry {
58        pattern: "LIDL",
59        account: "Expenses:Groceries",
60        category: "Groceries",
61    },
62    MerchantEntry {
63        pattern: "COSTCO",
64        account: "Expenses:Groceries",
65        category: "Groceries",
66    },
67    MerchantEntry {
68        pattern: r"SAM'?S\s*CLUB",
69        account: "Expenses:Groceries",
70        category: "Groceries",
71    },
72    MerchantEntry {
73        pattern: "WEGMANS",
74        account: "Expenses:Groceries",
75        category: "Groceries",
76    },
77    MerchantEntry {
78        pattern: r"H[\s-]?E[\s-]?B\b",
79        account: "Expenses:Groceries",
80        category: "Groceries",
81    },
82    MerchantEntry {
83        pattern: "MEIJER",
84        account: "Expenses:Groceries",
85        category: "Groceries",
86    },
87    MerchantEntry {
88        pattern: r"FOOD\s*LION",
89        account: "Expenses:Groceries",
90        category: "Groceries",
91    },
92    MerchantEntry {
93        pattern: "SPROUTS",
94        account: "Expenses:Groceries",
95        category: "Groceries",
96    },
97    MerchantEntry {
98        pattern: "INSTACART",
99        account: "Expenses:Groceries",
100        category: "Groceries",
101    },
102    // ===== Dining =====
103    MerchantEntry {
104        pattern: "STARBUCKS",
105        account: "Expenses:Dining:Coffee",
106        category: "Dining",
107    },
108    MerchantEntry {
109        pattern: "DUNKIN",
110        account: "Expenses:Dining:Coffee",
111        category: "Dining",
112    },
113    MerchantEntry {
114        pattern: r"PEET'?S\s*COFFEE",
115        account: "Expenses:Dining:Coffee",
116        category: "Dining",
117    },
118    MerchantEntry {
119        pattern: "MCDONALD",
120        account: "Expenses:Dining:FastFood",
121        category: "Dining",
122    },
123    MerchantEntry {
124        pattern: r"BURGER\s*KING",
125        account: "Expenses:Dining:FastFood",
126        category: "Dining",
127    },
128    MerchantEntry {
129        pattern: r"TACO\s*BELL",
130        account: "Expenses:Dining:FastFood",
131        category: "Dining",
132    },
133    MerchantEntry {
134        pattern: r"CHICK[\s-]?FIL[\s-]?A",
135        account: "Expenses:Dining:FastFood",
136        category: "Dining",
137    },
138    MerchantEntry {
139        pattern: "SUBWAY",
140        account: "Expenses:Dining:FastFood",
141        category: "Dining",
142    },
143    MerchantEntry {
144        pattern: "CHIPOTLE",
145        account: "Expenses:Dining:FastFood",
146        category: "Dining",
147    },
148    MerchantEntry {
149        pattern: "PANERA",
150        account: "Expenses:Dining:FastFood",
151        category: "Dining",
152    },
153    MerchantEntry {
154        pattern: "DOORDASH",
155        account: "Expenses:Dining:Delivery",
156        category: "Dining",
157    },
158    MerchantEntry {
159        pattern: "GRUBHUB",
160        account: "Expenses:Dining:Delivery",
161        category: "Dining",
162    },
163    MerchantEntry {
164        pattern: r"UBER\s*EATS",
165        account: "Expenses:Dining:Delivery",
166        category: "Dining",
167    },
168    // ===== Transport =====
169    MerchantEntry {
170        pattern: r"UBER\s*(TRIP|RIDE|BV)",
171        account: "Expenses:Transport:Rideshare",
172        category: "Transport",
173    },
174    MerchantEntry {
175        pattern: "LYFT",
176        account: "Expenses:Transport:Rideshare",
177        category: "Transport",
178    },
179    MerchantEntry {
180        pattern: r"SHELL\b",
181        account: "Expenses:Transport:Fuel",
182        category: "Transport",
183    },
184    MerchantEntry {
185        pattern: "CHEVRON",
186        account: "Expenses:Transport:Fuel",
187        category: "Transport",
188    },
189    MerchantEntry {
190        pattern: "EXXON",
191        account: "Expenses:Transport:Fuel",
192        category: "Transport",
193    },
194    MerchantEntry {
195        pattern: r"BP\b",
196        account: "Expenses:Transport:Fuel",
197        category: "Transport",
198    },
199    MerchantEntry {
200        pattern: "SPEEDWAY",
201        account: "Expenses:Transport:Fuel",
202        category: "Transport",
203    },
204    MerchantEntry {
205        pattern: "PARKING",
206        account: "Expenses:Transport:Parking",
207        category: "Transport",
208    },
209    MerchantEntry {
210        pattern: r"E[\s-]?Z\s*PASS",
211        account: "Expenses:Transport:Tolls",
212        category: "Transport",
213    },
214    // ===== Shopping =====
215    MerchantEntry {
216        pattern: "AMAZON|AMZN",
217        account: "Expenses:Shopping:Amazon",
218        category: "Shopping",
219    },
220    MerchantEntry {
221        pattern: r"WALMART|WM\s*SUPERCENTER",
222        account: "Expenses:Shopping",
223        category: "Shopping",
224    },
225    MerchantEntry {
226        pattern: r"TARGET\b",
227        account: "Expenses:Shopping",
228        category: "Shopping",
229    },
230    MerchantEntry {
231        pattern: r"BEST\s*BUY",
232        account: "Expenses:Shopping:Electronics",
233        category: "Shopping",
234    },
235    MerchantEntry {
236        pattern: r"APPLE\.COM|APPLE\s*STORE",
237        account: "Expenses:Shopping:Electronics",
238        category: "Shopping",
239    },
240    MerchantEntry {
241        pattern: r"HOME\s*DEPOT",
242        account: "Expenses:Shopping:Home",
243        category: "Shopping",
244    },
245    MerchantEntry {
246        pattern: r"LOWE'?S",
247        account: "Expenses:Shopping:Home",
248        category: "Shopping",
249    },
250    MerchantEntry {
251        pattern: "IKEA",
252        account: "Expenses:Shopping:Home",
253        category: "Shopping",
254    },
255    // ===== Subscriptions =====
256    MerchantEntry {
257        pattern: "NETFLIX",
258        account: "Expenses:Subscriptions:Streaming",
259        category: "Subscriptions",
260    },
261    MerchantEntry {
262        pattern: "SPOTIFY",
263        account: "Expenses:Subscriptions:Streaming",
264        category: "Subscriptions",
265    },
266    MerchantEntry {
267        pattern: "HULU",
268        account: "Expenses:Subscriptions:Streaming",
269        category: "Subscriptions",
270    },
271    MerchantEntry {
272        pattern: r"DISNEY\s*\+|DISNEYPLUS",
273        account: "Expenses:Subscriptions:Streaming",
274        category: "Subscriptions",
275    },
276    MerchantEntry {
277        pattern: r"HBO\s*MAX|MAX\.COM",
278        account: "Expenses:Subscriptions:Streaming",
279        category: "Subscriptions",
280    },
281    MerchantEntry {
282        pattern: r"APPLE\s*(TV|MUSIC|ONE|ICLOUD)",
283        account: "Expenses:Subscriptions:Apple",
284        category: "Subscriptions",
285    },
286    MerchantEntry {
287        pattern: r"AMAZON\s*PRIME",
288        account: "Expenses:Subscriptions:Amazon",
289        category: "Subscriptions",
290    },
291    MerchantEntry {
292        pattern: "ADOBE",
293        account: "Expenses:Subscriptions:Software",
294        category: "Subscriptions",
295    },
296    MerchantEntry {
297        pattern: r"MICROSOFT\s*(365|OFFICE)",
298        account: "Expenses:Subscriptions:Software",
299        category: "Subscriptions",
300    },
301    MerchantEntry {
302        pattern: "GITHUB",
303        account: "Expenses:Subscriptions:Software",
304        category: "Subscriptions",
305    },
306    MerchantEntry {
307        pattern: "OPENAI|CHATGPT",
308        account: "Expenses:Subscriptions:Software",
309        category: "Subscriptions",
310    },
311    // ===== Utilities =====
312    MerchantEntry {
313        pattern: r"AT\s*&?\s*T\b",
314        account: "Expenses:Utilities:Phone",
315        category: "Utilities",
316    },
317    MerchantEntry {
318        pattern: "VERIZON",
319        account: "Expenses:Utilities:Phone",
320        category: "Utilities",
321    },
322    MerchantEntry {
323        pattern: r"T[\s-]?MOBILE",
324        account: "Expenses:Utilities:Phone",
325        category: "Utilities",
326    },
327    MerchantEntry {
328        pattern: "COMCAST|XFINITY",
329        account: "Expenses:Utilities:Internet",
330        category: "Utilities",
331    },
332    MerchantEntry {
333        pattern: "SPECTRUM",
334        account: "Expenses:Utilities:Internet",
335        category: "Utilities",
336    },
337    // ===== Health =====
338    MerchantEntry {
339        pattern: "CVS",
340        account: "Expenses:Health:Pharmacy",
341        category: "Health",
342    },
343    MerchantEntry {
344        pattern: "WALGREENS",
345        account: "Expenses:Health:Pharmacy",
346        category: "Health",
347    },
348    MerchantEntry {
349        pattern: r"PLANET\s*FITNESS",
350        account: "Expenses:Health:Fitness",
351        category: "Health",
352    },
353    MerchantEntry {
354        pattern: "PELOTON",
355        account: "Expenses:Health:Fitness",
356        category: "Health",
357    },
358    // ===== Travel =====
359    MerchantEntry {
360        pattern: "AIRBNB",
361        account: "Expenses:Travel:Lodging",
362        category: "Travel",
363    },
364    MerchantEntry {
365        pattern: r"BOOKING\.COM",
366        account: "Expenses:Travel:Lodging",
367        category: "Travel",
368    },
369    MerchantEntry {
370        pattern: "MARRIOTT",
371        account: "Expenses:Travel:Lodging",
372        category: "Travel",
373    },
374    MerchantEntry {
375        pattern: "HILTON",
376        account: "Expenses:Travel:Lodging",
377        category: "Travel",
378    },
379    MerchantEntry {
380        pattern: "EXPEDIA",
381        account: "Expenses:Travel",
382        category: "Travel",
383    },
384    // ===== Financial =====
385    MerchantEntry {
386        pattern: "VENMO",
387        account: "Expenses:Transfers:Venmo",
388        category: "Financial",
389    },
390    MerchantEntry {
391        pattern: "PAYPAL",
392        account: "Expenses:Transfers:PayPal",
393        category: "Financial",
394    },
395    MerchantEntry {
396        pattern: "ZELLE",
397        account: "Expenses:Transfers:Zelle",
398        category: "Financial",
399    },
400    // ===== Entertainment =====
401    MerchantEntry {
402        pattern: "TICKETMASTER",
403        account: "Expenses:Entertainment",
404        category: "Entertainment",
405    },
406    MerchantEntry {
407        pattern: r"STEAM\s*(GAMES|PURCHASE)",
408        account: "Expenses:Entertainment:Games",
409        category: "Entertainment",
410    },
411];
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416
417    #[test]
418    fn merchant_patterns_not_empty() {
419        assert!(!MERCHANT_PATTERNS.is_empty());
420        assert!(
421            MERCHANT_PATTERNS.len() > 50,
422            "Expected at least 50 merchant patterns"
423        );
424    }
425
426    #[test]
427    fn all_patterns_compile() {
428        for entry in MERCHANT_PATTERNS {
429            let result = regex::RegexBuilder::new(entry.pattern)
430                .case_insensitive(true)
431                .build();
432            assert!(
433                result.is_ok(),
434                "Pattern '{}' for {} failed to compile: {:?}",
435                entry.pattern,
436                entry.account,
437                result.err()
438            );
439        }
440    }
441
442    #[test]
443    fn patterns_have_valid_accounts() {
444        for entry in MERCHANT_PATTERNS {
445            assert!(
446                entry.account.starts_with("Expenses:") || entry.account.starts_with("Income:"),
447                "Pattern '{}' has invalid account '{}' — must start with Expenses: or Income:",
448                entry.pattern,
449                entry.account,
450            );
451        }
452    }
453
454    #[test]
455    fn patterns_have_categories() {
456        for entry in MERCHANT_PATTERNS {
457            assert!(
458                !entry.category.is_empty(),
459                "Pattern '{}' is missing a category",
460                entry.pattern,
461            );
462        }
463    }
464}