Source & license
Upstream license: MIT
License TL;DR
You can use it, change it, self-host it and sell it. Keep the original copyright and license notice with copies of the code. You don’t have to publish your changes. The authors don’t promise it will work.
Explain MIT in plain English →Summary of the main license. Separate packages and assets can have different terms.
Inspect repository ↗Read this project’s actual license ↗Repository owner
See the upstream repository for the original creator and contributors.
Maintain this project? Maintainer verification →Cloudflare hosting
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.
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.
Business expense and receipt organization/export; no corporate-card program, enterprise approval suite or complete expense-platform parity.
See supporting source ↗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 ↗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
Cloudflare Workers · compatibility 2025-01-01
weaveledger-api · default
Entrypoint: src/index.ts
Cron triggers (UTC): 0 6 * * * · */10 * * * * · 0 7 1 * *
DB→ D1RECEIPTS_BUCKET→ R2RECEIPT_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.
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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.
deploy: npx wrangler d1 migrations apply DB --remote && wrangler deploy
Repository README
View original on GitHub ↗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.
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:
- A Cloudflare account (free tier works)
- Node.js 18 or later
- npm (included with Node.js)
- Wrangler CLI authenticated:
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.tomlships withREGISTRATION = "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:
- Open the WeaveLedger app on your iPhone or iPad
- On the login screen, tap Server Configuration (below the login form)
- Enter your Worker URL:
https://weaveledger-api.<your-subdomain>.workers.dev - Tap Save
- 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:
- Sync all active integrations -- Pulls the latest transactions from Stripe, Google Play, and Apple App Store for every user who has configured an integration.
- 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
- In the Cloudflare dashboard, go to your domain > Email > Email Routing
- Enable Email Routing for your domain if not already enabled
- Create a routing rule:
- Custom address:
receipts@yourdomain.com(or any address you prefer) - Action: Send to a Worker
- Destination Worker:
weaveledger-api
- Custom address:
- Uncomment the email routing section in
wrangler.toml:
[[email_routing]]
enabled = true
- 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
- Create a service account in Google Cloud Console with the
androidpublisherscope - Add the service account credentials through the app's Settings > Integrations
- 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
- Create an App Store Connect API key with Finance role
- Add the credentials (issuer ID, key ID, private key, vendor number) through the app's Settings > Integrations
- 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:
- Add your domain to Cloudflare (it must use Cloudflare DNS)
- Uncomment and edit the
routessection inwrangler.toml:
routes = [
{ pattern = "ledger.yourdomain.com", custom_domain = true }
]
- 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:
- 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"}'
- 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
forloop (d1 executeper file), your database has no migration tracking andnpm run deploywill fail trying to re-apply0001. Runnpm run db:baselineonce first — it marks 0001–0023 as applied without running them — thennpm run deployworks 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
- Verify Email Routing is enabled on your domain in Cloudflare dashboard
- Verify the routing rule points to the
weaveledger-apiWorker - Verify the sender's email matches a registered user or one of their linked emails
- 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 →