Inspiration
Plumbers, electricians, cleaners and tutors do the work first and the paperwork later, often days later. An invoice sent at the door gets paid sooner than one typed up on Sunday night.
What it does
You type or speak what you did, for example "Fixed the boiler for Ola Nowak, [email protected]. 2 hours at 50 euro an hour, valve 30, 10 percent off for a repeat customer, plus 23 percent VAT." Paid Tonight turns it into an invoice draft with the customer, line items, discount and tax, and shows the totals. You correct anything and tap Send. The PayPal invoice goes to the customer, and a QR code on your screen lets them pay on the spot.
In the live PayPal sandbox that sentence now becomes an invoice for 143.91 EUR (130.00 subtotal, 13.00 off, 26.91 VAT after the discount), and PayPal's own total matches. In an earlier sandbox run with a customer email, PayPal emailed the invoice, returned the QR code and pay link, returned the same invoice when the send was retried, listed it as unpaid and sent its reminder.
After Send the success card follows PayPal's live status every 5 seconds for up to 2 minutes and turns green by itself when the invoice is paid. It can also record a cash payment at the door through PayPal's own record payment API for the amount due, so the paid status comes from PayPal. PayPal's signed webhook covers INVOICING.INVOICE.PAID, INVOICING.INVOICE.CANCELLED and INVOICING.INVOICE.REFUNDED, and every event is verified with PayPal before anything is stored. The Unpaid tab drops an invoice that a verified PayPal webhook reported as paid and shows it in a Paid today list.
In the live sandbox on 18 Oct 2026, invoice INV2-UQ95-FLW8-W34K-VQAC moved from UNPAID to MARKED_AS_PAID through PayPal record payment, with 143.91 EUR paid and 0.00 due. A second invoice, PT-R4HW2213119-658BC4, appeared in the Paid today list after PayPal's signed INVOICING.INVOICE.PAID webhook was verified.
The Unpaid tab reads your open invoices from PayPal. It sends PayPal's standard reminder when you tap it, and a second AI step drafts a personal message, friendly or firm depending on how late the invoice is, that you can send by email in your own words. Customer emails are masked in the list.
How we built it
- Cloudflare Workers serve the app and the API. Workers AI (Llama 3.3 70B) reads the job description and returns structured JSON, which is validated before anything else happens.
- Money is never computed by the model. The server computes totals in integer cents the way PayPal does, confirmed against the live sandbox, with tax charged after the discount on each discounted line, rounded the way PayPal rounds each line. A discount is baked into the unit prices so PayPal charges tax after the discount.
- PayPal Invoicing v2 (sandbox) creates a draft with a per-draft invoice number, reads back PayPal's total so the app sends only if it matches, then sends, generates the QR code, lists unpaid invoices and reminds.
- The payment loop is closed with PayPal. The record payment API marks a cash payment at the door, the signed webhook is verified with PayPal before anything is stored, and the success card follows the live status by itself every 5 seconds.
Challenges
Spoken descriptions are messy. "Minus 15 discount" was first billed as an extra 15, and "3000 yen an hour" became 6,000 dollars. Both now come out right. Discounts reduce the invoice, and a percentage discount such as "10 percent off", "10 % Rabatt" or "descuento del 15 %" is applied to every line in any language. When a job names both, as in "10 percent off and another 5 euro off", both come off, but only when the description itself names the second amount, so a model that repeats the percent share is never billed twice. If the description names an amount the model missed, the draft asks the seller to check it. A discount is baked into the unit prices, so VAT is charged after the discount. When an uneven fixed discount cannot divide by each quantity, PayPal applies it at invoice level after tax, and the draft carries a warning. Tax rates with 3 decimals such as 8.875 work, and a European number like 1.250,00 is traced back to the job text. Item names stay in the language of the job.
Currency words count only next to an amount, so a "euro cylinder lock" stays an item, and a currency the app cannot invoice is refused instead of converted silently. Prompt injection inside the description is treated as data. Invented emails are flagged, links are removed from notes, and absurd amounts are refused.
Accomplishments
- A retry can never create or email a second invoice, and a corrected draft is never sent as the old one, because the invoice number is tied to the draft and its content, and PayPal's own total is checked before sending.
- Five of five live sandbox invoices matched PayPal's total to the cent, at 143.91, 143.91, 99.99, 1563.33 and 1670.14.
- Fractional hours match too. A German job of 3.5 hours at 65.50 plus a 1,249.00 part, 10 percent off and 19 percent VAT gives 1583.21 in the app and in PayPal.
- 244 automated tests run without network access, with a fake PayPal that follows the Invoicing v2 and Notifications APIs.
What's next
Recurring customers, a materials price list, and SMS delivery for the reminder message.
AI tools used
- Workers AI with @cf/meta/llama-3.3-70b-instruct-fp8-fast extracts the invoice and drafts the reminder message.
- The browser Web Speech API turns speech into text in the browser, with a language picker for Polish, English, German and Dutch.
- Built with help from Claude (Anthropic) and DeepSeek coding agents, with every change checked and tested.
Built with
TypeScript, Cloudflare Workers, Workers AI, Workers KV, PayPal Invoicing API v2 (sandbox), zod.
Links
- The live demo is at https://paid-tonight.lipmichal.workers.dev
- The code (MIT) is at https://github.com/theluckystrike/paid-tonight
- The demo video is at https://youtu.be/zyJFPQg8pAY
Built by Michael Lip of Warsaw, Poland, github.com/theluckystrike. Not affiliated with PayPal.
Built With
- cloudflare-workers
- llama
- paypal
- paypal-webhooks
- typescript
- workers-ai
- zod
Log in or sign up for Devpost to join the conversation.