<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Rajesh Mudi</title>
    <description>The latest articles on DEV Community by Rajesh Mudi (@rajesh_mudi).</description>
    <link>https://dev.to/rajesh_mudi</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2688285%2F728e994d-2d61-4b67-9c9b-77d36431643e.png</url>
      <title>DEV Community: Rajesh Mudi</title>
      <link>https://dev.to/rajesh_mudi</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rajesh_mudi"/>
    <language>en</language>
    <item>
      <title>I Got Sick of Subscription Budget Apps. So I Built My Own With Telegram, Python, and a $0/Month Stack.</title>
      <dc:creator>Rajesh Mudi</dc:creator>
      <pubDate>Sat, 08 Aug 2026 20:27:37 +0000</pubDate>
      <link>https://dev.to/rajesh_mudi/i-got-sick-of-subscription-budget-apps-so-i-built-my-own-with-telegram-python-and-a-0month-14lh</link>
      <guid>https://dev.to/rajesh_mudi/i-got-sick-of-subscription-budget-apps-so-i-built-my-own-with-telegram-python-and-a-0month-14lh</guid>
      <description>&lt;p&gt;&lt;em&gt;No logins. No monthly fees. No "Premium required to export your own data." Just you, a Telegram message, and a double-click.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Try it now: &lt;a href="https://t.me/PennyTrak_bot" rel="noopener noreferrer"&gt;@PennyTrak_bot&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Every January I download a new budgeting app. Every March I forget to open it. By April, I'm back to squinting at my bank statement trying to reverse-engineer where ₹40,000 went.&lt;/p&gt;

&lt;p&gt;It wasn't a discipline problem. It was a friction problem. Every app wanted me to log into a dashboard, find the right category, tap through three menus, and somehow remember to do this after every transaction. I don't do that. Nobody does that.&lt;/p&gt;

&lt;p&gt;But I &lt;em&gt;do&lt;/em&gt; check Telegram constantly. So I asked myself a dumb question: what if logging money felt exactly like texting a friend?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Me: spent 500 on ola
Me: swiggy 420 dinner
Me: got salary 75000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No menu. No category dropdown. Just words. That's the whole idea behind &lt;strong&gt;Ek Ek Paisa ka Hisab&lt;/strong&gt; — a personal finance system that lives in Telegram, persists to Supabase, and surfaces in a static HTML dashboard you open with a double-click.&lt;/p&gt;

&lt;p&gt;Here's how it works, and why a few of its design decisions might be worth stealing.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Architecture in One Sentence
&lt;/h2&gt;

&lt;p&gt;A Python bot parses your natural-language messages, writes them to a Postgres database, then regenerates a self-contained HTML file that opens offline with no server.&lt;/p&gt;

&lt;p&gt;That last part is the unusual bit. Most developers would reach for a React dashboard served from a cloud function. I went the other direction: the dashboard is a single &lt;code&gt;.html&lt;/code&gt; file with your spending data baked in as a JavaScript variable. Double-click it. Done. No internet, no login, no third party seeing your rent figure.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;EXPENSE_DATA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;date&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-08-01&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;category&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;food&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;amount&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;420&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;note&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;swiggy dinner&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;expense&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The file regenerates after every Telegram message. The dashboard reads it via a &lt;code&gt;&amp;lt;script src="data.js"&amp;gt;&lt;/code&gt; tag — not &lt;code&gt;fetch()&lt;/code&gt;, which would require a server for the &lt;code&gt;file://&lt;/code&gt; protocol. It's an old trick, but it works perfectly and the whole thing loads in a blink with no CDN dependency.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Parser Is the Product
&lt;/h2&gt;

&lt;p&gt;The hardest part of this project wasn't Supabase or Telegram. It was making the parser genuinely robust.&lt;/p&gt;

&lt;p&gt;"spent 500 on ola" is easy. But real people type things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;1.5k myntra shirt&lt;/code&gt; (k = thousands)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;2l rent paid&lt;/code&gt; (l = lakhs, Indian notation)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;rs 500 groceries&lt;/code&gt; (currency prefix)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;got salary 75k&lt;/code&gt; (income, not expense)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ola 250 last night&lt;/code&gt; (amount anywhere in the sentence)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;coffee 3&lt;/code&gt; (small amounts without a unit)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The parser handles all of these. It finds amounts using regex that understands Indian notation, classifies the type as expense or income based on trigger words ("salary", "credited", "refund", "cashback"), and assigns a category by matching the note against keyword sets.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spent 500 on ola&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;         &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;travel&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expense&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;swiggy 420 dinner&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;        &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;420&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;food&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expense&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;got salary 75000&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;         &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;75000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;income&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;income&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1.5k myntra shirt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;        &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;clothes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expense&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2l rent paid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;             &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expense&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;91 test cases cover the parser alone, including adversarial inputs: a message like &lt;code&gt;"call me at 7pm"&lt;/code&gt; should return &lt;code&gt;null&lt;/code&gt;, not &lt;code&gt;{amount: 7}&lt;/code&gt;. Getting that boundary right took more iteration than any other part of the system.&lt;/p&gt;




&lt;h2&gt;
  
  
  Per-User Isolation From Day One
&lt;/h2&gt;

&lt;p&gt;Most personal bots are built for one person. This one is built to handle many people using the same bot — each completely isolated from the other.&lt;/p&gt;

&lt;p&gt;Every transaction carries a &lt;code&gt;chat_id&lt;/code&gt;. Every query is scoped to the sender. Nobody sees anyone else's spending. And the dashboard — which has no login — shows exactly one owner's data, set in config via a single field: &lt;code&gt;owner_chat_id&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"owner_chat_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;123456789&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not sure what your chat id is? &lt;code&gt;/whoami&lt;/code&gt; tells you. It even detects the single-user case automatically — if only one person has ever messaged the bot, it adopts them as the owner without requiring any configuration at all.&lt;/p&gt;

&lt;p&gt;Per-user budgets extend this. You can set category caps without touching a config file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/setcap food 8000
/setbudget 50000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your caps layer over the defaults. Setting one doesn't wipe the others. And when the dashboard regenerates, it reads &lt;em&gt;your&lt;/em&gt; caps — not the install defaults — so the budget bars actually reflect what you told it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Projection That Doesn't Lie to You
&lt;/h2&gt;

&lt;p&gt;Most budget apps project your monthly spend by multiplying today's daily average by 30. This sounds reasonable until you pay rent on the 1st and the app tells you you're on track to spend ₹6,00,000 this month.&lt;/p&gt;

&lt;p&gt;Ek Ek Paisa ka Hisab flags &lt;strong&gt;recurring costs&lt;/strong&gt; before extrapolating. Rent, EMIs, subscriptions — large one-time payments that appear in history at roughly monthly intervals — get pulled out before the daily rate is calculated. What's left is genuine variable spending: food, travel, coffee. That gets projected. Rent doesn't.&lt;/p&gt;

&lt;p&gt;The result is a projection that feels honest. If it says you'll overshoot by ₹3,000 on food by the end of the month, it probably means it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Health Check That Started a Security Story
&lt;/h2&gt;

&lt;p&gt;I built a &lt;code&gt;/health&lt;/code&gt; route because I wanted to point an uptime monitor at the bot. GET &lt;code&gt;/health/live&lt;/code&gt; for a liveness probe, &lt;code&gt;/health/ready&lt;/code&gt; if you want it to actually hit Postgres first. Standard stuff.&lt;/p&gt;

&lt;p&gt;But building it forced me to think about what a health endpoint is actually exposing. It knows your database URL. It knows whether your bot token is set. And Postgres error messages — as I found out the hard way — will sometimes quote your connection string back at you when they fail.&lt;/p&gt;

&lt;p&gt;So the health endpoint reports credentials as &lt;strong&gt;shapes, not values&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"supabase_key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sb_secret_…(41)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"telegram_token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"set"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"supabase_project"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"hfmm…yc"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It doesn't use &lt;code&gt;SimpleHTTPRequestHandler&lt;/code&gt;, which would cheerfully serve the &lt;code&gt;.env&lt;/code&gt; file sitting beside it. It answers exactly three string paths and has no concept of the filesystem. And &lt;code&gt;report()&lt;/code&gt; runs a final sweep over the assembled JSON to catch any chat id that snuck into an error message — because Telegram chat ids are personal data and the dashboard already works hard to keep them out of the browser.&lt;/p&gt;

&lt;p&gt;There are 19 traversal attack attempts in the test suite: &lt;code&gt;/../.env&lt;/code&gt;, &lt;code&gt;/%2e%2e%2f.env&lt;/code&gt;, &lt;code&gt;/health/../.env&lt;/code&gt;, &lt;code&gt;/../../../../etc/passwd&lt;/code&gt;. All of them 404.&lt;/p&gt;

&lt;p&gt;Building the health check right taught me something: &lt;strong&gt;every surface that reports on a system is also a potential leak of that system.&lt;/strong&gt; Write it accordingly.&lt;/p&gt;




&lt;h2&gt;
  
  
  A Lesson in What Gets Committed
&lt;/h2&gt;

&lt;p&gt;Here's the part I'd rather not write, but honesty is the point of this kind of article.&lt;/p&gt;

&lt;p&gt;The original &lt;code&gt;config.json&lt;/code&gt; held all three credentials — the Telegram token, the Supabase URL, and the secret key. When I pushed the first commit to a public GitHub repo, all three went with it. The repo was indexed within minutes.&lt;/p&gt;

&lt;p&gt;The fix was moving every credential into &lt;code&gt;.env&lt;/code&gt; (gitignored) and making &lt;code&gt;config.json&lt;/code&gt; settings-only. But the better fix was making the code &lt;em&gt;refuse to start&lt;/em&gt; if a secret finds its way back into the file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;stale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;telegram_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;supabase_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
         &lt;span class="nf"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_PLACEHOLDERS&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;stale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ConfigError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stale&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; still have a real value in config.json.&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;  Secrets belong in .env now, which is gitignored.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A leak that becomes a startup error instead of a silent commit is a much better outcome. Pair that with a test that asserts no credential ever reaches &lt;code&gt;data.js&lt;/code&gt;, and you've got defense in depth rather than "I promise I'll remember."&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;.env&lt;/code&gt; loader is thirty lines of vanilla Python — no &lt;code&gt;python-dotenv&lt;/code&gt;, no extra install. It handles comments, &lt;code&gt;export&lt;/code&gt; prefixes, quoted values, and a &lt;code&gt;#&lt;/code&gt; inside a quoted key (Supabase keys can legitimately contain one). 12 parser tests for that loader alone, including the edge case that makes most hand-rolled parsers fail.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Actually Running
&lt;/h2&gt;

&lt;p&gt;The stack is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Python&lt;/strong&gt; + &lt;code&gt;python-telegram-bot&lt;/code&gt; v22 — async handlers, &lt;code&gt;asyncio.to_thread&lt;/code&gt; for blocking I/O&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Supabase&lt;/strong&gt; (Postgres + PostgREST) — free tier handles the volume comfortably&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No server for the dashboard&lt;/strong&gt; — static HTML + &lt;code&gt;data.js&lt;/code&gt;, served from &lt;code&gt;file://&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;323 automated checks&lt;/strong&gt; — parser, storage, dashboard DOM, live handler runs, health probes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The test that's most worth reading is &lt;code&gt;tests/live_two_users.py&lt;/code&gt;. It drives the actual bot handlers with fake Update objects — the same way python-telegram-bot would — and prints every reply. Unit tests passed on this codebase while the bot was projecting spend off by 6x. The live test caught it. Reading the words your bot actually says is irreplaceable.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;I'd design for secret rotation from the start.&lt;/strong&gt; Not "I'll add that later." The gap between "later" and "that commit is on GitHub" is shorter than you think.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I'd run the live integration test before shipping any non-trivial change.&lt;/strong&gt; The unit tests give you fast, reliable feedback. The live test tells you what the user actually reads. Both matter; they answer different questions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I'd set &lt;code&gt;owner_chat_id&lt;/code&gt; on day one.&lt;/strong&gt; An ambiguous owner means no dashboard, and that failure mode is confusing the first time you hit it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Get the Code — and Try It Live
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Try the bot right now:&lt;/strong&gt; &lt;a href="https://t.me/PennyTrak_bot" rel="noopener noreferrer"&gt;@PennyTrak_bot&lt;/a&gt; on Telegram. Just send it a message like &lt;code&gt;spent 500 on coffee&lt;/code&gt; and watch it work.&lt;/p&gt;

&lt;p&gt;The full project — bot, parser, dashboard, health endpoint, all 323 tests — is on GitHub. The only thing you need to self-host is a Telegram bot from &lt;a class="mentioned-user" href="https://dev.to/botfather"&gt;@botfather&lt;/a&gt; and a free Supabase project. Total monthly cost: ₹0.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/mudirajesh/....
&lt;span class="nb"&gt;cd &lt;/span&gt;Ek-Paisa-ka-Hisab
&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env
&lt;span class="c"&gt;# fill in TELEGRAM_TOKEN, SUPABASE_URL, SUPABASE_KEY&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;python-telegram-bot supabase
python bot.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then text your bot. Your first entry takes about four seconds end-to-end — message received, parsed, stored, &lt;code&gt;data.js&lt;/code&gt; regenerated. Open &lt;code&gt;dashboard.html&lt;/code&gt;. That's it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you found this useful, the clap button keeps these going. Questions or pull requests welcome.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Tags:&lt;/strong&gt; Python · Telegram Bot · Personal Finance · Supabase · Side Projects · Open Source&lt;/p&gt;

</description>
      <category>expensetracking</category>
      <category>montlybudgeting</category>
      <category>buildinpublic</category>
      <category>sideprojects</category>
    </item>
  </channel>
</rss>
