Cloudsteading
Dashboard

Minvoice

Self-hosted invoices and PDF documents with optional payment providers

Minvoice is a self-hosted FreshBooks/Invoice Ninja alternative built on Cloudflare (D1, Workers). Free tier eligible within limits. Inspect the source and license in the linked repository.

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

@ddyy

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.
Check current pricing ↗
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.

external SaaS target
varies
→ D1 + Workers
external SaaS target
varies
→ D1 + Workers

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 ↗
Public interface
Configured entry points1
minvoice
wrangler.jsonc
↓
App
minvoice
entry
Cloudflare Workers
Entrypoint: src/index.tsxConfigured cron (UTC): 0 15 * * *
↓

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
wrangler.jsonc ↗

Cloudflare Workers · compatibility 2026-06-01

minvoice · default

Entrypoint: src/index.tsx

Static assets: ./public

Cron triggers (UTC): 0 15 * * *

  • DB → D1
  • ASSETS → Static assets
test/wrangler.test.jsonc ↗

Cloudflare Workers · example/template, excluded from overview · compatibility 2026-06-01

minvoice-test · default

Entrypoint: ../src/index.tsx

Static assets: ../public

  • DB → D1
  • ASSETS → 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.

src/index.tsx ↗
  • 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

src/services/reminders.ts ↗
  • 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

src/middleware/access.ts ↗

    Environment references: c.env.ACCESS_TEAM_DOMAIN · c.env.ACCESS_AUD · c.env.ADMIN_PASSWORD

    src/routes/admin.tsx ↗
    • 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

    src/routes/pay.tsx ↗
    • 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

    src/routes/webhooks.ts ↗
    • L15 · webhooks.post("/stripe")
    • L59 · webhooks.post("/paypal")

    Environment references: c.env.DB

    src/db/queries.ts ↗
    • 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
    src/services/outbox.ts ↗
    • 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

    src/lib/outbox.ts ↗
    • L11 · backoffMinutes calls (conditional paths may differ): Math.min, Math.max
    src/services/pdf.ts ↗
    • 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
    src/services/email.ts ↗
    • 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

    src/lib/admin-auth.ts ↗
    • 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

    src/lib/base-url.ts ↗
    • L7 · resolveBaseUrl calls (conditional paths may differ): replace, trim, isLocalHost
    src/lib/dates.ts ↗
    • L2 · todayInTz calls (conditional paths may differ): format
    • L16 · addDaysISO calls (conditional paths may differ): d.setUTCDate, d.getUTCDate, slice, d.toISOString
    • L26 · formatTimestamp calls (conditional paths may differ): at.replace, Number.isNaN, d.getTime, format
    src/lib/reminders.ts ↗
    • L15 · parseSchedule calls (conditional paths may differ): slice, sort, filter, map, raw.split, Number.isInteger
    • L30 · daysBetween calls (conditional paths may differ): Math.round, Date.parse
    • L34 · reminderDue calls (conditional paths may differ): map, schedule.slice, Math.min
    src/lib/money.ts ↗
    • 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
    src/lib/config.ts ↗
    • 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

    src/lib/color.ts ↗
    • 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
    src/lib/providers.ts ↗
    • 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

    src/lib/secretbox.ts ↗
    • 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.

    CI · .github/workflows/ci.yml ↗

    Triggers: push, pull_request

    test · no job dependencies declared

    1. actions/checkout@v4actions/checkout@v4
    2. actions/setup-node@v4actions/setup-node@v4
    3. Shell commandnpm ci
    4. Shell commandnpx wrangler types
    5. Shell commandnpx tsc --noEmit
    6. Shell commandnpm test
    package.json ↗
    • deploy: wrangler d1 migrations apply DB --remote && wrangler deploy && node scripts/ensure-master-key.mjs
    • deploy:test: wrangler d1 migrations apply DB --remote --env test && wrangler deploy --env test && node scripts/ensure-master-key.mjs --env test

    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.

    Deploy to Cloudflare

    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.

    Minvoice walkthrough

    Dashboard Public pay page
    Payments and history Print view

    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/VOID stamps) 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.
    • /admin requires 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_PASSWORD and you can sign in at /admin immediately (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: /admin fails 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.

    1. 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.com becomes your ACCESS_TEAM_DOMAIN.
    2. Go to Access → Applications → Add an application → Self-hosted.
    3. Name it (e.g. "Minvoice admin"). Under the public hostname, set domain to your Worker's hostname (e.g. invoice.example.com) and path to admin.
    4. Add an Allow policy with Include → Emails → your email address.
    5. 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.
    6. Save, then open the application's overview and copy the Application Audience (AUD) Tag.
    7. Put both values in wrangler.jsonc — ACCESS_TEAM_DOMAIN (from step 1) and ACCESS_AUD (from step 6) — and npm run deploy.
    8. Verify: in a private window, https://yourhost/admin should redirect to your *.cloudflareaccess.com login; 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 — /admin returns 403 rather than falling open. If you get a 403 after the Access login succeeds, one of the two values in wrangler.jsonc doesn'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_KEY secret. Free tier covers invoicing volume, works on the Workers Free plan.
    • Cloudflare Email Sending: onboard your domain (dashboard → Email Service) and add the send_email binding from wrangler.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, events checkout.session.completed and checkout.session.async_payment_succeeded
    • PayPal → https://yourhost/webhooks/paypal, event PAYMENT.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.

    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 deploys
    • npm test — unit tests over the money/date/timeline/config logic
    • npm 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 export snapshots 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 →
    No comments yet — be the first.