Source & license
Upstream license: MIT
License TL;DR
You can use it, change it, self-host it and sell it. Keep the original copyright and license notice with copies of the code. You don’t have to publish your changes. The authors don’t promise it will work.
Explain MIT in plain English →Summary of the main license. Separate packages and assets can have different terms.
Inspect repository ↗Read this project’s actual license ↗Repository owner
See the upstream repository for the original creator and contributors.
Maintain this project? Maintainer verification →Cloudflare hosting
Free tier eligible within limits
The minimal manual-invoice path can fit Workers/D1 free limits. The README distinguishes inexpensive standard-font PDFs from multilingual-font generation that requires Workers Paid; use the former and measure 10ms CPU fit. Stripe/PayPal fees and email sending are optional separate costs.
Hosting requirements
- Use the current starter/admin-password setup and your own D1 database. Some later README setup sections still assume older mandatory payment/domain settings.
- Do not enable multilingual PDF font embedding under a Free-plan claim. Optional arbitrary-recipient Cloudflare email requires Workers Paid.
- Source and configuration review establishes a deployment path and conditional costs; this candidate was not executed or load-tested.
Sources checked 01/10/2026
Repository snapshot: 99563a5. Hosting eligibility reflects the deployment documentation and listed assumptions.
- freshbooks ↗
Minvoice (**min**imal in**voice**) is single-business invoicing that runs entirely on Cloudflare — Workers, D1, Access, and Email Sending. Create invoices, email them with a
- invoice-ninja ↗
Minvoice (**min**imal in**voice**) is single-business invoicing that runs entirely on Cloudflare — Workers, D1, Access, and Email Sending. Create invoices, email them with a
- workers ↗
nt: works as-is on workers.dev (and with the Deploy to // Cloudflare button, which provisions the D1 database and rewrites its id). // For a custom domain, Cloudflare Access, the Cloudflare email binding, or a // staging environment, graft the relevant blocks from wrangler.jsonc.example. { "$schema": "node_modules/wrangler/config-schema.json", "name": "minvoice", "main": "src/index.tsx", "compatibility_date": "2026-06-01", "compatibility_flags": ["nodejs_compat"], "assets": { "direct
- d1 ↗
bility_flags": ["nodejs_compat"], "assets": { "directory": "./public", "binding": "ASSETS" }, "d1_databases": [ { "binding": "DB", "database_name": "minvoice", // manual setup: npx wrangler d1 create minvoice → paste the id it prints "database_id": "00000000-0000-0000-0000-000000000000" } ], // No vars needed for the starter: APP_BASE_URL falls back to the request // origin, and the PayPal live/sandbox choice lives in Settings. "observability":
- free-tier-eligible ↗
// Zero-config deployment: works as-is on workers.dev (and with the Deploy to // Cloudflare button, which provisions the D1 database and rewrites its id). // For a custom domain, Cloudflare Access, the Cloudflare email binding, or a // staging environment, graft the relevant blocks from wrangler.jsonc.example. { "$schema": "node_modules/wrangler/config-schema.json", "name": "minvoice", "main": "src/index.tsx", "compatibility_date": "2026-06-01", "compatibility_flags": ["nodejs_compat"], "assets": { "directory": "./public", "binding": "ASSETS" }, "d1_databases": [ { "binding": "DB", "database_name": "minvoice", // manual setup: npx wrangler d1 create minvoice → paste the id it prints "database_id": "00000000-0000-0000-0000-000000000000"
- free-tier-eligible ↗
| **Free** | 100,000 per day | No charge for duration | 10 milliseconds of CPU time per invocation |
- free-tier-eligible ↗
| Rows read | 5 million / day | First 25 billion / month included + $0.001 / million rows |
- free-tier-eligible ↗
| Rows written | 100,000 / day | First 50 million / month included + $1.00 / million rows |
- MIT ↗
MIT License Copyright (c) 2026 Daniel Yang Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECT
- architecture ↗
// Zero-config deployment: works as-is on workers.dev (and with the Deploy to // Cloudflare button, which provisions the D1 database and rewrites its id). // For a custom domain, Cloudflare Access, the Cloudflare email binding, or a // staging environment, graft the relevant blocks from wrangler.jsonc.example. { "$schema": "node_modules/wrangler/config-schema.json", "name": "minvoice", "main": "src/index.tsx", "compatibility_date": "2026-06-01", "compatibility_flags": ["nodejs_compat"], "assets": { "directory": "./public", "binding": "ASSETS" }, "d1_databases": [ { "binding": "DB", "database_name": "minvoice", // manual setup: npx wrangler d1 create minvoice → paste the id it prints "database_id": "00000000-0000-0000-0000-000000000000"
- architecture ↗
nt: works as-is on workers.dev (and with the Deploy to // Cloudflare button, which provisions the D1 database and rewrites its id). // For a custom domain, Cloudflare Access, the Cloudflare email binding, or a // staging environment, graft the relevant blocks from wrangler.jsonc.example. { "$schema": "node_modules/wrangler/config-schema.json", "name": "minvoice", "main": "src/index.tsx", "compatibility_date": "2026-06-01", "compatibility_flags": ["nodejs_compat"], "assets": { "direct
- architecture ↗
bility_flags": ["nodejs_compat"], "assets": { "directory": "./public", "binding": "ASSETS" }, "d1_databases": [ { "binding": "DB", "database_name": "minvoice", // manual setup: npx wrangler d1 create minvoice → paste the id it prints "database_id": "00000000-0000-0000-0000-000000000000" } ], // No vars needed for the starter: APP_BASE_URL falls back to the request // origin, and the PayPal live/sandbox choice lives in Settings. "observability":
Upstream screenshot · ddyy/minvoice repository contributors ↗. Depicts the upstream project. We have not deployed and tested a fresh installation here.
What it can replace
Compare the workflow you need. These mappings describe overlap; full feature parity requires a separate comparison.
Invoice creation, PDFs, share links and payment-status records; no full accounting, bank reconciliation or complete paid invoicing-suite parity.
See supporting source ↗Invoice creation, PDFs, share links and payment-status records; no full accounting, bank reconciliation or complete paid invoicing-suite parity.
See supporting source ↗How it works
The shape of Minvoice on Cloudflare, and how it stacks up against the rented tools it replaces.
Architecture
Diagram of deployment declarations at the reviewed commit. Each app has its own entrypoint; declared resources do not prove runtime calls. Follow file and line sources below.
View upstream source ↗Configuration and workflow sources
Reviewed commit 99563a5a4d8e. Files were read as data; upstream applications and CI jobs were not executed.
Partial source coverage: 19 files outside collection bounds; 0 collection or parsing issues. Dynamic imports and generated entrypoints may need manual review.
Deployment configuration · 2 files
Cloudflare Workers · compatibility 2026-06-01
minvoice · default
Entrypoint: src/index.tsx
Static assets: ./public
Cron triggers (UTC): 0 15 * * *
DB→ D1ASSETS→ Static assets
Cloudflare Workers · example/template, excluded from overview · compatibility 2026-06-01
minvoice-test · default
Entrypoint: ../src/index.tsx
Static assets: ../public
DB→ D1ASSETS→ Static assets
Named environments are separate deployments. Bindings are shown only where declared. Configured routes are URL patterns, not verified application endpoints.
Runtime source · handlers, binding usage and workflow steps
Observed TypeScript/JavaScript declarations from Worker entrypoints and resolved relative imports. Calls and workflow steps may run conditionally; their listed order is not a proven end-to-end request flow. Router declarations may be mounted under a prefix or may not be registered. This shows code wiring, not a successful deployment or runtime test. Dynamic wiring, aliases and generated code may not resolve.
- L178 · fetch handler exported
- L182 · scheduled handler exported · references DB · calls ctx.waitUntil, sendOverdueReminders, processEmailOutbox, purgeOldOutbox, purgeOldLoginAttempts
- L42 · app.use("*")
- L48 · app.get("/")
- L55 · app.use("/admin/*")
- L62 · app.use("/admin/*")
- L66 · app.get("/admin/login")
- L73 · app.post("/admin/login")
- L105 · app.get("/admin/logout")
- L114 · app.get("/health")
- L127 · app.get("/logo")
- L140 · app.use("/admin/*")
- L143 · app.get("/admin/invoices/:id/pdf")
- L159 · app.route("/admin")
- L162 · app.route("/pay")
- L163 · app.route("/webhooks")
Environment references: c.env.APP_BASE_URL · c.env.DB · c.env.ADMIN_PASSWORD · c.env.ASSETS · env.DB
- L15 · sendOverdueReminders calls (conditional paths may differ): getSettings, replace, trim, console.warn, todayInTz, parseSchedule, listOverdueForReminders, daysBetween, inv.last_reminder_at.slice, reminderDue, enqueueReminder, console.error
Environment references: env.DB · env.APP_BASE_URL
Environment references: c.env.ACCESS_TEAM_DOMAIN · c.env.ACCESS_AUD · c.env.ADMIN_PASSWORD
- L67 · admin.use("*")
- L74 · admin.get("/setup")
- L82 · admin.post("/setup")
- L226 · admin.get("/")
- L251 · admin.get("/invoices/new")
- L263 · admin.post("/invoices/new")
- L330 · admin.get("/invoices/:id")
- L366 · admin.get("/invoices/:id/edit")
- L388 · admin.post("/invoices/:id/edit")
- L444 · admin.post("/invoices/:id/status")
- L534 · admin.post("/invoices/:id/duplicate")
- L566 · admin.post("/invoices/:id/payments/:pid/undo")
- L578 · admin.post("/invoices/:id/payments/:pid/note")
- L594 · admin.get("/clients")
- L599 · admin.post("/clients")
- L613 · admin.get("/clients/new")
- L615 · admin.get("/clients/:id")
- L625 · admin.post("/clients/:id")
- L663 · admin.get("/export/invoices.csv")
- L685 · admin.get("/export/payments.csv")
- L720 · admin.post("/invoices/:id/events/:eventId/delete")
- L730 · admin.get("/payments")
- L751 · admin.get("/reports")
- L782 · admin.post("/settings/appearance")
- L789 · admin.get("/settings")
- L844 · admin.post("/settings")
- L895 · admin.post("/settings/email")
- L921 · admin.post("/settings/test-email")
- L931 · admin.post("/settings/providers")
- L130 · validLocaleTag calls (conditional paths may differ): test, v.trim
- L140 · arr calls (conditional paths may differ): Array.isArray
- L150 · parseItemDrafts calls (conditional paths may differ): arr, Math.max, trim, problems.push, parseAmountToCents, parseFloat, Number.isFinite, items.push
- L192 · invoiceHeaderProblems calls (conditional paths may differ): Number, str, Number.isInteger, getClient, problems.push, test, toUpperCase, trim, isSupportedCurrency
- L219 · str calls (conditional paths may differ): Array.isArray
- L648 · csvField calls (conditional paths may differ): String, test, s.replace
- L653 · csvResponse calls (conditional paths may differ): join, rows.map, r.map
- L777 · themeCookie calls (conditional paths may differ): getCookie
Environment references: c.env.DB · c.env.EMAIL · c.env.APP_BASE_URL · c.env.ASSETS · c.env.SETTINGS_MASTER_KEY · c.env.STRIPE_SECRET_KEY · c.env.STRIPE_WEBHOOK_SECRET · c.env.PAYPAL_CLIENT_ID · c.env.PAYPAL_CLIENT_SECRET · c.env.PAYPAL_WEBHOOK_ID · c.env.RESEND_API_KEY · c.env.PAYPAL_API_BASE
- L28 · pay.use("*")
- L55 · pay.get("/:token")
- L95 · pay.get("/:token/print")
- L112 · pay.get("/:token/pdf")
- L126 · pay.post("/:token/stripe")
- L145 · pay.post("/:token/paypal")
- L166 · pay.get("/:token/paypal/return")
- L45 · classifyView calls (conditional paths may differ): c.req.header, cookie.includes, BOT_UA.test, DATACENTER_ASN.test, join, filter
Environment references: c.env.DB · c.env.APP_BASE_URL · c.env.ASSETS
Environment references: c.env.DB
- L104 · isOverdue calls (conditional paths may differ): slice, toISOString
- L113 · getSettings calls (conditional paths may differ): first, db.prepare
- L119 · updateSettings calls (conditional paths may differ): run, bind, db.prepare
- L168 · updateProviderSettings calls (conditional paths may differ): run, bind, db.prepare
- L203 · updateEmailSettings calls (conditional paths may differ): run, bind, db.prepare
- L215 · updateLastSeenOrigin calls (conditional paths may differ): run, bind, db.prepare
- L222 · listOverdueForReminders calls (conditional paths may differ): all, bind, db.prepare
- L238 · setResendApiKey calls (conditional paths may differ): run, bind, db.prepare
- L253 · setSecretSetting calls (conditional paths may differ): run, bind, db.prepare
- L258 · setNextInvoiceNumber calls (conditional paths may differ): run, bind, db.prepare
- L262 · completeSetup calls (conditional paths may differ): run, db.prepare
- L266 · formatInvoiceNumber calls (conditional paths may differ): padStart, String
- L271 · expandInvoicePrefix calls (conditional paths may differ): split, todayInTz, replaceAll, prefix.replaceAll, yyyy.slice
- L280 · hasDateTokens calls (conditional paths may differ): test
- L289 · nextNumberForDatedPrefix calls (conditional paths may differ): first, bind, db.prepare, padStart, String
- L306 · claimInvoiceNumber calls (conditional paths may differ): getSettings, hasDateTokens, nextNumberForDatedPrefix, expandInvoicePrefix, first, db.prepare, formatInvoiceNumber
- L322 · suggestedInvoiceNumber calls (conditional paths may differ): hasDateTokens, nextNumberForDatedPrefix, expandInvoicePrefix, formatInvoiceNumber
- L329 · invoiceNumberExists calls (conditional paths may differ): first, bind, db.prepare
- L336 · listClients calls (conditional paths may differ): all, db.prepare
- L343 · getClient calls (conditional paths may differ): first, bind, db.prepare
- L347 · createClient calls (conditional paths may differ): run, bind, db.prepare
- L359 · updateClient calls (conditional paths may differ): run, bind, db.prepare
- L372 · listInvoices calls (conditional paths may differ): all, db.prepare
- L384 · getInvoice calls (conditional paths may differ): first, bind, db.prepare
- L394 · getInvoiceByToken calls (conditional paths may differ): first, bind, db.prepare
- L404 · getInvoiceItems calls (conditional paths may differ): all, bind, db.prepare
- L413 · getPayments calls (conditional paths may differ): all, bind, db.prepare
- L427 · awaitingPaymentReview calls (conditional paths may differ): payments.some
- L451 · createInvoice calls (conditional paths may differ): getSettings, claimInvoiceNumber, computeTotals, db.batch, bind, db.prepare, newPublicToken, draft.items.map, itemAmountCents
- L493 · updateInvoice calls (conditional paths may differ): getInvoice, computeTotals, db.batch, bind, db.prepare, draft.items.map, itemAmountCents
- L539 · markInvoiceSent calls (conditional paths may differ): run, bind, db.prepare
- L551 · markInvoiceUnsent calls (conditional paths may differ): run, bind, db.prepare
- L561 · setInvoiceStatus calls (conditional paths may differ): run, bind, db.prepare
- L574 · deleteInvoice calls (conditional paths may differ): db.batch, bind, db.prepare
- L581 · setPaypalOrderId calls (conditional paths may differ): run, bind, db.prepare
- L585 · getInvoiceByPaypalOrderId calls (conditional paths may differ): first, bind, db.prepare
- L610 · logInvoiceEvent calls (conditional paths may differ): run, bind, db.prepare
- L626 · recordInvoiceView calls (conditional paths may differ): first, bind, db.prepare, join, filter, logInvoiceEvent
- L641 · getInvoiceEvents calls (conditional paths may differ): all, bind, db.prepare
- L665 · buildTimeline calls (conditional paths may differ): events.some, entries.push, invoice.sent_at.slice, p.created_at.slice, at.slice, formatAmount, reverse, entries.sort
- L25 · processEmailOutbox calls (conditional paths may differ): listDueOutbox, getSettings, replace, trim, console.warn, deliver, console.error, markOutboxFailed, String, backoffMinutes
- L55 · deliver calls (conditional paths may differ): JSON.parse, sendPaymentReceipt, sendPaidNotice, markOutboxSent, Promise.all, getInvoice, getSettings, cancelOutboxRow, sendReminderEmail, markReminderSent
Environment references: env.DB · env.APP_BASE_URL
- L11 · backoffMinutes calls (conditional paths may differ): Math.min, Math.max
- L47 · loadFontBytes calls (conditional paths may differ): Promise.resolve, Promise.all, map, Object.keys, assets.fetch, res.arrayBuffer, Object.fromEntries, console.error
- L67 · embedFonts calls (conditional paths may differ): loadFontBytes, doc.embedFont, doc.registerFontkit
- L86 · generateInvoicePdf calls (conditional paths may differ): resolveLocale, getStrings, s.replace, deSpace, formatCentsTag, formatDateTag, hexToRgb01, rgb, v.toLocaleUpperCase, join, upper, map, t.footerThanks, date, invoice.paid_at.slice, money, items.map, every, ch.charCodeAt, CP1252_EXTRAS.has
- L361 · tryEmbedLogo calls (conditional paths may differ): doc.embedPng, doc.embedJpg, fetch, res.arrayBuffer, res.headers.get, type.includes
- L381 · wrapText calls (conditional paths may differ): str.split, filter, para.split, font.widthOfTextAtSize, sanitize, out.push, map, truncate
- L406 · fontCharSet calls (conditional paths may differ): charSets.has, charSets.set, font.getCharacterSet, charSets.get
- L423 · sanitize calls (conditional paths may differ): fontCharSet, join, map, set.has, ch.codePointAt, ch.charCodeAt, CP1252_EXTRAS.has
- L432 · truncate calls (conditional paths may differ): sanitize, font.widthOfTextAtSize, s.slice
- L26 · deliver calls (conditional paths may differ): settings.email_from.trim, effectiveProviderEnv, fetch, JSON.stringify, m.attachments.map, toBase64, res.text, env.EMAIL.send
- L84 · toBase64 calls (conditional paths may differ): String.fromCharCode, bytes.subarray, btoa
- L97 · sendInvoiceEmail calls (conditional paths may differ): resolveLocale, getStrings, safeAccent, accentForeground, formatCentsTag, formatDateTag, deliver, t.emailInvoiceSubject, join, t.greeting, t.emailInvoiceBody, escapeHtml
- L164 · sendReminderEmail calls (conditional paths may differ): resolveLocale, getStrings, safeAccent, accentForeground, formatCentsTag, formatDateTag, deliver, t.reminderSubject, join, t.greeting, t.reminderBody, escapeHtml
- L228 · sendPaymentReceipt calls (conditional paths may differ): Promise.all, getInvoice, getSettings, resolveLocale, getStrings, safeAccent, formatCentsTag, deliver, t.receiptSubject, join, t.greeting, t.receiptBody, escapeHtml, t.paid.toLocaleLowerCase, logInvoiceEvent
- L280 · sendPaidNotice calls (conditional paths may differ): Promise.all, getInvoice, getSettings, safeAccent, formatCents, deliver, join, escapeHtml
- L320 · sendTestEmail calls (conditional paths may differ): getSettings, todayInTz, computeTotals, addDaysISO, generateInvoicePdf, getLogo, sendInvoiceEmail
- L379 · sendErrorAlert calls (conditional paths may differ): getSettings, String, deliver, message.slice, escapeHtml, console.error
- L401 · escapeHtml calls (conditional paths may differ): replace, s.replace
Environment references: env.EMAIL · env.APP_BASE_URL · env.ASSETS
- L13 · authMode calls (conditional paths may differ): team.endsWith, test
- L32 · isLocalRequest calls (conditional paths may differ): req.headers.get
- L44 · sessionKey calls (conditional paths may differ): crypto.subtle.digest, enc.encode, crypto.subtle.importKey
- L49 · hex calls (conditional paths may differ): join, map, padStart, b.toString
- L54 · signSession calls (conditional paths may differ): sessionKey, crypto.subtle.sign, enc.encode, String, hex
- L60 · verifySession calls (conditional paths may differ): Date.now, token.indexOf, Number, token.slice, Number.isFinite, signSession, timingSafeEqual
- L75 · timingSafeEqual calls (conditional paths may differ): crypto.subtle.generateKey, Promise.all, crypto.subtle.sign, enc.encode, hex
Environment references: env.ACCESS_TEAM_DOMAIN · env.ACCESS_AUD · env.ADMIN_PASSWORD
- L7 · resolveBaseUrl calls (conditional paths may differ): replace, trim, isLocalHost
- L10 · isSupportedCurrency calls (conditional paths may differ): test, ZERO_DECIMAL.has
- L20 · formatCents calls (conditional paths may differ): format
- L25 · parseAmountToCents calls (conditional paths may differ): input.replace, test, Math.round, parseFloat, Number.isFinite
- L34 · itemAmountCents calls (conditional paths may differ): Math.round
- L38 · computeTotals calls (conditional paths may differ): items.reduce, itemAmountCents, Math.round
- L45 · formatTaxRate calls (conditional paths may differ): replace, toFixed
- L50 · currencyOptions calls (conditional paths may differ): map, codes.filter, names.of
- L19 · secretConfigured calls (conditional paths may differ): trim, PLACEHOLDER_VALUES.has
- L32 · configWarnings calls (conditional paths may differ): effectiveProviderEnv, warnings.push, storedSecretsHealth, push, validMasterKey, trim, settings.email_from.trim, authMode
Environment references: env.SETTINGS_MASTER_KEY · env.EMAIL
- L6 · safeAccent calls (conditional paths may differ): trim, test
- L12 · hexToRgb01 calls (conditional paths may differ): slice, safeAccent, join, map, parseInt
- L20 · luminance calls (conditional paths may differ): hexToRgb01, lin
- L27 · contrastRatio calls (conditional paths may differ): sort, luminance
- L38 · accentUsable calls (conditional paths may differ): trim, test, contrastRatio
- L45 · accentForeground calls (conditional paths may differ): contrastRatio, safeAccent
- L16 · pick calls (conditional paths may differ): secretConfigured, unbox, trim
- L66 · effectiveProviderEnv calls (conditional paths may differ): Promise.all, pick, trim
- L96 · providerAvailability calls (conditional paths may differ): effectiveProviderEnv, STRIPE_CURRENCIES.has, PAYPAL_CURRENCIES.has
- L115 · keySource calls (conditional paths may differ): secretConfigured, trim, isBoxed
- L125 · storedSecretsHealth calls (conditional paths may differ): trim, isBoxed, unbox
- L141 · encryptStoredSecrets calls (conditional paths may differ): validMasterKey, trim, isBoxed, setSecretSetting, box
Environment references: env.SETTINGS_MASTER_KEY · env.STRIPE_SECRET_KEY · env.STRIPE_WEBHOOK_SECRET · env.PAYPAL_CLIENT_SECRET · env.RESEND_API_KEY · env.PAYPAL_CLIENT_ID · env.PAYPAL_WEBHOOK_ID · env.PAYPAL_API_BASE
- L22 · validMasterKey calls (conditional paths may differ): trim, secretConfigured
- L27 · isBoxed calls (conditional paths may differ): v.startsWith
- L31 · aesKey calls (conditional paths may differ): crypto.subtle.digest, encode, crypto.subtle.importKey
- L36 · box calls (conditional paths may differ): validMasterKey, crypto.getRandomValues, crypto.subtle.encrypt, aesKey, encode, packed.set, btoa, String.fromCharCode
- L53 · unbox calls (conditional paths may differ): isBoxed, validMasterKey, Uint8Array.from, atob, stored.slice, c.charCodeAt, crypto.subtle.decrypt, packed.slice, aesKey, decode
- L71 · sealIfKeyed calls (conditional paths may differ): validMasterKey, box
Build and deployment pipeline · 1 GitHub Actions workflows
Repository CI declarations, separate from runtime request processing. Job dependencies and conditions are shown as written; long commands are shortened with an ellipsis; a workflow file does not prove a recent successful run.
Triggers: push, pull_request
test · no job dependencies declared
- actions/checkout@v4
actions/checkout@v4 - actions/setup-node@v4
actions/setup-node@v4 - Shell command
npm ci - Shell command
npx wrangler types - Shell command
npx tsc --noEmit - Shell command
npm test
deploy: wrangler d1 migrations apply DB --remote && wrangler deploy && node scripts/ensure-master-key.mjsdeploy:test: wrangler d1 migrations apply DB --remote --env test && wrangler deploy --env test && node scripts/ensure-master-key.mjs --env test
Repository README
View original on GitHub ↗Full upstream document by @ddyy · README.md · snapshot 99563a5
Minvoice
Minvoice (minimal invoice) is single-business invoicing that runs entirely on Cloudflare — Workers, D1, Access, and Email Sending. Create invoices, email them with a PDF attached, get paid by card (Stripe Checkout) or PayPal, and keep clean books. No servers, no framework runtime, no third-party requests on any page. Designed for one business (you), not as a SaaS.
One click gets you: the repo cloned to your GitHub, a D1 database provisioned and migrated, a
password-protected admin (set the ADMIN_PASSWORD secret when prompted), and the setup wizard on
first visit — a working invoicing app on workers.dev. Connect payments, email, and Cloudflare
Access at your own pace afterward (see Setup below).
Everything beyond the Worker itself is optional:
- Custom domain — optional. Everything works on the free workers.dev domain; the only feature that needs a custom domain is Cloudflare Access for admin auth, and the built-in password login covers that until you add one.
- Payments — optional, and Stripe and PayPal are each optional too: connect either, both, or neither. With none connected, clients still get the invoice page, print view, and PDF — and you record checks or bank transfers manually, which keeps the books just the same.
- Email — optional. Send through Cloudflare Email Sending or Resend, or turn email off in Settings and share pay links directly.

![]() |
![]() |
![]() |
![]() |
Features
- Invoices — line items with multi-line descriptions, subjects, per-client payment terms and
default rates, custom or auto numbering (including dated prefixes like
{YYYY}{MM}{DD}with a per-day counter), duplicate-invoice, and full-status delete with guardrails - Get paid — unguessable pay links (20-char, 100-bit tokens) with hosted Stripe Checkout and PayPal buttons; webhooks are the source of truth, fully idempotent, and record each payment atomically (event, payment, and status change commit or roll back together); receipts and "you got paid" notifications are enqueued in that same transaction and delivered with retries (at-least-once — a rare duplicate beats a silent never)
- Documents — a print-optimized invoice view (one-click print,
PAID/VOIDstamps) and a generated PDF (pdf-lib) attached to invoice emails - History — every invoice keeps an activity timeline: edits, sends, payments (with undo), and pay-link views with city-level geolocation (bot- and scanner-filtered, admin views excluded)
- Email — Cloudflare Email Sending or Resend, selectable in Settings
- Languages — everything clients see (emails, pay page, PDF) in English, Spanish, German, or
French, with per-client overrides and regional date/number formatting (e.g.
de-AT); the admin stays English - Payment reminders — opt-in daily cron emails overdue clients on an editable schedule (default 1, 7, and 14 days past due, up to 10 reminders, burst-protected); every reminder is logged to the invoice history and delivered through a durable outbox that retries failures
- Admin — dashboard with status tabs and client filter, payments list, monthly reports (filterable by client), CSV export, first-launch setup wizard, configuration warnings for missing secrets, encrypted-at-rest storage for API keys entered in-app, and logo upload (PNG/JPEG stored in your database — no external image host needed; a logo URL still works)
- No fees — fits Cloudflare's free tier (Resend's free tier covers email there), so there's no monthly cost to run. No subscription and no added payment fees on any plan: you pay only Stripe's or PayPal's own rate (2.9% + 30¢ on card), straight to your own account. Nothing sits in between taking a cut
- Your data stays yours — books live in a D1 database in your Cloudflare account; no analytics, no tracking, no third-party requests on any page. Payment providers see only the charge itself, and clients' invoice pages are unindexed, unguessable URLs
- Ledger design — a warm, print-inspired design system (Fraunces + Instrument Sans, self-hosted); Lighthouse 100 performance and accessibility on the payment page, WCAG AA contrast
Architecture
src/
index.tsx Hono app: routes, Access middleware on /admin/*, /health, styled 404
middleware/access.ts Cloudflare Access JWT verification (defense in depth, fails closed)
routes/ admin.tsx (dashboard/CRUD/reports/CSV), pay.tsx (public), webhooks.ts
services/ stripe.ts, paypal.ts, pdf.ts, email.ts
db/queries.ts typed D1 access, webhook idempotency, timeline assembly
views/ server-rendered JSX (hono/jsx) — no client framework
lib/ money (integer cents, bps tax), dates (timezone), tokens, config
migrations/ D1 schema migrations
public/ styles.css, self-hosted fonts, favicon
Invariants worth knowing before you change anything:
- Money is integer cents; tax rates are basis points; totals are computed once and stored.
- "Paid" comes from webhooks, deduplicated by
UNIQUE(provider, event_id)+UNIQUE(provider, provider_ref)+ status-guarded updates — replays are no-ops, and payment emails fire only on the actual transition. - Payments are soft-deleted (undo keeps history); storage is UTC with a business-timezone setting driving display and date logic.
/adminrequires a valid Cloudflare Access JWT verified in-Worker: if Access is misconfigured or disabled at the edge, admin fails closed with 403.
Setup
You'll need: a Cloudflare account, a domain on Cloudflare, a Stripe account, and optionally a PayPal Business account.
Plans: the Workers Free plan is enough — D1's free tier (5 GB, 5M reads/day) vastly exceeds single-business invoicing volume — if you use Resend as the email provider (free tier: 3,000 emails/month). The built-in Cloudflare Email Sending path requires the Workers Paid plan to send to arbitrary recipients (your clients).
Prefer the CLI over the deploy button? npm create minvoice scaffolds a fresh copy — repo
downloaded, dependencies installed, fresh git history — and prints these same steps.
1. Create the database and config
The checked-in wrangler.jsonc is a zero-config starter (workers.dev, request-derived URLs).
wrangler.jsonc.example shows the full production shape — custom domain, Access, Cloudflare
email binding, staging — to graft in as you upgrade.
npm install
npx wrangler d1 create minvoice # paste the printed id into wrangler.jsonc (database_id)
npx wrangler types
npm run db:migrate:remote # deploy scripts also run this automatically
2. Local development
cp .dev.vars.example .dev.vars # set ADMIN_PASSWORD (any value locally); test-mode Stripe key, sandbox PayPal creds
npx wrangler d1 migrations apply minvoice --local
npm run dev # http://localhost:8787 — first visit runs the setup wizard
Local Stripe webhooks: stripe listen --forward-to localhost:8787/webhooks/stripe and put the
printed whsec_… in .dev.vars. PayPal needs no local webhook — capture-on-return keeps the
books correct. Note .dev.vars changes require a dev-server restart.
3. Admin auth: password to start, Cloudflare Access when ready
Admin auth picks the strongest configured mode automatically:
- Quick start — password:
npx wrangler secret put ADMIN_PASSWORDand you can sign in at/adminimmediately (signed 7-day session cookie, timing-safe checks, throttled failures). The dashboard will nudge you toward Access. - Recommended — Cloudflare Access: phishing-resistant SSO in front of
/admin, verified in-Worker on every request. Setting it up disables the password login automatically — Access always wins. Walkthrough below. - Neither configured:
/adminfails closed with setup instructions.
Setting up Cloudflare Access, step by step
Prerequisite: a custom domain. Access cannot protect *.workers.dev hostnames, so first put
your domain on Cloudflare and attach it to the Worker — add a routes block to wrangler.jsonc
(see wrangler.jsonc.example) and deploy once so the hostname is live.
- Open Zero Trust. First visit ever? You'll be asked to
create an organization and pick a team name — choose carefully, because
<team-name>.cloudflareaccess.combecomes yourACCESS_TEAM_DOMAIN. - Go to Access → Applications → Add an application → Self-hosted.
- Name it (e.g. "Minvoice admin"). Under the public hostname, set domain to your Worker's
hostname (e.g.
invoice.example.com) and path toadmin. - Add an Allow policy with Include → Emails → your email address.
- Login method: new orgs default to the Cloudflare identity provider (sign in with your Cloudflare account — solid, MFA-backed). Heads-up: its consent screen briefly shows "Unknown app wants to access your account"; that's cosmetic and Cloudflare-side. Prefer emailed codes instead? Add One-Time PIN under Integrations → Identity providers and select it in the application's login methods.
- Save, then open the application's overview and copy the Application Audience (AUD) Tag.
- Put both values in
wrangler.jsonc—ACCESS_TEAM_DOMAIN(from step 1) andACCESS_AUD(from step 6) — andnpm run deploy. - Verify: in a private window,
https://yourhost/adminshould redirect to your*.cloudflareaccess.comlogin; after signing in you land in the admin. The dashboard's "password-based auth" warning disappears — Access has retired the password.
Notes:
- Fail-closed by design: if the AUD or team domain is wrong — or Access is later disabled at
the edge —
/adminreturns 403 rather than falling open. If you get a 403 after the Access login succeeds, one of the two values inwrangler.jsoncdoesn't match the application. - Staging shares the app: add the staging hostname as an additional domain on the same application (same AUD for both) instead of creating a second app.
- Your own pay-page visits stop being logged to invoice History once Access is active — the view tracking recognizes the Access session cookie.
4. Email
Two providers, selectable in Settings:
- Resend (default for the starter config): verify your domain at Resend and set the
RESEND_API_KEYsecret. Free tier covers invoicing volume, works on the Workers Free plan. - Cloudflare Email Sending: onboard your domain (dashboard → Email Service) and add the
send_emailbinding fromwrangler.jsonc.example(requires Workers Paid to send to clients).
Either way, set the From address in Settings after first launch — sends fail loudly until it's set, and the dashboard warns about any provider misconfiguration.
5. Secrets and deploy
Only ADMIN_PASSWORD is needed up front (the one-click flow prompts for exactly that). Payment
and Resend keys can be added either in-app (Settings → Payments & keys — zero CLI) or as
Wrangler secrets, which are excluded from database exports and always take precedence — the
hardened choice. Each payment method also has an on/off toggle in Settings. Pay buttons stay
hidden and the dashboard warns until a method is configured.
Keys entered in-app are envelope-encrypted (AES-256-GCM) before they're stored in D1, using a
SETTINGS_MASTER_KEY secret that npm run deploy generates automatically on first deploy. A
database export alone can't reveal them — reading a key requires the Worker secret too. Existing
deployments migrate on their own: any plaintext keys are re-encrypted the next time the Settings
page loads after the master key exists (Settings shows an alert until then). Don't rotate or
delete SETTINGS_MASTER_KEY — stored keys become undecryptable and must be re-entered.
npx wrangler secret put ADMIN_PASSWORD # unless you configured Access in step 3
npx wrangler secret put STRIPE_SECRET_KEY # restricted key: Checkout Sessions write
npx wrangler secret put STRIPE_WEBHOOK_SECRET # after registering the webhook (below)
npx wrangler secret put PAYPAL_CLIENT_ID
npx wrangler secret put PAYPAL_CLIENT_SECRET
npx wrangler secret put PAYPAL_WEBHOOK_ID
npx wrangler secret put RESEND_API_KEY # if using Resend for email
npm run deploy
Register webhooks pointing at your domain:
- Stripe →
https://yourhost/webhooks/stripe, eventscheckout.session.completedandcheckout.session.async_payment_succeeded - PayPal →
https://yourhost/webhooks/paypal, eventPAYMENT.CAPTURE.COMPLETED
Payment buttons appear on pay pages only for providers whose credentials are configured — an invoice-only deployment (no payment secrets yet) still produces shareable, printable invoices.
Visit /admin, complete the setup wizard, set the email From address in Settings, and send
yourself a test invoice. The dashboard warns about any missing configuration.
Staging (optional, recommended)
The env.test block in wrangler.jsonc.example defines a full staging duplicate — its own D1,
hostname, and sandbox provider credentials — so fake money can never reach your real books.
npm run deploy:test / npm run db:migrate:test.
Uptime monitoring (optional)
GET /health returns 200 only when the Worker and D1 both answer — point any external monitor
at it. If you firewall datacenter ASNs, exempt this path.
Languages
Set "Customer language & region" in Settings (built-in: en, es, de, fr), or override it
per client for businesses invoicing across languages. The value is a BCP-47 tag: the language
picks the wording, the full tag drives date and currency formatting — so de-AT gets German
text with Austrian formatting (18.07.2026, 1.234,56 €). Everything the CLIENT sees is
localized: invoice/reminder/receipt emails, the public pay page, the print view, and the PDF.
The admin UI stays English.
Adding a language is one file: copy src/lib/strings/en.ts, translate the values, and register
it in src/lib/strings/index.ts — the compiler enforces completeness. The same file is the
supported way to adjust wording you disagree with (say, MwSt. instead of USt.).
PDFs embed Noto Sans/Serif (SIL OFL, see public/fonts/pdf/OFL.txt) only when the document
actually needs them — the built-in locales (including umlauts, accents, and €) render with the
PDF standard fonts at ~3.5ms CPU, comfortably inside the Workers Free plan's 10ms limit. Text
in extended Latin (Polish, Czech, Turkish, Vietnamese), Greek, or Cyrillic triggers Noto
embedding with per-document glyph subsetting (~130ms CPU), which needs the Workers Paid plan's
CPU allowance. Right-to-left scripts (Arabic, Hebrew) and Indic scripts are out
of scope: correct rendering needs a text-shaping engine the PDF layer doesn't have. CJK would
need you to ship your own font files in a fork.
Spanish and French strings were drafted by the maintainers — native-speaker review PRs are welcome and are deliberately easy first contributions.
Operations
npm run deploy— applies pending D1 migrations (by binding name), then deploysnpm test— unit tests over the money/date/timeline/config logicnpm run db:wipe:prod— clear transactional data, keep settings (CLI-only by design)npm run db:reset:prod— factory reset; re-arms the setup wizard- D1 Time Travel provides 30-day point-in-time restore; take periodic
wrangler d1 exportsnapshots for older history
License
MIT. Bundled fonts (Fraunces, Instrument Sans) are under the SIL Open Font License — see public/fonts/LICENSE.
Frequently asked about Minvoice
What is Minvoice?+
Minvoice is a self-hosted FreshBooks/Invoice Ninja alternative built on the Cloudflare developer platform. Self-hosted invoices and PDF documents with optional payment providers
What does Minvoice replace?+
Minvoice is listed as an alternative to FreshBooks, Invoice Ninja. Compare the features and tradeoffs before migrating.
What Cloudflare primitives does Minvoice use?+
Minvoice is built on D1, Workers.
How much does Minvoice cost to run?+
The minimal manual-invoice path can fit Workers/D1 free limits. The README distinguishes inexpensive standard-font PDFs from multilingual-font generation that requires Workers Paid; use the former and measure 10ms CPU fit. Stripe/PayPal fees and email sending are optional separate costs. Use the current starter/admin-password setup and your own D1 database. Some later README setup sections still assume older mandatory payment/domain settings. Do not enable multilingual PDF font embedding under a Free-plan claim. Optional arbitrary-recipient Cloudflare email requires Workers Paid. Source and configuration review establishes a deployment path and conditional costs; this candidate was not executed or load-tested. Check current Cloudflare pricing before deploying.
Is Minvoice open source?+
The upstream repository declares the MIT license. Read its terms at https://raw.githubusercontent.com/ddyy/minvoice/99563a5a4d8e90481e76d2440c719c909a67f243/LICENSE. Source code and contributor credit are available at https://github.com/ddyy/minvoice.







Discussion · 0
sign in to comment →