<?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: Kleo Geos</title>
    <description>The latest articles on DEV Community by Kleo Geos (@kleogeos).</description>
    <link>https://dev.to/kleogeos</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%2F4191174%2F5fe990e2-eacd-4f98-9535-55bb09af5ba2.png</url>
      <title>DEV Community: Kleo Geos</title>
      <link>https://dev.to/kleogeos</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kleogeos"/>
    <language>en</language>
    <item>
      <title>A Client Stole My Proposal and Hired Someone Cheaper to Build It — Here's the Paid-Scoping Fix</title>
      <dc:creator>Kleo Geos</dc:creator>
      <pubDate>Thu, 22 Oct 2026 05:20:53 +0000</pubDate>
      <link>https://dev.to/kleogeos/a-client-stole-my-proposal-and-hired-someone-cheaper-to-build-it-heres-the-paid-scoping-fix-2p98</link>
      <guid>https://dev.to/kleogeos/a-client-stole-my-proposal-and-hired-someone-cheaper-to-build-it-heres-the-paid-scoping-fix-2p98</guid>
      <description>&lt;p&gt;A pattern that comes up constantly in freelance communities: you spend real hours on a detailed proposal (architecture, scope, pricing) to win a client, they go quiet after the call, and a few weeks later their project ships — built exactly on top of what you sent them, just by someone cheaper.&lt;/p&gt;

&lt;p&gt;The usual advice is "charge for the proposal." True, but it's incomplete — the part that actually determines whether a client says yes or walks is &lt;em&gt;how&lt;/em&gt; you structure that paid step. Three things that made the difference for me:&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Split the pitch into two documents, not one
&lt;/h2&gt;

&lt;p&gt;Most people send a single combined doc: why-us &lt;em&gt;and&lt;/em&gt; the exact technical plan, in one file. That's exactly what makes it easy to lift — the client has everything they need to brief a cheaper freelancer the moment you hit send.&lt;/p&gt;

&lt;p&gt;Instead, split it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Capability pitch&lt;/strong&gt; (free to share): who you are, the expected outcome, rough timeline and price range. No architecture, no step-by-step plan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Execution blueprint&lt;/strong&gt; (gated): the actual "how we'd build it" — only exists after a paid scoping step.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A client who ghosts after the free pitch never had the material to hand to someone else in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Make the paid step a real invoice, not a verbal policy
&lt;/h2&gt;

&lt;p&gt;"We charge for scoping" said out loud on a call is not the same as a dated invoice for a named deliverable ("Technical scope &amp;amp; architecture document — Project X"). If a dispute ever happens, "here's the signed quote and the paid invoice for this specific document" is a real paper trail. "We had a call and I mentioned we charge for that" is not.&lt;/p&gt;

&lt;p&gt;I use my own &lt;a href="https://kleogeos.github.io/freelancer-tools/invoice.html?src=devto6" rel="noopener noreferrer"&gt;invoice generator&lt;/a&gt; for this — a scoping fee is a line item like any other, it just happens to be deliverable #0.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Frame the fee as creditable, not an extra cost
&lt;/h2&gt;

&lt;p&gt;State up front: &lt;em&gt;this fee becomes a credit toward the project if you hire me.&lt;/em&gt; This kills the "why would I pay for a proposal" objection, because the honest answer becomes "you're paying for this research either way — this way you keep the value if we move forward." It reduces a scoping fee from "extra friction" to "you were always going to pay for this, I'm just making it explicit and fair."&lt;/p&gt;

&lt;h2&gt;
  
  
  The part people skip: put it in writing before the blueprint goes out
&lt;/h2&gt;

&lt;p&gt;None of this works as a verbal understanding. Before I send the gated blueprint, the client has signed something — doesn't need to be a lawyer-drafted contract, a one-page mutual agreement covering confidentiality and the credit terms is enough to make the paid-scoping step mean something if it's ever tested. I use a &lt;a href="https://kleogeos.github.io/freelancer-tools/nda-generator.html?src=devto6" rel="noopener noreferrer"&gt;free NDA generator&lt;/a&gt; for exactly this — fill in two names, done in under a minute, no account needed.&lt;/p&gt;

&lt;p&gt;And if you're scoping a full services contract (not just an NDA) after the paid phase converts into real work, a &lt;a href="https://kleogeos.github.io/freelancer-tools/contract-checklist.html?src=devto6" rel="noopener noreferrer"&gt;contract checklist scanner&lt;/a&gt; catches the clauses people forget — payment terms with a real late fee, IP assignment tied to a specific moment, an acceptance deadline. I wrote about &lt;a href="https://dev.to/member_fb93637b/6-clauses-your-freelance-contract-needs-or-you-risk-not-getting-paid-584b"&gt;the 6 clauses that actually matter&lt;/a&gt; in more detail previously.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't fix
&lt;/h2&gt;

&lt;p&gt;A bad-faith client who's willing to pay a small scoping fee and &lt;em&gt;still&lt;/em&gt; shop your blueprint around afterward isn't stopped by any of this — nothing short of a strong contract and a lawyer stops that. What this does fix is the much more common case: the tire-kicker who was never going to pay for anything, filtered out before you've sunk two unpaid days into their "quick proposal."&lt;/p&gt;

</description>
      <category>career</category>
      <category>freelance</category>
      <category>productivity</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I added a $1.30 invoice generator to my Telegram bot — here's the parser that never throws</title>
      <dc:creator>Kleo Geos</dc:creator>
      <pubDate>Tue, 20 Oct 2026 21:13:11 +0000</pubDate>
      <link>https://dev.to/kleogeos/i-added-a-130-invoice-generator-to-my-telegram-bot-heres-the-parser-that-never-throws-2bhb</link>
      <guid>https://dev.to/kleogeos/i-added-a-130-invoice-generator-to-my-telegram-bot-heres-the-parser-that-never-throws-2bhb</guid>
      <description>&lt;p&gt;A few weeks ago I wrote about running a &lt;a href="https://dev.to/member_fb93637b/i-run-a-telegram-stars-payment-bot-for-0month-forever-on-github-actions-not-a-server-4a5a"&gt;Telegram Stars payment bot on GitHub Actions for $0/month&lt;/a&gt;. That bot only sold one fixed product. This week I added a second, much cheaper product to the same bot: a buyer sends a plain-text list of line items in chat, gets a priced preview and a payment link, pays 100 Stars (~$1.30), and the bot sends back a polished HTML invoice.&lt;/p&gt;

&lt;p&gt;The interesting part isn't the payment flow — that's the same &lt;code&gt;pre_checkout_query&lt;/code&gt;/&lt;code&gt;successful_payment&lt;/code&gt; plumbing as before. It's the parser that turns a Telegram message into invoice data, because unlike a web form, a chat message has no input validation before it reaches your code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The format
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/invoice
from: Your Name
to: Client Name
invoice_number: INV-001
date: 2026-10-20
due_date: 2026-11-20
tax_rate: 0
notes: Thanks for your business
item: Website redesign | 10 | 50
item: Logo design | 1 | 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Line-based &lt;code&gt;key: value&lt;/code&gt;, with repeated &lt;code&gt;item:&lt;/code&gt; lines split on &lt;code&gt;|&lt;/code&gt; for description/qty/rate. No JSON, no quoting rules, nothing a non-technical buyer has to get exactly right syntactically — just colons and pipes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule that shaped the whole function: never raise
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;parse_invoice_text()&lt;/code&gt; runs directly on untrusted user input with no validation layer in front of it (there's no form, no client-side JS — a Telegram message is just a string). So the function's contract is &lt;code&gt;(data, None)&lt;/code&gt; on success or &lt;code&gt;(None, error_message)&lt;/code&gt; on failure, and it is not allowed to raise:&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;parse_invoice_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ln&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;ln&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;splitlines&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;ln&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="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;ln&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&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="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ln&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&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="s"&gt;Couldn&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t parse this line (missing &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="s"&gt;): &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ln&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;USAGE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ln&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;partition&lt;/span&gt;&lt;span class="p"&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="bp"&gt;...&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;item&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;p&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&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="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&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="s"&gt;Item line needs exactly 3 parts separated by &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="s"&gt; ...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;qty&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;qty_s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rate_s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&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="s"&gt;qty and rate must be numbers, got: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ln&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;USAGE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every failure path returns the exact same usage text that would've gotten the message right in the first place, inlined after the specific complaint. A buyer who gets &lt;code&gt;qty and rate must be numbers, got: 'item: Logo design | one | 200'&lt;/code&gt; immediately sees both what they did and what's expected, in the same message, with no need to scroll up. 26 tests cover this (valid messages, missing fields, malformed item lines, non-numeric qty/rate, bare &lt;code&gt;/invoice&lt;/code&gt; with no body) — the whole point of this function is that it has no code path that reaches an uncaught exception, and the tests exist to keep that true across edits, not just to hit a coverage number.&lt;/p&gt;

&lt;h2&gt;
  
  
  The forgiving part: unknown keys are silently ignored
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;key&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;from&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;to&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;invoice_number&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;date&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;due_date&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;notes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;
&lt;span class="c1"&gt;# unknown keys are silently ignored rather than rejected — keeps the
# format forgiving of e.g. a stray colon in a copy-pasted notes line
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If I made unknown keys an error, a buyer who pastes a &lt;code&gt;notes:&lt;/code&gt; line containing its own colon (&lt;code&gt;notes: Paid via wire, ref: 4471&lt;/code&gt;) wouldn't actually break anything, since &lt;code&gt;partition(":")&lt;/code&gt; only splits on the first colon and &lt;code&gt;notes&lt;/code&gt; is matched as a known key before this branch could ever trigger — but the design leaves room for a buyer typo'ing a field name (&lt;code&gt;not:&lt;/code&gt; instead of &lt;code&gt;notes:&lt;/code&gt;) to fail &lt;em&gt;silently&lt;/em&gt; rather than loudly. That's a real tradeoff, not a free win: silent data loss is worse than a loud rejection in some products. For a $1.30 one-shot invoice I chose forgiving over strict, because the buyer sees a full preview before paying — a dropped field shows up as a visibly blank line in the preview, not as corrupted output after money has changed hands.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reusing the renderer, not importing it
&lt;/h2&gt;

&lt;p&gt;The HTML the bot sends back is byte-for-byte the same template as my free, zero-dependency &lt;a href="https://github.com/kleogeos/freelance-invoice-cli" rel="noopener noreferrer"&gt;&lt;code&gt;freelance-invoice-cli&lt;/code&gt;&lt;/a&gt; — same fonts, same table layout, same print stylesheet. I vendored the render function into this repo instead of importing it across repos at runtime, specifically so a buyer who later wants to self-serve with the free CLI isn't surprised by a different-looking invoice, and so this bot has no runtime dependency on another repo's availability. The tradeoff is the usual one for vendoring: if the CLI's renderer changes, I have to remember to port the change by hand. For a ~30-line pure function that's a cost I'm fine carrying.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it stands
&lt;/h2&gt;

&lt;p&gt;Live now: send &lt;code&gt;/invoice&lt;/code&gt; to &lt;a href="https://t.me/freelancerkitbot" rel="noopener noreferrer"&gt;@freelancerkitbot&lt;/a&gt; with the format above. &lt;a href="https://kleogeos.github.io/freelancer-tools/invoice-bot.html" rel="noopener noreferrer"&gt;More detail + a worked example&lt;/a&gt;. No new hosting, no new bot, no new Telegram app — same $0/month Actions-hosted responder from the earlier post, same bot token, new code path. Code (MIT): &lt;a href="https://github.com/kleogeos/telegram-stars-checkout" rel="noopener noreferrer"&gt;https://github.com/kleogeos/telegram-stars-checkout&lt;/a&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>telegram</category>
      <category>showdev</category>
      <category>api</category>
    </item>
    <item>
      <title>I fuzzed 50,000 dollar amounts across my Python JS ports and found a silent 1.2% rounding bug</title>
      <dc:creator>Kleo Geos</dc:creator>
      <pubDate>Tue, 20 Oct 2026 14:31:14 +0000</pubDate>
      <link>https://dev.to/kleogeos/i-fuzzed-50000-dollar-amounts-across-my-python-js-ports-and-found-a-silent-12-rounding-bug-ld0</link>
      <guid>https://dev.to/kleogeos/i-fuzzed-50000-dollar-amounts-across-my-python-js-ports-and-found-a-silent-12-rounding-bug-ld0</guid>
      <description>&lt;p&gt;I maintain ~10 small zero-dependency CLI tools for freelancer admin (invoicing, late fees, currency conversion, tax estimates) — some in Python, some ported to Node so people can &lt;code&gt;npx&lt;/code&gt; them without installing anything. Porting money-handling code between the two languages taught me a lesson the hard way: &lt;strong&gt;&lt;code&gt;Math.round(n * 100) / 100&lt;/code&gt; and Python's &lt;code&gt;round(n, 2)&lt;/code&gt; do not agree 1.2% of the time&lt;/strong&gt;, and the disagreement is silent — no crash, no NaN, just a wrong cent that only shows up on specific inputs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Every port had the same shape: read a dollar/ruble amount, do arithmetic (apply a rate, a fee, a tax bracket), round to 2 decimals, print it. I'd hand-write 5-10 test cases per port, compare output against the Python original byte-for-byte, and ship once they matched. That caught obvious bugs. It did not catch this one, because none of my hand-picked test cases happened to land exactly on a rounding boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why they disagree
&lt;/h2&gt;

&lt;p&gt;Python's &lt;code&gt;round()&lt;/code&gt; uses &lt;strong&gt;round-half-to-even&lt;/strong&gt; (banker's rounding) and operates on the &lt;em&gt;exact&lt;/em&gt; binary value of the float. JavaScript has no built-in decimal round — the common pattern is:&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;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This rounds &lt;strong&gt;half-up&lt;/strong&gt;, but more importantly, &lt;code&gt;n * 100&lt;/code&gt; is itself a new floating-point multiplication that can land on a different side of &lt;code&gt;.5&lt;/code&gt; than the original value, because &lt;code&gt;n * 100&lt;/code&gt; isn't the same double as "the true value times 100" — it's the double closest to that product.&lt;/p&gt;

&lt;p&gt;A real one that shipped and sat live for days before I caught it — converting USDT to RUB at a P2P rate (85900 USDT at a rate of 117.1):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Python (ground truth):   9993507.21
JS (Math.round, shipped): 9993507.22
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One cent off, on a 7-figure number, with zero errors or warnings anywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I actually found it
&lt;/h2&gt;

&lt;p&gt;Not by reading the code harder — I'd already read it. I wrote a fuzzer: generate 50,000 random amounts, rates, and fees, run both the Python original and the JS port on each, diff the outputs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;50,000 random inputs → 1.2% disagreed by exactly 1 cent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;1.2% doesn't sound like much until you remember every one of those is a real dollar amount a real invoice would show to a real client.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Round-half-to-even on the &lt;em&gt;exact&lt;/em&gt; decimal expansion of the double, not on an intermediate &lt;code&gt;n * 100&lt;/code&gt; multiplication:&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;round2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&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;neg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&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;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// exact decimal expansion of the double&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;intPart&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fracPart&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.&lt;/span&gt;&lt;span class="dl"&gt;"&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;keep&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fracPart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&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;rest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fracPart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;roundUp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;5&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;roundUp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;5&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;roundUp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;else&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;restAfter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/0+$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;roundUp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;restAfter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;keep&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;keepNum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BigInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;intPart&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;keep&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;padEnd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;roundUp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;keepNum&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;n&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="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;keepNum&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;neg&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&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;It's uglier than one line of &lt;code&gt;Math.round&lt;/code&gt;, but it agrees with Python on all 50,000 fuzzed inputs — and on every input since.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd actually recommend
&lt;/h2&gt;

&lt;p&gt;If you're porting money logic between languages (or even just adding decimal rounding to a JS app that touches money), don't trust hand-picked test cases to catch rounding-boundary bugs — they're specifically the inputs you're least likely to think of by hand. A quick fuzz-and-diff against a reference implementation (or against &lt;code&gt;toFixed&lt;/code&gt; + exact decimal math, if you don't have one) takes a few minutes to write and will catch the 1-in-80 case your test suite won't.&lt;/p&gt;

&lt;p&gt;The affected tools (now fixed) are open source, zero-dependency, MIT-licensed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/kleogeos/usdt-rub-js" rel="noopener noreferrer"&gt;usdt-rub-js&lt;/a&gt; — USDT→RUB conversion estimator&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/kleogeos/freelance-currency-js" rel="noopener noreferrer"&gt;freelance-currency-js&lt;/a&gt; — live-rate currency converter&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/kleogeos/freelance-late-fee-js" rel="noopener noreferrer"&gt;freelance-late-fee-js&lt;/a&gt; — overdue invoice late-fee calculator&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/kleogeos/freelance-rate-js" rel="noopener noreferrer"&gt;freelance-rate-js&lt;/a&gt; — hourly rate calculator&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rest of these money-math tools ship together in the &lt;a href="https://kleogeos.github.io/freelancer-tools/kit.html?src=devto4" rel="noopener noreferrer"&gt;Freelancer Starter Kit&lt;/a&gt; — invoicing, contracts, NDAs, timesheets, all the same zero-dependency philosophy.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Have you hit a similar cross-language rounding bug?&lt;/strong&gt; JS &lt;code&gt;Math.round&lt;/code&gt; vs Python &lt;code&gt;round()&lt;/code&gt;, or any two money-math implementations quietly disagreeing by a cent — I'd like to hear the specific input that broke yours. Drop it in the comments.&lt;/p&gt;

&lt;p&gt;If the fuzz-and-diff approach was useful, a ❤️ or 🦄 helps other people writing money logic find this post.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://kleogeos.github.io/freelancer-tools/kit.html?src=devto4cta" rel="noopener noreferrer"&gt;Try the bug-fixed tools free, no signup →&lt;/a&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>python</category>
      <category>testing</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I run a Telegram Stars payment bot for $0/month forever — on GitHub Actions, not a server</title>
      <dc:creator>Kleo Geos</dc:creator>
      <pubDate>Sat, 17 Oct 2026 23:58:02 +0000</pubDate>
      <link>https://dev.to/kleogeos/i-run-a-telegram-stars-payment-bot-for-0month-forever-on-github-actions-not-a-server-4a5a</link>
      <guid>https://dev.to/kleogeos/i-run-a-telegram-stars-payment-bot-for-0month-forever-on-github-actions-not-a-server-4a5a</guid>
      <description>&lt;p&gt;Telegram Stars (currency &lt;code&gt;XTR&lt;/code&gt;) let you sell digital goods inside Telegram with no bank, no card network, no Stripe/PayPal, and no KYC on the selling side. The catch most tutorials skip: Telegram requires &lt;code&gt;answerPreCheckoutQuery&lt;/code&gt; within &lt;strong&gt;10 seconds&lt;/strong&gt; of every payment attempt — there's no auto-approve fallback. That means you need something always-on, listening. Normally that's a $5-20/month VPS or a serverless function with its own billing.&lt;/p&gt;

&lt;p&gt;I didn't want a second hosting bill for a side project, so I run the responder entirely inside a &lt;strong&gt;public GitHub repo's Actions minutes&lt;/strong&gt;, which are unlimited for public repos.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trick
&lt;/h2&gt;

&lt;p&gt;A single GitHub Actions job can run up to 6 hours. So:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Long-poll Telegram's &lt;code&gt;getUpdates&lt;/code&gt; for ~5h50m inside one job.&lt;/li&gt;
&lt;li&gt;Commit the updated offset + a dedup list of already-fulfilled payment IDs back to the repo as the last step.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;cron&lt;/code&gt; schedule re-triggers the next run every 6 hours — it queues right behind the previous job (same concurrency group), so the gap between one run ending and the next starting is seconds, not minutes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Net result: near-continuous coverage, $0/month, no separate hosting account to manage or lose access to.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*/6&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;
&lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;telegram-stars-bot&lt;/span&gt;
  &lt;span class="na"&gt;cancel-in-progress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;respond&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;timeout-minutes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;355&lt;/span&gt;   &lt;span class="c1"&gt;# just under the 6h hard cap&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The bug this trick hides until your first real sale
&lt;/h2&gt;

&lt;p&gt;The last step commits &lt;code&gt;state.json&lt;/code&gt; (the &lt;code&gt;getUpdates&lt;/code&gt; offset) and &lt;code&gt;fulfilled.json&lt;/code&gt; (processed payment IDs) back to &lt;code&gt;main&lt;/code&gt;. A plain &lt;code&gt;git push&lt;/code&gt; there is a trap: if &lt;em&gt;anything else&lt;/em&gt; pushes to &lt;code&gt;main&lt;/code&gt; during that ~5h50m window — even an unrelated commit to a different file — the push fails with a plain &lt;code&gt;! [rejected] (fetch first)&lt;/code&gt;. The job itself still shows green in the Actions tab (the failure is buried in one sub-step), so nothing alerts you.&lt;/p&gt;

&lt;p&gt;What actually breaks: the whole run's progress — offset &lt;em&gt;and&lt;/em&gt; the fulfilled-payment dedup list — gets silently discarded. The next run starts from the old offset, Telegram happily replays the same updates (it doesn't drop them without a confirmed offset), and because the dedup record also reverted, a real purchase could get double-processed — not a double charge to the buyer, but a duplicate delivery message, which looks broken to a paying customer on their first interaction with your bot.&lt;/p&gt;

&lt;p&gt;Fix: retry the push with a rebase instead of giving up after one attempt.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in &lt;/span&gt;1 2 3 4 5&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;git push &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0
  git fetch origin main
  git rebase origin/main &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; git rebase &lt;span class="nt"&gt;--abort&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
  &lt;span class="nb"&gt;sleep &lt;/span&gt;3
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I found this by noticing a run failure buried in email notifications, not from a customer complaint — it hadn't sold anything yet when I caught it, but it was a live landmine for the first real sale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it stands
&lt;/h2&gt;

&lt;p&gt;Live since Oct 17: dedicated bot (&lt;a href="https://t.me/freelancerkitbot" rel="noopener noreferrer"&gt;@freelancerkitbot&lt;/a&gt;), &lt;code&gt;pre_checkout_query&lt;/code&gt; answered automatically, &lt;code&gt;successful_payment&lt;/code&gt; triggers an automatic delivery message, replayed updates can't double-deliver (charge IDs are deduped). Zero sales so far — I'm not going to pretend otherwise — but the plumbing is solid and tested (unit tests mock the Telegram API, no token needed to run them).&lt;/p&gt;

&lt;h2&gt;
  
  
  Update (Oct 20)
&lt;/h2&gt;

&lt;p&gt;Added a second use for the same bot: send &lt;code&gt;/invoice&lt;/code&gt; as a plain-text message (from/to, line items as &lt;code&gt;item: description|qty|rate&lt;/code&gt;) and it replies with a payment link; pay 100 Stars (~$1.3) and it generates and sends back the HTML invoice file. Same &lt;code&gt;pre_checkout&lt;/code&gt;/&lt;code&gt;successful_payment&lt;/code&gt; plumbing this post describes, just a second payload type and a new &lt;code&gt;pending_orders.json&lt;/code&gt; dedup list alongside &lt;code&gt;fulfilled.json&lt;/code&gt;. Try it: &lt;a href="https://kleogeos.github.io/freelancer-tools/invoice-bot.html?src=devto3" rel="noopener noreferrer"&gt;https://kleogeos.github.io/freelancer-tools/invoice-bot.html&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Code (MIT): &lt;a href="https://github.com/kleogeos/telegram-stars-checkout" rel="noopener noreferrer"&gt;https://github.com/kleogeos/telegram-stars-checkout&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you want to see it live: &lt;a href="https://kleogeos.github.io/freelancer-tools/buy-crypto.html?src=devto3" rel="noopener noreferrer"&gt;one of the checkout pages&lt;/a&gt; has a "Pay with Telegram Stars" button wired to this exact repo.&lt;/p&gt;

</description>
      <category>python</category>
      <category>opensource</category>
      <category>githubactions</category>
      <category>showdev</category>
    </item>
    <item>
      <title>6 Clauses Your Freelance Contract Needs (Or You Risk Not Getting Paid)</title>
      <dc:creator>Kleo Geos</dc:creator>
      <pubDate>Sat, 17 Oct 2026 22:44:08 +0000</pubDate>
      <link>https://dev.to/kleogeos/6-clauses-your-freelance-contract-needs-or-you-risk-not-getting-paid-584b</link>
      <guid>https://dev.to/kleogeos/6-clauses-your-freelance-contract-needs-or-you-risk-not-getting-paid-584b</guid>
      <description>&lt;p&gt;I built a free &lt;a href="https://kleogeos.github.io/freelancer-tools/contract-checklist.html?src=devto2" rel="noopener noreferrer"&gt;contract checklist tool&lt;/a&gt; that scans a pasted contract for missing clauses. Writing the checks made it obvious which 6 clauses actually matter — the rest is boilerplate.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Scope and acceptance criteria
&lt;/h2&gt;

&lt;p&gt;"Build a website" is a conversation topic, not a scope. Name the exact deliverable, format, and how the client verifies it is done. Without this, every revision request becomes an unwinnable argument about what you actually promised.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Acceptance deadline with an auto-accept clause
&lt;/h2&gt;

&lt;p&gt;Give the client a fixed number of days (3-5 business days is typical) to approve the work or send written feedback. State that silence past the deadline counts as acceptance. Without this clause, a client who goes quiet can block you from ever formally closing the project -- and from invoicing with a clean paper trail.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Payment terms with a real late fee
&lt;/h2&gt;

&lt;p&gt;Net 15 or Net 30 from invoice date, not "on completion." Add a concrete late fee (commonly 1.5%/month on the overdue balance) and the right to pause work once payment is overdue. A deadline with no penalty attached is a suggestion, not a term.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. IP assignment, tied to a specific moment
&lt;/h2&gt;

&lt;p&gt;"Client may use the work" is not an assignment. State explicitly whether full rights transfer -- and from what moment, usually full payment, not delivery -- or whether it is a license. Skip this and default copyright law applies, which can mean the client is not the sole legal owner even after paying in full.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Confidentiality / NDA
&lt;/h2&gt;

&lt;p&gt;If the work touches client data, system access, or business information, a confidentiality clause (or separate NDA) protects both sides and makes clients more comfortable sharing what you actually need to do the job well.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. A plan for when the client disappears
&lt;/h2&gt;

&lt;p&gt;A kill fee for mid-project cancellation, combined with clauses 2 and 3 above, gives you a concrete written basis to invoice and collect -- instead of an unenforceable verbal understanding.&lt;/p&gt;




&lt;p&gt;Full write-up: &lt;a href="https://kleogeos.github.io/freelancer-tools/how-to-write-a-freelance-contract.html?src=devto2" rel="noopener noreferrer"&gt;How to Write a Freelance Contract&lt;/a&gt;. The checklist tool above runs entirely in your browser -- nothing you paste is uploaded anywhere.&lt;/p&gt;

&lt;p&gt;I also build small zero-dependency CLIs for freelance admin (invoicing, NDAs, time tracking) -- &lt;a href="https://github.com/kleogeos/awesome-freelance-tools" rel="noopener noreferrer"&gt;repo list here&lt;/a&gt; if that is useful to anyone, or bundled as the &lt;a href="https://kleogeos.github.io/freelancer-tools/kit.html?src=devto2" rel="noopener noreferrer"&gt;Freelancer Starter Kit&lt;/a&gt; if you want them without touching a terminal.&lt;/p&gt;

</description>
      <category>freelance</category>
      <category>contract</category>
      <category>beginners</category>
      <category>productivity</category>
    </item>
    <item>
      <title>I built a suite of zero-dependency CLIs for freelancer admin (invoicing, contracts, NDAs, time tracking)</title>
      <dc:creator>Kleo Geos</dc:creator>
      <pubDate>Sat, 17 Oct 2026 21:35:56 +0000</pubDate>
      <link>https://dev.to/kleogeos/i-built-a-suite-of-zero-dependency-clis-for-freelancer-admin-invoicing-contracts-ndas-time-3769</link>
      <guid>https://dev.to/kleogeos/i-built-a-suite-of-zero-dependency-clis-for-freelancer-admin-invoicing-contracts-ndas-time-3769</guid>
      <description>&lt;p&gt;I kept rebuilding the same client paperwork for every project — proposal, NDA before the discovery call, services agreement, invoice, time tracking, and eventually a late-payment reminder — so I turned them into a set of small, zero-dependency Python CLIs. Each one takes a JSON file and outputs a clean, print-ready HTML file. No accounts, no cloud, no telemetry.&lt;/p&gt;

&lt;p&gt;The suite now covers the whole client lifecycle:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;freelance-proposal-cli&lt;/strong&gt; — scope/pricing/timeline quote&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;freelance-nda-cli&lt;/strong&gt; — mutual NDA for the discovery call before scoping&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;freelance-contract-cli&lt;/strong&gt; — plain-language services agreement&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;contract-checklist-cli&lt;/strong&gt; — scans a contract for commonly-missing clauses&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;freelance-time-cli&lt;/strong&gt; — time tracking&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;freelance-timesheet-to-invoice-cli&lt;/strong&gt; — converts tracked hours straight into an invoice&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;freelance-invoice-cli&lt;/strong&gt; — HTML invoice generator&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;freelance-late-fee-cli&lt;/strong&gt; — computes late fees and which reminder email to send&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;freelance-rate-cli&lt;/strong&gt; / &lt;strong&gt;freelance-expense-cli&lt;/strong&gt; / &lt;strong&gt;freelance-tax-cli&lt;/strong&gt; — hourly rate math, expenses, US self-employment tax&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;crypto-pay-cli&lt;/strong&gt; / &lt;strong&gt;usdt-rub-cli&lt;/strong&gt; / &lt;strong&gt;freelance-currency-cli&lt;/strong&gt; — for clients who pay in stablecoins instead of wire transfer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each one is MIT-licensed, has a test suite, and runs in CI. They're designed to chain together — timesheet hours flow into the invoice generator, the invoice generator's output matches what the late-fee calculator expects, and so on — so you're not retyping numbers between tools.&lt;/p&gt;

&lt;p&gt;Hub repo with the full list: &lt;a href="https://github.com/kleogeos/awesome-freelance-tools" rel="noopener noreferrer"&gt;https://github.com/kleogeos/awesome-freelance-tools&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you'd rather not install anything, there are free browser versions (just forms, nothing uploaded) at &lt;a href="https://kleogeos.github.io/freelancer-tools/?src=devto1" rel="noopener noreferrer"&gt;https://kleogeos.github.io/freelancer-tools/?src=devto1&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For the no-install path, the browser forms are free one at a time; a &lt;a href="https://kleogeos.github.io/freelancer-tools/kit.html?src=devto1" rel="noopener noreferrer"&gt;Freelancer Starter Kit bundle&lt;/a&gt; packages the invoicing/contract/NDA/time set together if you want them all at once.&lt;/p&gt;

&lt;p&gt;Standard disclaimer: the contract/NDA generators are deliberately simple templates (not legal advice), and that's stated right on the generated document.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;What's the most annoying piece of freelance admin busywork you still do by hand&lt;/strong&gt; — the thing you'd pay $5 to never think about again? Tell me in the comments, I'm genuinely deciding what to build next.&lt;/p&gt;

&lt;p&gt;If any of these saved you a copy-paste, a ❤️ or 🦄 helps other freelancers find them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://kleogeos.github.io/freelancer-tools/kit.html?src=devto1cta" rel="noopener noreferrer"&gt;Grab the full bundle free to try →&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>opensource</category>
      <category>python</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
