<?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: pedro acosta</title>
    <description>The latest articles on DEV Community by pedro acosta (@pedroacosta613).</description>
    <link>https://dev.to/pedroacosta613</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%2F22247%2Faad92919-9259-4d7d-ba54-fed24aeb2fab.jpg</url>
      <title>DEV Community: pedro acosta</title>
      <link>https://dev.to/pedroacosta613</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pedroacosta613"/>
    <language>en</language>
    <item>
      <title>I built a subscription finder that never uploads your bank statement</title>
      <dc:creator>pedro acosta</dc:creator>
      <pubDate>Wed, 28 Oct 2026 17:25:25 +0000</pubDate>
      <link>https://dev.to/pedroacosta613/i-built-a-subscription-finder-that-never-uploads-your-bank-statement-1d4p</link>
      <guid>https://dev.to/pedroacosta613/i-built-a-subscription-finder-that-never-uploads-your-bank-statement-1d4p</guid>
      <description>&lt;p&gt;I'm a software engineer, and I built a budgeting app because I was tired of guessing where my money went. One piece of it has its own free page: a Subscription Finder where you drop in a CSV from your bank and it lists every recurring charge. The rule that shaped the whole thing was simple. The file must never leave the browser. This post is about how that works and the parts that turned out harder than I expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  No upload endpoint, so nothing to leak
&lt;/h2&gt;

&lt;p&gt;The page is a client component. The file is read with &lt;code&gt;file.text()&lt;/code&gt; and parsed in the browser. There is no API route that accepts it, so there is nothing on my server that could store or leak a statement. That is a stronger promise than a privacy policy, because it holds even if I make a mistake later.&lt;/p&gt;

&lt;p&gt;One honest detail: when the tool runs it logs a single analytics event with counts only, how many recurring charges it found, how many possible ones, and how many months the statement covers. No file, no merchant names, no amounts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reuse the real detector, not a toy one
&lt;/h2&gt;

&lt;p&gt;The free tool calls the same recurring detector the app uses, so the two never disagree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;charges&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;txns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;NOT_A_CHARGE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;recurring&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extractSubscriptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;detectRecurring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;charges&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is what &lt;code&gt;detectRecurring&lt;/code&gt; does. It groups charges by normalized merchant name, direction, and account. A group needs at least three charges spread over at least two weeks. It takes the median gap between the dates and snaps it to a frequency such as weekly, biweekly, or monthly. Then it averages the amount after dropping anything more than 20 percent from the median, so a late fee on a phone bill does not drag the number around.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;null&lt;/code&gt; argument matters. The app normally looks at a rolling window of the last few months, but an uploaded statement can end months ago, so the finder asks it to use every transaction.&lt;/p&gt;

&lt;h2&gt;
  
  
  One statement has no other accounts
&lt;/h2&gt;

&lt;p&gt;Inside the app, transfers between your own accounts are removed by matching them across accounts. A single CSV has nothing to match against, so the finder filters by name instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;NOT_A_CHARGE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="sr"&gt;/TRANSFER|&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;XFER&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;|PAYMENT THANK YOU|AUTOPAY PAYMENT|ONLINE PAYMENT|&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;ZELLE&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;|&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;ATM&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;|DEPOSIT|INTEREST &lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;CHARGE|PAID&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is a heuristic and it will sometimes be wrong. I would rather show a slightly noisy list than hide real subscriptions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Short statements need honest handling
&lt;/h2&gt;

&lt;p&gt;Three charges are the minimum, so a one month statement cannot show recurrence at all. I handle that two ways. A list of well known subscription merchants flags a charge seen only once as a possible subscription. And if the tool finds nothing and the statement covers less than three months, it says that is probably why, instead of implying you have no subscriptions.&lt;/p&gt;

&lt;p&gt;Real bank files are messy too. Some banks put a few account summary lines above the header, so the parser looks through the first 15 rows for the real header with date, description, and amount columns. Billing phone numbers get stripped from merchant names, so "HULU 877-8244858" and "HULU" are not listed twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell another developer
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;If a privacy promise can be structural, make it structural. No upload endpoint beats a paragraph in a policy.&lt;/li&gt;
&lt;li&gt;Reuse the product's real logic for a free tool, so the free version and the product agree.&lt;/li&gt;
&lt;li&gt;When the input is too small to answer the question, say so. Tell the person what you cannot see.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you want to try it, it is free and needs no account: &lt;a href="https://finsight.it.com/free-subscription-finder" rel="noopener noreferrer"&gt;https://finsight.it.com/free-subscription-finder&lt;/a&gt;. It is part of FinSight, a budgeting app with an AI assistant that explains your money.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>fintech</category>
    </item>
    <item>
      <title>How I built an AI that explains your money, with no way to touch it</title>
      <dc:creator>pedro acosta</dc:creator>
      <pubDate>Mon, 26 Oct 2026 18:49:58 +0000</pubDate>
      <link>https://dev.to/pedroacosta613/how-i-built-an-ai-that-explains-your-money-with-no-way-to-touch-it-1kbi</link>
      <guid>https://dev.to/pedroacosta613/how-i-built-an-ai-that-explains-your-money-with-no-way-to-touch-it-1kbi</guid>
      <description>&lt;p&gt;I'm a software engineer, and I built a budgeting app because I was tired of guessing where my money went. The part people ask about most is the AI assistant, so this post is about the one design choice that shaped all of it: the AI can read a snapshot of your finances, and it has no way to do anything else.&lt;/p&gt;

&lt;p&gt;The stack is Next.js, Prisma, Clerk for auth, Plaid for bank connections, and Claude for the assistant, streamed through the Vercel AI SDK. Here is how the read-only part works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer one: the bank connection only reads
&lt;/h2&gt;

&lt;p&gt;When someone connects a bank, the Plaid Link token asks for exactly one product, Transactions. There is no payment initiation product in the request, so even a bug on my side cannot create a transfer. It is a boundary the bank enforces, not a promise in my code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer two: the model gets a snapshot, not a database
&lt;/h2&gt;

&lt;p&gt;The assistant does not run queries and it has no tools. Before each chat turn, the server builds a text summary of the user's finances (account balances, three months of cash flow, spending by category, budgets, goals, and upcoming recurring charges) and passes it as the system message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;systemContext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;buildAiContext&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;streamText&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;claude-sonnet-4-6&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;system&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;systemContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;messagesForModel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;maxOutputTokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1024&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 model can only talk about what is in that snapshot. It cannot fetch more, write anything back, or call out to anything. If the model misbehaves completely, the worst outcome is a bad answer on screen.&lt;/p&gt;

&lt;p&gt;I considered giving it tools, so it could look up arbitrary date ranges. I decided against it. Every tool is a new way for a prompt to cause a side effect, and for a money app the extra flexibility is not worth the new attack surface. A bigger or better organized snapshot solves most of what tools would have solved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer three: cache the snapshot, retrieve the rest
&lt;/h2&gt;

&lt;p&gt;The snapshot is the expensive part of each request, and it barely changes between messages in one conversation. So it sits in the system message with Anthropic's prompt caching turned on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;providerOptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;cacheControl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ephemeral&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;Follow up questions in the same session reuse the cached prefix instead of paying for it again.&lt;/p&gt;

&lt;p&gt;For general money questions that are not about the user's own data, like "what is an avalanche payoff", I retrieve a few articles from a small knowledge base and put them in the last user message. They go there on purpose. Retrieval results differ on every turn, so putting them in the cached system message would break the cache every time. A failed lookup falls back to the plain messages, so retrieval can never take the chat down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer four: boring guardrails
&lt;/h2&gt;

&lt;p&gt;Monthly message quotas are enforced per plan and counted in the database, and a Redis rate limit sits in front of everything as an abuse guard. The request body is validated with Zod, and the message list only allows user and assistant roles. A client cannot smuggle in a fake system message, because the Messages API has no system role inside the list at all, and I restrict the schema to match.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell another developer
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Decide what the model must never be able to do, then make that impossible at the boundary, not in the prompt.&lt;/li&gt;
&lt;li&gt;Prefer a good snapshot over tools until you can name a specific question the snapshot cannot answer.&lt;/li&gt;
&lt;li&gt;Put stable context in the cached prefix and volatile context in the user turn.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you want to see the result, the app is FinSight at &lt;a href="https://finsight.it.com" rel="noopener noreferrer"&gt;https://finsight.it.com&lt;/a&gt;, and there is a free tool that finds recurring charges in a bank statement CSV without an account: &lt;a href="https://finsight.it.com/free-subscription-finder" rel="noopener noreferrer"&gt;https://finsight.it.com/free-subscription-finder&lt;/a&gt;. I'm happy to answer questions about any of this in the comments.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>ai</category>
      <category>webdev</category>
      <category>fintech</category>
    </item>
  </channel>
</rss>
