Cloudsteading
Product image still needed. This listing has source documentation, but no reviewed screenshot yet.

WeaveLedger

Self-hosted business expense records with optional AI receipt extraction

WeaveLedger is a self-hosted Expensify alternative built on Cloudflare (D1, R2, Workers, Workflows). Paid services required. 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

@WeaveHubHQ

See the upstream repository for the original creator and contributors.

Maintain this project? Maintainer verification →

Cloudflare hosting

Paid services required

The committed cpu_ms=30000 requires Workers Paid from $5 USD/account/month plus DB/storage/workflow overages. Claude/OpenAI API charges are required for AI receipt extraction and billed separately; manually entered expense records do not require that AI processing. Hosted subscriptions are disabled in the self-host config.

Hosting requirements
  • Set JWT_SECRET and provision your own database/bucket/workflow resources; no hosted subscription is required with SUBSCRIPTION_ENFORCEMENT=none.
  • Optional email-receipt forwarding, external income integrations and AI processing have independent credentials/costs. The README free-hosting wording does not override the committed Paid CPU setting.
  • 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: 8ec86d6. Hosting eligibility reflects the deployment documentation and listed assumptions.

  • expensify ↗

    Smart expense tracking for freelancers and small business owners. Self-hosted on Cloudflare Workers -- you own your data.

  • workers ↗

    name = "weaveledger-api" main = "src/index.ts" compatibility_date = "2025-01-01" # Uncomment and set your custom domain after deployment: # routes = [ # { pattern = "ledger.yourdomain.com", custom_domain = true } # ] # D1 Database — create with: npx wrangler d1 create weaveledger-db [[d1_databases]] binding = "DB" database_name = "weaveledger-db" database_id = "" # Set aft

  • d1 ↗

    = "ledger.yourdomain.com", custom_domain = true } # ] # D1 Database — create with: npx wrangler d1 create weaveledger-db [[d1_databases]] binding = "DB" database_name = "weaveledger-db" database_id = "" # Set after running: npx wrangler d1 create weaveledger-db migrations_dir = "migrations" # R2 Bucket — create with: npx wrangler r2 bucket create weaveledger-receipts [[r2_buckets]] binding = "RECEIPTS_BUCKET" bucket_name = "weaveledger-receipts" # Workflows [[workflows]] name = "receipt-process

  • r2 ↗

    weaveledger-db migrations_dir = "migrations" # R2 Bucket — create with: npx wrangler r2 bucket create weaveledger-receipts [[r2_buckets]] binding = "RECEIPTS_BUCKET" bucket_name = "weaveledger-receipts" # Workflows [[workflows]] name = "receipt-processor" binding = "RECEIPT_WORKFLOW" class_name = "ReceiptProcessorWorkflow" # Inbound email routing (optional) # Configure in Cloudflare Dashboard > your domain > Email > Email Routing # to route receipts@yourdomain.com > this Worker # [[email_routing]] # enabled

  • workflows ↗

    ipts [[r2_buckets]] binding = "RECEIPTS_BUCKET" bucket_name = "weaveledger-receipts" # Workflows [[workflows]] name = "receipt-processor" binding = "RECEIPT_WORKFLOW" class_name = "ReceiptProcessorWorkflow" # Inbound email routing (optional) # Configure in Cloudflare Dashboard > your domain > Email > Email Routing # to route receipts@yourdomain.com > this Worker # [[email_routing]] # enabled = true # Outbound email (optional) # [[send_email]] # name = "SEND_EMAIL" # Subscription enforcement — the iOS app re

  • paid ↗

    name = "weaveledger-api" main = "src/index.ts" compatibility_date = "2025-01-01" # Uncomment and set your custom domain after deployment: # routes = [ # { pattern = "ledger.yourdomain.com", custom_domain = true } # ] # D1 Database — create with: npx wrangler d1 create weaveledger-db [[d1_databases]] binding = "DB" database_name = "weaveledger-db" database_id = "" # Set after running: npx wrangler d1 create weaveledger-db migrations_dir = "migrations" # R2 Bucket — create with: npx wrangler r2 bucket create weaveledger-receipts [[r2_buckets]] binding = "RECEIPTS_BUCKET" bucket_name = "weaveledger-receipts" # Workflows [[workflows]] name = "receipt-processor" binding = "RECEIPT_WORKFLOW" class_name = "ReceiptProcessorWorkflow" # Inbound email routing (optional) # Configure in Cloudfla

  • paid ↗

    The Workers Paid plan includes Workers, Pages Functions, Workers KV, Hyperdrive, and Durable Objects usage for a minimum charge of $5 USD per month for an account. The plan includes increased initial usage allotments, with clear charges for usage that exceeds the base plan. There are no additional charges for data transfer (egress) or throughput (bandwidth).

  • paid ↗

    | Rows read | 5 million / day | First 25 billion / month included + $0.001 / million rows |

  • paid ↗

    | Storage | 10 GB-month / month |

  • paid ↗

    | Steps | 3,000 per day | 500,000 included per month + $0.80/ additional 100,000 per month |

  • paid ↗

    | Class A Operations | 1 million requests / month |

  • paid ↗

    | Class B Operations | 10 million requests / month |

  • paid ↗

    | Rows written | 100,000 / day | First 50 million / month included + $1.00 / million rows |

  • MIT ↗

    MIT License Copyright (c) 2026 WeaveHub Technologies LLC 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

  • architecture ↗

    name = "weaveledger-api" main = "src/index.ts" compatibility_date = "2025-01-01" # Uncomment and set your custom domain after deployment: # routes = [ # { pattern = "ledger.yourdomain.com", custom_domain = true } # ] # D1 Database — create with: npx wrangler d1 create weaveledger-db [[d1_databases]] binding = "DB" database_name = "weaveledger-db" database_id = "" # Set after running: npx wrangler d1 create weaveledger-db migrations_dir = "migrations" # R2 Bucket — create with: npx wrangler r2 bucket create weaveledger-receipts [[r2_buckets]] binding = "RECEIPTS_BUCKET" bucket_name = "weaveledger-receipts" # Workflows [[workflows]] name = "receipt-processor" binding = "RECEIPT_WORKFLOW" class_name = "ReceiptProcessorWorkflow" # Inbound email routing (optional) # Configure in Cloudfla

  • architecture ↗

    name = "weaveledger-api" main = "src/index.ts" compatibility_date = "2025-01-01" # Uncomment and set your custom domain after deployment: # routes = [ # { pattern = "ledger.yourdomain.com", custom_domain = true } # ] # D1 Database — create with: npx wrangler d1 create weaveledger-db [[d1_databases]] binding = "DB" database_name = "weaveledger-db" database_id = "" # Set aft

  • architecture ↗

    = "ledger.yourdomain.com", custom_domain = true } # ] # D1 Database — create with: npx wrangler d1 create weaveledger-db [[d1_databases]] binding = "DB" database_name = "weaveledger-db" database_id = "" # Set after running: npx wrangler d1 create weaveledger-db migrations_dir = "migrations" # R2 Bucket — create with: npx wrangler r2 bucket create weaveledger-receipts [[r2_buckets]] binding = "RECEIPTS_BUCKET" bucket_name = "weaveledger-receipts" # Workflows [[workflows]] name = "receipt-process

  • architecture ↗

    weaveledger-db migrations_dir = "migrations" # R2 Bucket — create with: npx wrangler r2 bucket create weaveledger-receipts [[r2_buckets]] binding = "RECEIPTS_BUCKET" bucket_name = "weaveledger-receipts" # Workflows [[workflows]] name = "receipt-processor" binding = "RECEIPT_WORKFLOW" class_name = "ReceiptProcessorWorkflow" # Inbound email routing (optional) # Configure in Cloudflare Dashboard > your domain > Email > Email Routing # to route receipts@yourdomain.com > this Worker # [[email_routing]] # enabled

  • architecture ↗

    ipts [[r2_buckets]] binding = "RECEIPTS_BUCKET" bucket_name = "weaveledger-receipts" # Workflows [[workflows]] name = "receipt-processor" binding = "RECEIPT_WORKFLOW" class_name = "ReceiptProcessorWorkflow" # Inbound email routing (optional) # Configure in Cloudflare Dashboard > your domain > Email > Email Routing # to route receipts@yourdomain.com > this Worker # [[email_routing]] # enabled = true # Outbound email (optional) # [[send_email]] # name = "SEND_EMAIL" # Subscription enforcement — the iOS app re

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 + R2 + Workers

How it works

The shape of WeaveLedger 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
weaveledger-api
api/wrangler.toml
↓
App
weaveledger-api
entry
Cloudflare Workers
Entrypoint: src/index.tsConfigured cron (UTC): 0 6 * * *Configured cron (UTC): */10 * * * *Configured cron (UTC): 0 7 1 * *
↓

Configuration and workflow sources

Reviewed commit 8ec86d657273. Files were read as data; upstream applications and CI jobs were not executed.

Partial source coverage: 10 files outside collection bounds; 0 collection or parsing issues. Dynamic imports and generated entrypoints may need manual review.

Deployment configuration · 1 files
api/wrangler.toml ↗

Cloudflare Workers · compatibility 2025-01-01

weaveledger-api · default

Entrypoint: src/index.ts

Cron triggers (UTC): 0 6 * * * · */10 * * * * · 0 7 1 * *

  • DB → D1
  • RECEIPTS_BUCKET → R2
  • RECEIPT_WORKFLOW → Workflows · class ReceiptProcessorWorkflow

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.

api/src/index.ts ↗
  • L30 · fetch handler exported · references DISPATCH_SECRET, DB, SUBSCRIPTION_ENFORCEMENT, LICENSING_URL, JWT_SECRET · calls request.headers.get, newHeaders.set, includes, addCors, json, path.startsWith, error, request.json, Uint8Array.from, atob, c.charCodeAt, handleInboundEmail, path.match, pending.push, worker.scheduled, Date.now, Promise.all, registrationStatus, checkRateLimit, register, login, forgotPassword, resetPassword, refreshAuth, passkeyLoginOptions, passkeyLoginVerify, JSON.stringify, landingPage, termsOfServicePage, privacyPolicyPage, verifyLinkedEmailLink, url.searchParams.has, authenticateDownload, exportBook, handleGooglePlayWebhook, handleAppleNotificationWebhook, authenticate, changePassword, getProfile, createAiKey, getAiUsage, createAiCheckout, updatePreferences, mfaSetup, mfaEnable, mfaDisable, passkeyRegisterOptions, passkeyRegisterVerify, listPasskeys, renamePasskey, deletePasskey, listLinkedEmails, addLinkedEmail, resendLinkedEmailVerification, removeLinkedEmail, verifyAppSubscription, getAppSubscriptionStatus, restoreAppSubscription, requireSubscription, handler, listBooks, paid, createBook, getBook, updateBook, deleteBook, setBookStatus, shareBook, revokeShare, listInvitations, revokeInvitation, getBookSummary, listAllReceipts, listReceipts, createReceipt, uploadReceiptImage, ctx.waitUntil, getReceipt, updateReceipt, deleteReceipt, retryAllFailedReceipts, retryReceipt, getReceiptImage, getReceiptAttachment, canAccessBook, Math.floor, deriveDownloadKey, crypto.subtle.sign, encoder.encode, replace, btoa, String.fromCharCode, listIntegrations, upsertIntegration, deleteIntegration, syncIntegration, listIncomeTransactions, getIncomeSummary, getIncomeDashboard, listPayouts, markPayoutReceived, triggerReconcile, backfillUsd, backfillFees, listCronRuns, listSubscriptions, getSubscriptionSummary, getSubscriptionForecast, syncSubscriptions, getBudgetStatus, listBudgets, createBudget, updateBudget, deleteBudget, exportReport, addReportItems, removeReportItem, listReports, createReport, getReport, updateReport, deleteReport, matchStatementTransaction, unmatchStatementTransaction, ignoreStatementTransaction, createReceiptFromTransaction, uploadStatement, listStatements, getStatement, deleteStatement, listRecurringExpenses, createRecurringExpense, updateRecurringExpense, deleteRecurringExpense, getTaxCategories, getTaxSettings, updateTaxSettings, getTaxSummary, getTaxEstimates, getProfitAndLoss, console.error
  • L628 · email handler exported · calls handleInboundEmail
  • L632 · scheduled handler exported · references DB, JWT_SECRET · calls ctx.waitUntil, cleanupStuckReceipts, generateId, run, bind, env.DB.prepare, startedAt.toISOString, reconcileAllUsers, Date.now, startedAt.getTime, String, console.error, msg.slice, Promise.all, syncAllIntegrations, advanceRecurringExpenses, all, decryptValue, JSON.parse, syncAppleFinanceReports, alertOnRecentFailures, syncGooglePlayEarnings, autoMarkOverduePayouts, console.log

Environment references: env.DISPATCH_SECRET · env.DB · env.SUBSCRIPTION_ENFORCEMENT · env.LICENSING_URL · env.JWT_SECRET

api/src/middleware/auth.ts ↗
  • L9 · authenticate calls (conditional paths may differ): request.headers.get, error, authHeader.slice, verifyJWT, first, bind, env.DB.prepare
  • L33 · authenticateDownload calls (conditional paths may differ): url.searchParams.get, error, parseInt, isNaN, Math.floor, Date.now, url.pathname.match, deriveDownloadKey, Uint8Array.from, atob, replace, token.replace, c.charCodeAt, crypto.subtle.verify, encoder.encode
  • L75 · checkRateLimit calls (conditional paths may differ): request.headers.get, Date.now, first, bind, db.prepare, error, run, Math.random, console.error
  • L108 · requireSubscription calls (conditional paths may differ): first, bind, db.prepare, error, expiresAt.getTime, fetch, res.json, run, JSON.stringify
  • L160 · canAccessBook calls (conditional paths may differ): first, bind, db.prepare
  • L195 · bookEditDeniedError calls (conditional paths may differ): first, bind, db.prepare, canAccessBook, error
  • L208 · getBookRole calls (conditional paths may differ): first, bind, db.prepare

Environment references: env.JWT_SECRET · env.DB

api/src/utils/crypto.ts ↗
  • L3 · generateId calls (conditional paths may differ): crypto.getRandomValues, join, map, Array.from, padStart, b.toString
  • L10 · pbkdf2Hash calls (conditional paths may differ): crypto.subtle.importKey, encoder.encode, crypto.subtle.deriveBits, join, map, Array.from, padStart, b.toString
  • L17 · hashPassword calls (conditional paths may differ): crypto.getRandomValues, join, map, Array.from, padStart, b.toString, pbkdf2Hash
  • L25 · verifyPassword calls (conditional paths may differ): stored.startsWith, stored.split, map, saltHex.match, parseInt, pbkdf2Hash, encode, crypto.subtle.timingSafeEqual
  • L46 · createRefreshToken calls (conditional paths may differ): crypto.getRandomValues, join, map, Array.from, padStart, b.toString
  • L52 · hashRefreshToken calls (conditional paths may differ): crypto.subtle.importKey, encoder.encode, crypto.subtle.sign, join, map, Array.from, padStart, b.toString
  • L59 · createJWT calls (conditional paths may differ): Math.floor, Date.now, replace, btoa, JSON.stringify, crypto.subtle.importKey, encoder.encode, crypto.subtle.sign, String.fromCharCode
  • L85 · deriveEncryptionKey calls (conditional paths may differ): crypto.subtle.importKey, encoder.encode, crypto.subtle.deriveKey
  • L98 · encryptValue calls (conditional paths may differ): deriveEncryptionKey, crypto.getRandomValues, encode, crypto.subtle.encrypt, join, map, Array.from, padStart, b.toString
  • L109 · decryptValue calls (conditional paths may differ): encrypted.startsWith, encrypted.split, map, ivHex.match, parseInt, ctHex.match, deriveEncryptionKey, crypto.subtle.decrypt, decode
  • L132 · deriveDownloadKey calls (conditional paths may differ): crypto.subtle.importKey, encoder.encode, crypto.subtle.sign
  • L139 · verifyJWT calls (conditional paths may differ): token.split, crypto.subtle.importKey, encoder.encode, Uint8Array.from, atob, replace, encodedSignature.replace, c.charCodeAt, crypto.subtle.verify, JSON.parse, encodedPayload.replace, Math.floor, Date.now
api/src/routes/auth.ts ↗
  • L10 · registrationStatus calls (conditional paths may differ): first, env.DB.prepare, success
  • L19 · register calls (conditional paths may differ): request.json, error, emailRegex.test, first, env.DB.prepare, bind, body.email.toLowerCase, generateId, hashPassword, run, all, createJWT, createRefreshToken, hashRefreshToken, json
  • L106 · login calls (conditional paths may differ): request.json, error, first, bind, env.DB.prepare, body.email.toLowerCase, hashPassword, getTime, Date.now, Math.ceil, run, verifyPassword, replace, toISOString, json, decryptValue, encryptValue, verifyTOTP, createJWT, createRefreshToken
  • L198 · changePassword calls (conditional paths may differ): request.json, error, first, bind, env.DB.prepare, verifyPassword, hashPassword, run, success
  • L223 · getProfile calls (conditional paths may differ): first, bind, env.DB.prepare, error, all, success
  • L268 · generateVerificationToken calls (conditional paths may differ): crypto.getRandomValues, String.fromCharCode, replace, btoa
  • L276 · buildVerificationEmail calls (conditional paths may differ): join, escapeHtml
  • L326 · escapeHtml calls (conditional paths may differ): replace, s.replace
  • L333 · sendAppEmail calls (conditional paths may differ): fetch, JSON.stringify, slice, resp.text, env.SEND_EMAIL.send
  • L354 · sendVerificationEmail calls (conditional paths may differ): encodeURIComponent, buildVerificationEmail, sendAppEmail
  • L360 · addLinkedEmail calls (conditional paths may differ): request.json, error, emailRegex.test, body.email.toLowerCase, first, bind, env.DB.prepare, toLowerCase, user.email.split, email.split, PUBLIC_EMAIL_DOMAINS.has, generateId, run, success, generateVerificationToken, toISOString, Date.now, sendVerificationEmail, console.error
  • L423 · resendLinkedEmailVerification calls (conditional paths may differ): first, bind, env.DB.prepare, error, generateVerificationToken, toISOString, Date.now, run, sendVerificationEmail, console.error, success
  • L456 · verifyLinkedEmailLink calls (conditional paths may differ): url.searchParams.get, verificationPage, first, bind, env.DB.prepare, getTime, Date.now, run
  • L484 · verificationPage calls (conditional paths may differ): escapeHtml
  • L518 · removeLinkedEmail calls (conditional paths may differ): run, bind, env.DB.prepare, error, success
  • L527 · listLinkedEmails calls (conditional paths may differ): all, bind, env.DB.prepare, success
  • L535 · updatePreferences calls (conditional paths may differ): request.json, valid.includes, error, run, bind, env.DB.prepare, encryptValue, body.weavehub_ai_key.startsWith, first, success
  • L616 · getUserApiKey calls (conditional paths may differ): first, bind, env.DB.prepare, decryptValue
  • L634 · mfaSetup calls (conditional paths may differ): request.json, error, first, bind, env.DB.prepare, verifyPassword, generateSecret, getOTPAuthURL, encryptValue, run, success
  • L657 · mfaEnable calls (conditional paths may differ): request.json, error, first, bind, env.DB.prepare, decryptValue, verifyTOTP, run, success
  • L682 · mfaDisable calls (conditional paths may differ): request.json, error, first, bind, env.DB.prepare, verifyPassword, decryptValue, verifyTOTP, run, success
  • L711 · forgotPassword calls (conditional paths may differ): request.json, error, first, bind, env.DB.prepare, body.email.toLowerCase, hashPassword, success, run, crypto.getRandomValues, join, map, Array.from, padStart, b.toString, hashResetToken, toISOString, Date.now, generateId, encodeURIComponent
  • L772 · resetPassword calls (conditional paths may differ): request.json, error, first, bind, env.DB.prepare, body.email.toLowerCase, all, hashResetToken, run, hashPassword, success
  • L826 · refreshAuth calls (conditional paths may differ): request.json, error, hashRefreshToken, first, bind, env.DB.prepare, run, createJWT, createRefreshToken, generateId, json
  • L874 · hashResetToken calls (conditional paths may differ): crypto.subtle.importKey, encoder.encode, crypto.subtle.sign, join, map, Array.from, padStart, b.toString

Environment references: env.REGISTRATION · env.DB · env.JWT_SECRET · env.EMAIL_FROM · env.RESEND_API_KEY · env.SEND_EMAIL · env.CLAUDE_API_KEY · env.OPENAI_API_KEY

api/src/routes/ai.ts ↗
  • L12 · resolveWeavehubKey calls (conditional paths may differ): first, bind, env.DB.prepare, decryptValue
  • L26 · createAiKey calls (conditional paths may differ): first, bind, env.DB.prepare, error, fetch, JSON.stringify, resp.json, encryptValue, run, success
  • L50 · getAiUsage calls (conditional paths may differ): resolveWeavehubKey, error, fetch, resp.json, success
  • L59 · createAiCheckout calls (conditional paths may differ): resolveWeavehubKey, error, catch, request.json, fetch, JSON.stringify, resp.json, success

Environment references: env.DB · env.JWT_SECRET · env.CLAUDE_API_KEY

api/src/routes/passkeys.ts ↗
  • L28 · fieldsTooLarge calls (conditional paths may differ): fields.some
  • L43 · storeChallenge calls (conditional paths may differ): generateId, toISOString, Date.now, run, bind, env.DB.prepare
  • L55 · consumeChallenge calls (conditional paths may differ): first, bind, env.DB.prepare, run, getTime, Date.now
  • L66 · passkeyRegisterOptions calls (conditional paths may differ): first, bind, env.DB.prepare, error, all, rpIdFromRequest, randomChallenge, storeChallenge, success, b64urlEncode, encode, existing.results.map, JSON.parse
  • L128 · passkeyRegisterVerify calls (conditional paths may differ): request.json, error, fieldsTooLarge, consumeChallenge, rpIdFromRequest, b64urlDecode, validateClientData, expectedOrigin, cborDecode, attestation.get, parseAuthenticatorData, sha256, encode, bytesEqual, b64urlEncode, coseToJwk, first, bind, env.DB.prepare, slice
  • L217 · passkeyLoginOptions calls (conditional paths may differ): rpIdFromRequest, randomChallenge, storeChallenge, success
  • L251 · passkeyLoginVerify calls (conditional paths may differ): request.json, error, fieldsTooLarge, consumeChallenge, rpIdFromRequest, b64urlDecode, validateClientData, expectedOrigin, first, bind, env.DB.prepare, decode, parseAuthenticatorData, sha256, encode, bytesEqual, verifyWebAuthnSignature, JSON.parse, getTime, Date.now
  • L347 · listPasskeys calls (conditional paths may differ): all, bind, env.DB.prepare, success, rows.results.map, JSON.parse
  • L359 · renamePasskey calls (conditional paths may differ): request.json, slice, trim, run, bind, env.DB.prepare, error, success
  • L372 · deletePasskey calls (conditional paths may differ): run, bind, env.DB.prepare, error, success

Environment references: env.DB · env.JWT_SECRET

api/src/routes/books.ts ↗
  • L6 · listBooks calls (conditional paths may differ): all, bind, env.DB.prepare, success
  • L26 · createBook calls (conditional paths may differ): request.json, error, generateId, run, bind, env.DB.prepare, first, success
  • L42 · getBook calls (conditional paths may differ): canAccessBook, error, first, bind, env.DB.prepare, all, success
  • L63 · updateBook calls (conditional paths may differ): canAccessBook, error, request.json, updates.push, values.push, run, bind, env.DB.prepare, updates.join, first, success
  • L89 · setBookStatus calls (conditional paths may differ): first, bind, env.DB.prepare, error, catch, request.json, run, success
  • L101 · deleteBook calls (conditional paths may differ): first, bind, env.DB.prepare, error, all, env.RECEIPTS_BUCKET.delete, run, success
  • L117 · shareBook calls (conditional paths may differ): canAccessBook, error, request.json, body.email.toLowerCase, validRoles.includes, first, bind, env.DB.prepare, generateId, run, success
  • L163 · listInvitations calls (conditional paths may differ): canAccessBook, error, all, bind, env.DB.prepare, success
  • L178 · revokeInvitation calls (conditional paths may differ): canAccessBook, error, run, bind, env.DB.prepare, success
  • L190 · revokeShare calls (conditional paths may differ): canAccessBook, error, run, bind, env.DB.prepare, success

Environment references: env.DB · env.RECEIPTS_BUCKET

api/src/routes/receipts.ts ↗
  • L23 · listReceipts calls (conditional paths may differ): canAccessBook, error, Object.fromEntries, params.push, parseFloat, isNaN, query.replace, first, bind, env.DB.prepare, allowedSorts.includes, Math.min, parseInt, Math.max, all, success, Math.ceil
  • L85 · listAllReceipts calls (conditional paths may differ): all, bind, env.DB.prepare, map, success, Object.fromEntries, join, bookIds.map, params.push, parseFloat, isNaN, first, allowedSorts.includes, Math.min, parseInt, Math.max, Math.ceil
  • L145 · createReceipt calls (conditional paths may differ): canAccessBook, bookEditDeniedError, request.json, generateId, run, bind, env.DB.prepare, first, success
  • L174 · getReceipt calls (conditional paths may differ): canAccessBook, error, first, bind, env.DB.prepare, all, success
  • L190 · updateReceipt calls (conditional paths may differ): canAccessBook, bookEditDeniedError, request.json, validStatuses.includes, error, updates.push, values.push, run, bind, env.DB.prepare, updates.join, first, success
  • L240 · deleteReceipt calls (conditional paths may differ): canAccessBook, bookEditDeniedError, first, bind, env.DB.prepare, error, env.RECEIPTS_BUCKET.delete, run, success
  • L256 · uploadReceiptImage calls (conditional paths may differ): canAccessBook, bookEditDeniedError, request.headers.get, contentType.startsWith, error, request.formData, formData.entries, ALLOWED_TYPES.includes, value.arrayBuffer, files.push, request.arrayBuffer, now.getUTCFullYear, padStart, String, now.getUTCMonth, generateId, file.type.split, env.RECEIPTS_BUCKET.put, run, bind
  • L327 · getReceiptImage calls (conditional paths may differ): canAccessBook, error, first, bind, env.DB.prepare, env.RECEIPTS_BUCKET.get
  • L348 · getReceiptAttachment calls (conditional paths may differ): canAccessBook, error, first, bind, env.DB.prepare, JSON.parse, parseInt, isNaN, env.RECEIPTS_BUCKET.get, slice, att.filename.replace
  • L381 · retryAllFailedReceipts calls (conditional paths may differ): canAccessBook, bookEditDeniedError, all, bind, env.DB.prepare, failed.results.slice, success, run, startReceiptProcessing, Date.now
  • L418 · retryReceipt calls (conditional paths may differ): canAccessBook, bookEditDeniedError, first, bind, env.DB.prepare, error, run, startReceiptProcessing, Date.now, success
  • L447 · cleanupStuckReceipts calls (conditional paths may differ): run, env.DB.prepare
  • L455 · getBookSummary calls (conditional paths may differ): canAccessBook, error, url.searchParams.get, params.push, first, bind, env.DB.prepare, all, success

Environment references: env.DB · env.RECEIPTS_BUCKET

api/src/services/export.ts ↗
  • L23 · exportBook calls (conditional paths may differ): canAccessBook, error, url.searchParams.get, params.push, all, bind, env.DB.prepare, first, sanitizeFilename, renderReceiptsExport
  • L63 · renderReceiptsExport calls (conditional paths may differ): exportCSV, exportJSON, exportQBO, exportOFX, exportPDFData, error
  • L77 · exportCSV calls (conditional paths may differ): receipts.map, csvEscape, join, headers.join, rows.map, r.join
  • L96 · exportJSON calls (conditional paths may differ): toISOString, receipts.reduce, receipts.map, JSON.stringify
  • L126 · exportQBO calls (conditional paths may differ): lines.push, formatQBDate, toFixed, iifEscape, r.amount.toFixed, lines.join
  • L154 · exportOFX calls (conditional paths may differ): formatOFXDate, r.amount.toFixed, xmlEscape, receipts.reduce, total.toFixed
  • L289 · exportPDFData calls (conditional paths may differ): receipts.reduce, toISOString, receipts.map, JSON.stringify
  • L324 · sanitizeFilename calls (conditional paths may differ): slice, replace, name.replace
  • L328 · iifEscape calls (conditional paths may differ): value.replace
  • L332 · csvEscape calls (conditional paths may differ): test, escaped.includes, escaped.replace
  • L344 · xmlEscape calls (conditional paths may differ): replace, value.replace
  • L348 · formatQBDate calls (conditional paths may differ): d.getMonth, d.getDate, d.getFullYear
  • L353 · formatOFXDate calls (conditional paths may differ): slice, replace, date.toISOString

Environment references: env.DB

api/src/routes/income.ts ↗
  • L7 · listIntegrations calls (conditional paths may differ): all, bind, env.DB.prepare, success
  • L15 · upsertIntegration calls (conditional paths may differ): request.json, validProviders.includes, error, encryptValue, JSON.stringify, first, bind, env.DB.prepare, run, success, generateId
  • L57 · deleteIntegration calls (conditional paths may differ): run, bind, env.DB.prepare, error, success
  • L67 · syncIntegrationCore calls (conditional paths may differ): decryptValue, JSON.parse, syncStripe, syncStripeSubscriptions, result.errors.push, syncGooglePlay, syncAppleAppStore, syncAppleSubscriptions, run, bind, env.DB.prepare, msg.slice, slice, result.errors.join
  • L145 · syncIntegration calls (conditional paths may differ): first, bind, env.DB.prepare, error, syncIntegrationCore, success, console.error
  • L162 · syncAllIntegrations calls (conditional paths may differ): all, env.DB.prepare, syncIntegrationCore, console.log, console.error
  • L177 · listIncomeTransactions calls (conditional paths may differ): url.searchParams.get, Math.max, parseInt, Math.min, params.push, first, bind, env.DB.prepare, all, success, Math.ceil
  • L215 · listPayouts calls (conditional paths may differ): url.searchParams.get, params.push, all, bind, env.DB.prepare, success
  • L236 · markPayoutReceived calls (conditional paths may differ): catch, request.json, first, bind, env.DB.prepare, error, slice, toISOString, run, success
  • L273 · getIncomeDashboard calls (conditional paths may differ): now.getUTCFullYear, padStart, String, now.getUTCMonth, first, bind, env.DB.prepare, success, grossOf
  • L359 · getIncomeSummary calls (conditional paths may differ): url.searchParams.get, params.push, first, bind, env.DB.prepare, all, success

Environment references: env.DB · env.JWT_SECRET

api/src/routes/admin.ts ↗
  • L22 · triggerReconcile calls (conditional paths may differ): url.searchParams.get, isNaN, asOf.getTime, error, generateId, run, bind, env.DB.prepare, startedAt.toISOString, JSON.stringify, asOf.toISOString, reconcileAllUsers, finishedAt.toISOString, finishedAt.getTime, startedAt.getTime, success, slice, String, Date.now, msg.slice
  • L70 · backfillUsd calls (conditional paths may differ): all, bind, env.DB.prepare, convertToUsdCents, run, errors.push, String, first, success, errors.slice
  • L121 · backfillFees calls (conditional paths may differ): all, bind, env.DB.prepare, decryptValue, JSON.parse, syncAppleAppStore, result.apple.errors.push, String, syncGooglePlayEarnings, result.google.errors.push, success
  • L166 · listCronRuns calls (conditional paths may differ): Math.min, parseInt, url.searchParams.get, all, bind, env.DB.prepare, success

Environment references: env.DB · env.JWT_SECRET

api/src/routes/subscriptions.ts ↗
  • L8 · listSubscriptions calls (conditional paths may differ): url.searchParams.get, Math.max, parseInt, Math.min, params.push, first, bind, env.DB.prepare, all, success, Math.ceil
  • L54 · getSubscriptionSummary calls (conditional paths may differ): computeSubscriptionSummary, success
  • L59 · getSubscriptionForecast calls (conditional paths may differ): Math.min, Math.max, parseInt, url.searchParams.get, computeForecast, success
  • L66 · syncSubscriptions calls (conditional paths may differ): first, bind, env.DB.prepare, error, decryptValue, JSON.parse, syncStripeSubscriptions, success, backfillGooglePlaySubscriptions, syncAppleSubscriptions, console.error
  • L108 · addGooglePlaySubscription calls (conditional paths may differ): request.json, error, first, bind, env.DB.prepare, decryptValue, JSON.parse, handleGooglePlayNotification, convertToUsdCents, run, success, console.error
  • L163 · handleGooglePlayWebhook calls (conditional paths may differ): request.headers.get, url.searchParams.get, encoder.encode, crypto.subtle.timingSafeEqual, request.json, atob, JSON.parse, all, env.DB.prepare, decryptValue, handleGooglePlayNotification, console.error

Environment references: env.DB · env.JWT_SECRET · env.GOOGLE_PLAY_WEBHOOK_SECRET

api/src/routes/app-subscription.ts ↗
  • L12 · verifyOrDecodeTransaction calls (conditional paths may differ): verifyAndDecodeJWS, decodeJWSPayload, console.warn, JSON.parse
  • L41 · verifyAppSubscription calls (conditional paths may differ): request.json, error, verifyOrDecodeTransaction, console.error, String, toISOString, generateId, run, bind, env.DB.prepare, first, catch, fetch, JSON.stringify, success
  • L132 · getAppSubscriptionStatus calls (conditional paths may differ): success, first, bind, env.DB.prepare
  • L173 · restoreAppSubscription calls (conditional paths may differ): request.json, Array.isArray, error, verifyOrDecodeTransaction, String, toISOString, generateId, run, bind, env.DB.prepare, first, success
  • L254 · handleAppleNotificationWebhook calls (conditional paths may differ): request.json, verifyAndDecodeJWS, console.error, String, console.warn, toISOString, first, bind, env.DB.prepare, run, writeAppleRefundIncome
  • L450 · writeAppleRefundIncome calls (conditional paths may differ): first, bind, env.DB.prepare, refundDate.slice, slice, toISOString, generateId, run, JSON.stringify, console.log, console.warn

Environment references: env.APPLE_BUNDLE_ID · env.DB · env.LICENSING_URL · env.LICENSING_API_KEY · env.HOSTED_PLATFORM

api/src/routes/budgets.ts ↗
  • L8 · listBudgets calls (conditional paths may differ): canAccessBook, error, all, bind, env.DB.prepare, success
  • L20 · createBudget calls (conditional paths may differ): canAccessBook, error, request.json, body.category.trim, Number.isInteger, VALID_PERIODS.includes, VALID_PERIODS.join, first, bind, env.DB.prepare, generateId, run, success
  • L59 · updateBudget calls (conditional paths may differ): canAccessBook, error, first, bind, env.DB.prepare, request.json, Number.isInteger, VALID_PERIODS.includes, VALID_PERIODS.join, run, success
  • L97 · deleteBudget calls (conditional paths may differ): canAccessBook, error, run, bind, env.DB.prepare, success
  • L110 · getPeriodDates calls (conditional paths may differ): now.getFullYear, now.getMonth, slice, start.toISOString, end.toISOString, Math.floor
  • L141 · getBudgetStatus calls (conditional paths may differ): canAccessBook, error, all, bind, env.DB.prepare, url.searchParams.get, parseInt, getPeriodDates, first, Math.round, statuses.push, success

Environment references: env.DB

api/src/routes/reports.ts ↗
  • L46 · listReports calls (conditional paths may differ): canAccessBook, error, all, bind, env.DB.prepare, totalsByReport.get, list.push, totalsByReport.set, success, reports.results.map
  • L82 · createReport calls (conditional paths may differ): canAccessBook, error, request.json, Array.isArray, receiptIds.some, findInvalidReceiptIds, generateId, bind, env.DB.prepare, map, dedupe, env.DB.batch, getReportPayload
  • L114 · getReport calls (conditional paths may differ): canAccessBook, error, getReportPayload
  • L121 · updateReport calls (conditional paths may differ): canAccessBook, error, first, bind, env.DB.prepare, request.json, body.title.trim, body.notes.trim, VALID_STATUSES.includes, VALID_STATUSES.join, toISOString, run, getReportPayload
  • L175 · deleteReport calls (conditional paths may differ): canAccessBook, error, first, bind, env.DB.prepare, env.DB.batch, success
  • L194 · addReportItems calls (conditional paths may differ): canAccessBook, error, first, bind, env.DB.prepare, request.json, dedupe, receiptIds.some, findInvalidReceiptIds, env.DB.batch, receiptIds.map, getReportPayload
  • L231 · removeReportItem calls (conditional paths may differ): canAccessBook, error, first, bind, env.DB.prepare, run, getReportPayload
  • L252 · exportReport calls (conditional paths may differ): canAccessBook, error, first, bind, env.DB.prepare, all, renderReceiptsExport, sanitizeExportName
  • L283 · getReportPayload calls (conditional paths may differ): first, bind, env.DB.prepare, error, all, totalsMap.set, totalsMap.get, map, totalsMap.entries, success
  • L314 · findInvalidReceiptIds calls (conditional paths may differ): dedupe, error, ids.slice, join, chunk.map, all, bind, env.DB.prepare, found.add, ids.filter, found.has, missing.slice
  • L343 · sanitizeExportName calls (conditional paths may differ): slice, replace, name.replace

Environment references: env.DB

api/src/routes/statements.ts ↗
  • L22 · uploadStatement calls (conditional paths may differ): canAccessBook, error, request.headers.get, contentType.startsWith, request.formData, form.get, file.text, request.text, text.trim, url.searchParams.get, sniffFormat, parseStatement, filter, transactions.map, fitids.slice, join, chunk.map, all, bind, env.DB.prepare
  • L131 · listStatements calls (conditional paths may differ): canAccessBook, error, all, bind, env.DB.prepare, success
  • L148 · getStatement calls (conditional paths may differ): canAccessBook, error, first, bind, env.DB.prepare, all, success
  • L169 · deleteStatement calls (conditional paths may differ): canAccessBook, error, first, bind, env.DB.prepare, env.DB.batch, success
  • L187 · matchStatementTransaction calls (conditional paths may differ): canAccessBook, error, first, bind, env.DB.prepare, catch, request.json, run, success
  • L215 · unmatchStatementTransaction calls (conditional paths may differ): canAccessBook, error, run, bind, env.DB.prepare, success
  • L229 · ignoreStatementTransaction calls (conditional paths may differ): canAccessBook, error, run, bind, env.DB.prepare, success
  • L244 · createReceiptFromTransaction calls (conditional paths may differ): canAccessBook, error, first, bind, env.DB.prepare, catch, request.json, slice, cleanMerchant, generateId, env.DB.batch, success
  • L277 · cleanMerchant calls (conditional paths may differ): trim, replace, description.replace, description.trim

Environment references: env.DB

api/src/routes/recurring-expenses.ts ↗
  • L8 · listRecurringExpenses calls (conditional paths may differ): canAccessBook, error, url.searchParams.get, bindings.push, env.DB.prepare, all, stmt.bind, success
  • L32 · createRecurringExpense calls (conditional paths may differ): canAccessBook, error, request.json, body.name.trim, test, VALID_FREQUENCIES.includes, VALID_FREQUENCIES.join, generateId, run, bind, env.DB.prepare, first, success
  • L75 · updateRecurringExpense calls (conditional paths may differ): canAccessBook, error, first, bind, env.DB.prepare, request.json, VALID_FREQUENCIES.includes, VALID_FREQUENCIES.join, test, run, success
  • L124 · deleteRecurringExpense calls (conditional paths may differ): canAccessBook, error, run, bind, env.DB.prepare, success
  • L137 · advanceDate calls (conditional paths may differ): d.setDate, d.getDate, d.setMonth, d.getMonth, d.setFullYear, d.getFullYear, slice, d.toISOString
  • L159 · advanceRecurringExpenses calls (conditional paths may differ): slice, toISOString, all, bind, env.DB.prepare, advanceDate, run

Environment references: env.DB

api/src/routes/tax.ts ↗
  • L31 · getTaxCategories calls (conditional paths may differ): success
  • L35 · getTaxSettings calls (conditional paths may differ): first, bind, env.DB.prepare, generateId, run, success
  • L53 · updateTaxSettings calls (conditional paths may differ): request.json, VALID_FILING_STATUSES.includes, error, VALID_FILING_STATUSES.join, first, bind, env.DB.prepare, generateId, run, success
  • L99 · getTaxSummary calls (conditional paths may differ): canAccessBook, error, url.searchParams.get, toString, getFullYear, all, bind, env.DB.prepare, reduce, success
  • L126 · getTaxEstimates calls (conditional paths may differ): canAccessBook, error, parseInt, url.searchParams.get, toString, getFullYear, first, bind, env.DB.prepare, Math.max, all, row.date.substring, quarters.map, q.months.reduce, Math.round, success

Environment references: env.DB

api/src/routes/pnl.ts ↗
  • L13 · getMonthlyBuckets calls (conditional paths may differ): months.map, padStart, String, getDate
  • L41 · getProfitAndLoss calls (conditional paths may differ): canAccessBook, error, url.searchParams.get, parseInt, toString, getFullYear, VALID_PERIODS.includes, VALID_PERIODS.join, getQuarterlyBuckets, getYearlyBuckets, getMonthlyBuckets, first, bind, env.DB.prepare, Math.round, periods.push, success

Environment references: env.DB

api/src/services/email-handler.ts ↗
  • L15 · handleInboundEmail calls (conditional paths may differ): message.headers.get, console.log, logRejectedSender, message.setReject, from.toLowerCase, first, bind, env.DB.prepare, generateId, run, streamToString, extractEmailHtml, extractEmailBody, extractAttachments, now.getUTCFullYear, padStart, String, now.getUTCMonth, env.RECEIPTS_BUCKET.put, storedAttachments.push
  • L170 · logRejectedSender calls (conditional paths may differ): run, bind, env.DB.prepare, generateId, from.toLowerCase, to.toLowerCase, subject.slice, console.error
  • L182 · streamToString calls (conditional paths may differ): stream.getReader, reader.read, decoder.decode
  • L196 · decodePartBody calls (conditional paths may differ): test, atob, body.replace, raw.charCodeAt, decode, unfolded.slice, bytes.push, parseInt, unfolded.charCodeAt
  • L225 · findAllHtmlParts calls (conditional paths may differ): raw.matchAll, raw.split, test, part.indexOf, part.slice, decodePartBody, trim, body.replace, results.push
  • L247 · extractEmailHtml calls (conditional paths may differ): findAllHtmlParts, test, best.slice, rawEmail.indexOf, rawEmail.replace, rawEmail.slice, decodePartBody, body.slice, replace, body.replace
  • L284 · extractEmailBody calls (conditional paths may differ): rawEmail.indexOf, rawEmail.slice, body.replace, trim, body.slice
  • L311 · extractAttachments calls (conditional paths may differ): rawEmail.match, rawEmail.split, part.match, contentType.split, part.indexOf, replace, part.slice, atob, binaryString.charCodeAt, attachments.push

Environment references: env.DB · env.RECEIPTS_BUCKET

api/src/utils/response.ts ↗
  • L11 · json calls (conditional paths may differ): JSON.stringify
  • L22 · error calls (conditional paths may differ): json
  • L26 · success calls (conditional paths may differ): json
api/src/utils/legal.ts ↗
  • L1 · termsOfServicePage calls (conditional paths may differ): legalPageShell, termsContent, legalHeaders
  • L7 · privacyPolicyPage calls (conditional paths may differ): legalPageShell, privacyContent, legalHeaders
api/src/workflows/receipt-processor.ts ↗
  • L286 · ReceiptProcessorWorkflow extends WorkflowEntrypoint
  • L20 · isBillingOrAuthError calls (conditional paths may differ): test
  • L33 · resolveKey calls (conditional paths may differ): decryptValue
  • L60 · htmlToText calls (conditional paths may differ): body.replace, trim, body.slice
  • L76 · runReceiptPipeline calls (conditional paths may differ): do, then, Promise.resolve, doStep, run, bind, env.DB.prepare, first, resolveKey, env.RECEIPTS_BUCKET.get, test, emailBody.trim, htmlToText, object.text, imageKey.endsWith, object.arrayBuffer, setAnthropicBaseUrl, setAnthropicModel, analyzeReceiptPdf, analyzeReceiptImage
  • L296 · startReceiptProcessing calls (conditional paths may differ): env.RECEIPT_WORKFLOW.create, catch, runReceiptPipeline, run, bind, env.DB.prepare, waitUntil

Environment references: env.JWT_SECRET · env.CLAUDE_API_KEY · env.OPENAI_API_KEY · env.DB · env.RECEIPTS_BUCKET · env.ANTHROPIC_BASE_URL · env.ANTHROPIC_MODEL · env.RECEIPT_WORKFLOW

Build and deployment pipeline · 0 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.

No GitHub Actions workflow was found in the collected tree. Deployment may be manual or configured elsewhere.

api/package.json ↗
  • deploy: npx wrangler d1 migrations apply DB --remote && wrangler deploy

Full upstream document by @WeaveHubHQ · README.md · snapshot 8ec86d6

WeaveLedger

Smart expense tracking for freelancers and small business owners. Self-hosted on Cloudflare Workers -- you own your data.

Deploy to Cloudflare

Buy Me A Coffee

Features

  • AI Receipt Scanning -- Snap a photo, AI extracts merchant, amount, date, and category (Claude or GPT-4o)
  • Expense Tracking -- Categorize, search, filter, and manage all business expenses
  • Expense Reports -- Bundle selected receipts into a report (draft → submitted → reimbursed) and export it as PDF or CSV for reimbursement
  • Statement Matching -- Import bank/card statements (OFX or CSV) and auto-match transactions to receipts; convert unmatched charges into expenses
  • Subscription Analytics -- Track MRR/ARR across Stripe, Google Play, and Apple App Store
  • Revenue Forecasting -- 12-month revenue projections from active subscriptions
  • Budget Management -- Set budgets by category, track spending vs limits
  • Tax Ready -- Tax category tracking, deduction estimates, Schedule C support
  • Multi-format Export -- CSV, JSON, PDF, QBO, OFX
  • Email Receipt Forwarding -- Forward receipts to your instance for automatic processing
  • Self-Hosted -- Runs entirely on Cloudflare's free tier (Workers, D1, R2)

Prerequisites

Before you begin, make sure you have:

npx wrangler login

This opens a browser window where you authorize Wrangler to manage your Cloudflare account.


Self-Hosted Feature Access

wrangler.toml ships with SUBSCRIPTION_ENFORCEMENT = "none", which means all features are unlocked for self-hosted deployments — no subscription required.

If you ever want to integrate with WeaveLedger's commercial licensing server (e.g., to validate App Store subscriptions centrally), change this to "licensing" and set LICENSING_URL.

Registration Control

Your worker URL serves a public landing page with a sign-in/register form (and a full web dashboard after login). Who may create accounts is controlled by the REGISTRATION var in wrangler.toml:

Mode Behavior
"first_user" (shipped default) Registration closes once any account exists. Deploy, register your own account, done — strangers who find your URL cannot sign up.
"invite" New emails must hold a pending book invitation — share a book with someone's email and they can then register.
"open" Anyone can register. Each account's data is isolated, but registered users consume your D1/R2 quota, and users who have not saved their own AI key in Settings fall back to your server-level CLAUDE_API_KEY / OPENAI_API_KEY for receipt scanning.

Registration and login are also IP rate-limited (5/min and 10/min). Unrecognized REGISTRATION values fail closed.


Quick Start

1. Clone and Install

git clone https://github.com/WeaveHubHQ/weaveledger-oss.git
cd weaveledger-oss/api
npm install

2. Create Cloudflare Resources

# Create the D1 database
npx wrangler d1 create weaveledger-db

Wrangler prints output like this:

Created database 'weaveledger-db'
database_id = "abc12345-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

Copy that database_id value and paste it into wrangler.toml:

[[d1_databases]]
binding = "DB"
database_name = "weaveledger-db"
database_id = "abc12345-xxxx-xxxx-xxxx-xxxxxxxxxxxx"  # <-- paste your ID here

Then create the R2 storage bucket:

npx wrangler r2 bucket create weaveledger-receipts

3. Run Database Migrations

Apply all migrations to set up the database schema:

npm run db:migrate

This uses wrangler's D1 migrations system: every file in migrations/ is applied in order and tracked in a d1_migrations table, so re-running only applies new ones. See Database Migrations below for what each migration does.

4. Set Secrets

# Required: JWT signing secret (generate a random 32-byte hex string)
# macOS/Linux: openssl rand -hex 32
npx wrangler secret put JWT_SECRET

# For AI receipt scanning (at least one):
npx wrangler secret put CLAUDE_API_KEY    # Anthropic API key
npx wrangler secret put OPENAI_API_KEY    # OpenAI API key

5. Deploy

npx wrangler deploy

Wrangler prints your Worker URL (e.g., https://weaveledger-api.<your-subdomain>.workers.dev).

6. Verify the Deployment

curl https://weaveledger-api.<your-subdomain>.workers.dev/api/health

You should see:

{"status":"ok","version":"2.3.0"}

Register your first user — easiest is to open your worker URL in a browser and use the Get Started / Sign In form on the landing page. Or via curl:

curl -X POST https://weaveledger-api.<your-subdomain>.workers.dev/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"you@example.com","password":"YourSecurePassword123!","name":"Your Name"}'

Note: wrangler.toml ships with REGISTRATION = "first_user", so registration closes automatically after this first account. See Registration Control to allow more users.

You are now ready to connect the iOS app.


Connect the iOS App

The WeaveLedger iOS app is available on the App Store. To connect it to your self-hosted instance:

  1. Open the WeaveLedger app on your iPhone or iPad
  2. On the login screen, tap Server Configuration (below the login form)
  3. Enter your Worker URL: https://weaveledger-api.<your-subdomain>.workers.dev
  4. Tap Save
  5. Register a new account or log in with the credentials you created above

The app stores your server URL locally and sends all requests to your self-hosted instance.


Database Migrations

The api/migrations/ directory contains all migrations. npm run db:migrate (and npm run deploy) applies them in order and tracks what has run.

File Description
0001_initial.sql Core schema: users, books, book_shares, receipts, rate_limits
0002_mfa.sql Multi-factor authentication (TOTP) fields on users
0003_roles_invitations.sql Role-based sharing (reader/member/admin) and invitation system
0004_user_emails.sql Linked email addresses for receipt capture attribution
0005_attachments.sql Multiple file attachments per receipt
0006_ai_provider_receipt_numbers.sql AI provider preference, receipt/invoice number fields for dedup
0007_user_api_keys.sql Per-user encrypted API keys (Anthropic, OpenAI)
0008_income_tracking.sql Income integrations and transactions (Stripe, Google Play, Apple)
0009_subscriptions.sql Subscription tracking for MRR/ARR revenue forecasting
0010_account_lockout.sql Failed login lockout, password reset tokens
0011_budgets_tax_recurring.sql Budgets, tax settings, recurring expense schedules
0012_token_version.sql Session invalidation on password change or MFA toggle
0013_app_subscriptions.sql WeaveLedger app subscription tracking (Apple IAP)
0014_fix_environment_check.sql Broaden app_subscriptions.environment CHECK to allow StoreKit sandbox/Xcode values
0015_rejected_email_senders.sql Track rejected inbound email senders
0017_income_lifecycle.sql Income transaction lifecycle (pending/settled)
0018_payouts.sql Payout tracking for reconciled income
0019_cron_runs.sql Cron run audit log
0020_income_updated_at.sql updated_at on income transactions
0021_gross_amount.sql Gross amount/currency columns on income
0022_zero_decimal_currency_backfill.sql Fix zero-decimal currency amounts (JPY etc.)
0023_subscription_amount_usd_cents.sql USD-normalized subscription amounts for MRR/ARR
0024_expense_reports.sql Expense reports and report↔receipt links
0025_statements.sql Bank/card statement import + receipt matching

(There is no 0016 — the sequence intentionally skips it.)


API Endpoints

All endpoints are under /api/. Routes marked with a lock require a valid JWT in the Authorization: Bearer <token> header. Routes marked with a dollar sign are gated by subscription enforcement.

Public Routes

Method Path Description
GET /api/health Health check
POST /api/auth/register Register a new user
POST /api/auth/login Log in (returns JWT)
POST /api/auth/forgot-password Request password reset
POST /api/auth/reset-password Reset password with token
POST /api/auth/refresh Refresh an auth token
POST /api/webhooks/google-play Google Play RTDN webhook
POST /api/webhooks/apple-notifications Apple App Store Server Notifications

Auth (authenticated)

Method Path Description
PUT /api/auth/password Change password
GET /api/auth/profile Get user profile
PUT /api/auth/preferences Update preferences
POST /api/auth/mfa/setup Begin MFA setup
POST /api/auth/mfa/enable Enable MFA
POST /api/auth/mfa/disable Disable MFA
GET /api/auth/emails List linked emails
POST /api/auth/emails Add linked email
DELETE /api/auth/emails/:id Remove linked email

App Subscription (authenticated)

Method Path Description
POST /api/app-subscription/verify Verify Apple IAP receipt
GET /api/app-subscription/status Get subscription status
POST /api/app-subscription/restore Restore purchase

Books (authenticated, paid)

Method Path Description
GET /api/books List books
POST /api/books Create a book
GET /api/books/:id Get a book
PUT /api/books/:id Update a book
DELETE /api/books/:id Delete a book
POST /api/books/:id/shares Share a book
DELETE /api/books/:id/shares/:shareId Revoke share
GET /api/books/:id/invitations List invitations
DELETE /api/books/:id/invitations/:invId Revoke invitation
GET /api/books/:id/summary Book summary

Receipts (authenticated, paid)

Method Path Description
GET /api/books/:id/receipts List receipts
POST /api/books/:id/receipts Create receipt
POST /api/books/:id/receipts/upload Upload receipt image (AI scan)
GET /api/books/:id/receipts/:rid Get receipt
PUT /api/books/:id/receipts/:rid Update receipt
DELETE /api/books/:id/receipts/:rid Delete receipt
POST /api/books/:id/receipts/:rid/retry Retry AI processing
GET /api/books/:id/receipts/:rid/image Get receipt image
GET /api/books/:id/receipts/:rid/attachments/:idx Get attachment

Export (authenticated, paid)

Method Path Description
POST /api/books/:id/export/:format/token Generate download token
GET /api/books/:id/export/:format Download export (csv, json, pdf, qbo, ofx)

Income & Integrations (authenticated, paid)

Method Path Description
GET /api/integrations List integrations
POST /api/integrations Add/update integration
DELETE /api/integrations/:id Remove integration
POST /api/integrations/:id/sync Trigger sync
GET /api/income List income transactions
GET /api/income/summary Income summary

Subscriptions (authenticated, paid)

Method Path Description
GET /api/subscriptions List tracked subscriptions
GET /api/subscriptions/summary MRR/ARR summary
GET /api/subscriptions/forecast 12-month revenue forecast
POST /api/integrations/:id/sync-subscriptions Sync subscriptions

Budgets (authenticated, paid)

Method Path Description
GET /api/books/:id/budgets List budgets
POST /api/books/:id/budgets Create budget
PUT /api/books/:id/budgets/:bid Update budget
DELETE /api/books/:id/budgets/:bid Delete budget
GET /api/books/:id/budgets/status Budget status (spending vs limits)

Statements (authenticated, paid)

Method Path Description
POST /api/books/:id/statements/upload Import OFX/CSV statement, auto-match to receipts (?format=ofx|csv optional)
GET /api/books/:id/statements List statements with match counts
GET /api/books/:id/statements/:sid Statement transactions + matched receipt info
DELETE /api/books/:id/statements/:sid Delete statement (receipts kept)
POST /api/books/:id/statements/:sid/transactions/:tid/match Confirm suggested or explicit match
POST /api/books/:id/statements/:sid/transactions/:tid/unmatch Clear a match / restore ignored
POST /api/books/:id/statements/:sid/transactions/:tid/ignore Ignore (e.g. personal spending)
POST /api/books/:id/statements/:sid/transactions/:tid/create-receipt Create a receipt from an unmatched transaction

Expense Reports (authenticated, paid)

Method Path Description
GET /api/books/:id/reports List reports (item counts + totals per currency)
POST /api/books/:id/reports Create report ({title, notes?, receipt_ids?})
GET /api/books/:id/reports/:rid Report details with full receipt rows
PUT /api/books/:id/reports/:rid Update title/notes/status (draft ↔ submitted → reimbursed)
DELETE /api/books/:id/reports/:rid Delete report (receipts are kept)
POST /api/books/:id/reports/:rid/items Add receipts ({receipt_ids}, draft only)
DELETE /api/books/:id/reports/:rid/items/:receiptId Remove a receipt (draft only)
GET /api/books/:id/reports/:rid/export/csv Export report as CSV
GET /api/books/:id/reports/:rid/export/pdf Export report PDF data (client-rendered)

Tax (authenticated, paid)

Method Path Description
GET /api/tax-categories List tax categories
GET /api/tax-settings Get tax settings
PUT /api/tax-settings Update tax settings
GET /api/books/:id/tax-summary Tax summary for a book
GET /api/books/:id/tax-estimates Tax estimates for a book

Profit & Loss (authenticated, paid)

Method Path Description
GET /api/books/:id/pnl Profit and loss report

Daily Sync (Cron)

WeaveLedger runs a scheduled job every day at 6:00 AM UTC. The cron trigger is defined in wrangler.toml:

[triggers]
crons = ["0 6 * * *"]

Each run performs two tasks:

  1. Sync all active integrations -- Pulls the latest transactions from Stripe, Google Play, and Apple App Store for every user who has configured an integration.
  2. Advance recurring expenses -- Generates expense entries for any recurring expenses that are due.

No configuration is needed beyond deployment. The cron runs automatically on Cloudflare's scheduler.


Email Receipt Forwarding (Optional)

You can forward email receipts to your WeaveLedger instance for automatic AI-powered processing. This requires a domain on Cloudflare.

Setup

  1. In the Cloudflare dashboard, go to your domain > Email > Email Routing
  2. Enable Email Routing for your domain if not already enabled
  3. Create a routing rule:
    • Custom address: receipts@yourdomain.com (or any address you prefer)
    • Action: Send to a Worker
    • Destination Worker: weaveledger-api
  4. Uncomment the email routing section in wrangler.toml:
[[email_routing]]
enabled = true
  1. Redeploy:
npx wrangler deploy

Usage

Forward any receipt email to receipts@yourdomain.com. WeaveLedger matches the sender's email address to a registered user (or their linked emails) and processes any image attachments through the AI receipt scanner.


CORS Configuration

By default the API allows requests from https://ledger.weavehub.app (the hosted web dashboard). This does not affect the iOS app — native apps do not send CORS headers.

If you are building a custom web frontend for your self-hosted instance, set the ALLOWED_ORIGIN variable in api/wrangler.toml:

[vars]
ALLOWED_ORIGIN = "https://your-frontend.example.com"

Then redeploy with npm run deploy.


Optional Integrations

Stripe

Add your Stripe secret key through the app's Settings > Integrations. WeaveLedger syncs your balance transactions and subscription data.

Google Play

  1. Create a service account in Google Cloud Console with the androidpublisher scope
  2. Add the service account credentials through the app's Settings > Integrations
  3. Set up Real-Time Developer Notifications (RTDN):
    • Create a Pub/Sub topic in your GCP project
    • Create a push subscription pointing to https://your-worker.example.com/api/webhooks/google-play?secret=YOUR_SECRET
    • Set the webhook secret: npx wrangler secret put GOOGLE_PLAY_WEBHOOK_SECRET
    • Configure the topic in Google Play Console > Settings > Real-time notifications

Apple App Store

  1. Create an App Store Connect API key with Finance role
  2. Add the credentials (issuer ID, key ID, private key, vendor number) through the app's Settings > Integrations
  3. Optionally, create an Admin-role key for real-time subscription cancellation detection

Custom Domain (Optional)

By default, your API is served at https://weaveledger-api.<your-subdomain>.workers.dev. To use a custom domain:

  1. Add your domain to Cloudflare (it must use Cloudflare DNS)
  2. Uncomment and edit the routes section in wrangler.toml:
routes = [
  { pattern = "ledger.yourdomain.com", custom_domain = true }
]
  1. Redeploy:
npx wrangler deploy

Cloudflare automatically provisions an SSL certificate for the custom domain.


Demo / Testing Data

The file api/seed-demo.sql contains sample data for testing and demo purposes. It was originally created for Apple App Review but is useful for anyone who wants to see the app populated with realistic data.

To use it:

  1. First, register a demo user via the API (the seed SQL expects the user to exist):
curl -X POST https://your-worker.workers.dev/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"demo@weaveledger.app","password":"DemoPass123!","name":"Demo User"}'
  1. Then apply the seed data:
npx wrangler d1 execute weaveledger-db --file=seed-demo.sql --remote

Architecture

+-------------------------------------------------+
| Cloudflare Worker (weaveledger-api)              |
|                                                  |
|  Routes -> Middleware (JWT Auth, Rate Limiting)   |
|  -> Services -> D1 Database + R2 Storage         |
|                                                  |
|  Cron: Daily sync at 6 AM UTC                    |
|  Email: Forward receipts for auto-processing     |
|  Webhooks: Google Play RTDN, Apple Notifications |
+-------------------------------------------------+
Component Service Free Tier
API & Logic Cloudflare Workers 100K requests/day
Database Cloudflare D1 (SQLite) 5M rows read/day
File Storage Cloudflare R2 10 GB
AI Processing Claude or GPT-4o Bring your own key

Development

cd api
npm install
npx wrangler dev  # Start local dev server on http://localhost:8787

npm Scripts

The package.json includes a few convenience scripts:

Script Description
npm run dev Start local development server
npm run deploy Apply any pending D1 migrations, then deploy to Cloudflare
npm run db:migrate Apply all pending migrations (remote)
npm run db:migrate:local Apply all pending migrations (local dev DB)
npm run db:baseline One-time: mark 0001–0023 as applied on a database that predates migration tracking (see Troubleshooting)

Updating

To pull the latest version and apply any new migrations:

cd weaveledger-oss
git pull origin main

cd api
npm install

# Applies any pending migrations, then deploys
npm run deploy

Upgrading from a pre-2.2 install? If you originally applied migrations with the old for loop (d1 execute per file), your database has no migration tracking and npm run deploy will fail trying to re-apply 0001. Run npm run db:baseline once first — it marks 0001–0023 as applied without running them — then npm run deploy works from then on.

Check the releases page for migration notes and breaking changes before updating.


Troubleshooting

"subscription required" / 403 Forbidden on most routes

You have not set SUBSCRIPTION_ENFORCEMENT = "none" in wrangler.toml. See Unlock All Features.

"Registration is closed on this server"

REGISTRATION = "first_user" (the shipped default) closes registration once any account exists. To add more users, either switch to "invite" and share a book with their email first, or set "open", then redeploy. See Registration Control.

database_id is empty / D1 errors on deploy

After running npx wrangler d1 create weaveledger-db, you must copy the database_id from the output and paste it into wrangler.toml. The default value is an empty string.

Migration fails with "table already exists"

Your database was set up before migration tracking (the old docs applied each file with d1 execute), so wrangler d1 migrations apply is trying to re-run migrations that already ran. Run npm run db:baseline once — it marks 0001–0023 as applied without executing them — then npm run db:migrate / npm run deploy will only apply genuinely new migrations. No data is affected.

"Unauthorized" on every request

Your JWT_SECRET is not set, or the token has expired. Verify the secret is configured:

npx wrangler secret list

If JWT_SECRET is missing, set it again with npx wrangler secret put JWT_SECRET.

AI receipt scanning returns empty results

Make sure at least one AI API key is set:

npx wrangler secret put CLAUDE_API_KEY
# or
npx wrangler secret put OPENAI_API_KEY

CORS errors in the browser

The API only allows requests from https://ledger.weavehub.app by default. If you are building a web client, set ALLOWED_ORIGIN in api/wrangler.toml. See CORS Configuration.

Email forwarding does not process receipts

  1. Verify Email Routing is enabled on your domain in Cloudflare dashboard
  2. Verify the routing rule points to the weaveledger-api Worker
  3. Verify the sender's email matches a registered user or one of their linked emails
  4. Check Worker logs: npx wrangler tail

Rate limiting / 429 Too Many Requests

The API applies per-IP rate limits on public routes (registration, login, password reset). Wait 60 seconds and try again.


License

MIT License -- see LICENSE for details.

Made by WeaveHub Technologies

Frequently asked about WeaveLedger

What is WeaveLedger?+

WeaveLedger is a self-hosted Expensify alternative built on the Cloudflare developer platform. Self-hosted business expense records with optional AI receipt extraction

What does WeaveLedger replace?+

WeaveLedger is listed as an alternative to Expensify. Compare the features and tradeoffs before migrating.

What Cloudflare primitives does WeaveLedger use?+

WeaveLedger is built on D1, R2, Workers, Workflows.

How much does WeaveLedger cost to run?+

The committed cpu_ms=30000 requires Workers Paid from $5 USD/account/month plus DB/storage/workflow overages. Claude/OpenAI API charges are required for AI receipt extraction and billed separately; manually entered expense records do not require that AI processing. Hosted subscriptions are disabled in the self-host config. Set JWT_SECRET and provision your own database/bucket/workflow resources; no hosted subscription is required with SUBSCRIPTION_ENFORCEMENT=none. Optional email-receipt forwarding, external income integrations and AI processing have independent credentials/costs. The README free-hosting wording does not override the committed Paid CPU setting. 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 WeaveLedger open source?+

The upstream repository declares the MIT license. Read its terms at https://raw.githubusercontent.com/WeaveHubHQ/weaveledger-oss/8ec86d657273bd549f274c64396be4cc0af870cf/LICENSE. Source code and contributor credit are available at https://github.com/WeaveHubHQ/weaveledger-oss.

Discussion · 0

sign in to comment →
No comments yet — be the first.