Skip to content
DOSPublic

About

Business OS for SME and Solo Foudners

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

728 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Crove

The open-source, AI-native Business OS for solo founders and SMEs. Vietnam first.

Forward or upload your purchase invoices. Crove reads them (Vietnamese e-invoice XML deterministically, PDFs and photos with any OpenAI-compatible model), checks every number, and puts a proposed TT133 journal entry in your AI Inbox. You review, adjust accounts if needed, and approve. Approved invoices become vendors, purchase documents and balanced journal entries you can export to Excel for your accountant.

Quick start

Requirements: Docker with Compose.

git clone https://github.com/DOS/Crove.git
cd Crove
docker compose up

Open http://localhost:8080, create an account, create your organization, and drop an e-invoice XML into the Inbox. Emails sent by the bundled auth service (confirmation, password reset) and Crove's member invites are caught by Mailpit at http://localhost:8025.

The defaults are for local use. Before anyone else can reach your instance, copy .env.example to .env and change every secret: docs/self-host/ walks through a server setup (TLS, mail, storage, sign-up, backups, upgrades).

What works today (MVP)

  • Email + password sign-up and sign-in (bundled Supabase Auth), organizations, members and roles
  • Upload of e-invoice XML (the tax authority's format under Decree 254/2026 and Circular 91/2026, as before under Decree 123/2020), PDF and images; inbound email per organization
  • Deterministic checks: line totals, VAT, tax-code check digit, tax-authority code, duplicates
  • AI Inbox: proposals with suggested TT133 accounts that learn per vendor; approve, edit or reject
  • Purchase documents, vendors, journal entries that always balance, Excel export
  • Tenant isolation with PostgreSQL row-level security, append-only audit log, idempotent approvals

Configuration

Feature Variables
Modules CROVE_MODULES: comma-separated modules to run, such as finance (default: every module; kernel is always on). A module left out registers no commands, routes, jobs or menus; migrations still apply to every schema. Give the api and worker roles the same value (ADR-0031)
AI for PDFs and photos AI_BASE_URL, AI_API_KEY, AI_MODEL (any OpenAI-compatible endpoint: OpenAI, Ollama, vLLM, ...)
Product name users see BRAND_NAME: the name the app, the browser tab title, mails (sender name and texts), the authenticator app entry, the status and bio pages, the calendar feed, chat alerts, Zalo articles without an author, exported files and their names show, 1 to 40 characters of letters, digits, spaces and . , & ' + _ ! ( ) -. Unset keeps "Crove"; any other character stops the boot. Code, schemas, logs and the AGPL source notice keep "Crove". Browsers read it from /api/config
Feedback and support link SUPPORT_URL: where the app's "Góp ý / Hỗ trợ" link goes, an https: page (a chat group, a help desk) or a mailto: address. Unset hides the link; any other scheme stops the boot. Browsers read it from /api/config
Terms and privacy notice TERMS_URL, PRIVACY_URL: https: pages the sign-up and invite screens link to ("Khi tiếp tục, bạn đồng ý với..."). Unset hides each link; any other scheme stops the boot. Drafts to adapt, not yet reviewed by a lawyer: docs/legal/
Onboarding by tax code TAX_CODE_LOOKUP_URL: an https URL template with {taxCode} (for example https://api.vietqr.io/v2/business/{taxCode}, which answers in the shape the kernel reads). With it, the new-organization form looks up a client's tax code and fills in the name and address; unset hides the button, since the lookup sends the tax codes users type to that provider. Answers are cached in the process for a day
Tax portal sync TAX_PORTAL_SYNC=true turns on "Lấy danh sách từ cổng thuế" on the reconciliation screen: the accountant types the client's hoadondientu.gdt.gov.vn password and solves the portal's CAPTCHA, and Crove fetches the period's purchase list and invoice XML. Off by default: it logs in to a government portal on a client's behalf, with the client's consent. The password is never stored and the CAPTCHA is never solved by software. Needs the credential key. See docs/integrations/tax-portal-sync.md
Inbound email INBOUND_DOMAIN, INBOUND_SECRET; your mail worker POSTs raw MIME to /api/inbound/email with Authorization: Bearer <INBOUND_SECRET> and the envelope recipient in X-Crove-Recipient. tools/inbound-mail/worker.js is a ready worker for Cloudflare Email Routing (setup: provision-pilot.md, "Inbound email"; rotating the secret without losing mail: secret-rotation.md)
Bot check on the booking page TURNSTILE_SITE_KEY, TURNSTILE_SECRET (both or neither): with them the public booking page (crm module) shows Cloudflare Turnstile and the kernel checks each booking request's token with Cloudflare's siteverify before recording it; the app's CSP then lets https://challenges.cloudflare.com in for Turnstile's script and frame. Unset, nothing changes. Browsers read the site key from /api/config
Org exports (offboarding) EXPORT_ENCRYPTION_KEY (32 random bytes, openssl rand -base64 32; required on a public URL) seals exports at rest with AES-256-GCM; EXPORT_RETENTION_DAYS (35 to 90, default 35) before the worker deletes them; EXPORT_ENCRYPTION_KEY_PREVIOUS to read exports sealed before a key rotation. Give the bucket a lifecycle rule for noncurrent versions under export/ and inbound/ too
Email delivery SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM, AUTH_AUTOCONFIRM=false. Supabase Auth and Crove's member invites use the same server; port 465 is implicit TLS, other ports with credentials require STARTTLS. Without SMTP_HOST Crove sends no invite mail and the inviter passes the one-time link on
Email rate limits AUTH_RATE_LIMIT_EMAIL_SENT (default 100): the emails Supabase Auth may send per hour for the whole deployment; size it to your SMTP provider's quota. AUTH_EMAIL_CLIENT_LIMIT_PER_HOUR (default 5, IPv6 counted per /64) and AUTH_EMAIL_ADDRESS_INTERVAL_SECONDS (default 60): the per-client and per-recipient caps the /auth/v1 proxy applies first, so one client cannot use up the budget for everyone; on a public URL Crove refuses to start unless it takes at least 10 client addresses to use it up. AUTH_RATE_LIMIT_OTP (default 30): Supabase Auth's requests per 5 minutes per client address on sign-up, recovery and OTP. Behind a reverse proxy set TRUSTED_PROXY_HOPS, or every client shares the proxy's address
Who may sign up AUTH_SIGNUP_POLICY: open (default; AUTH_DISABLE_SIGNUP=true closes sign-up for everyone), invite_only (an email with a valid member invite, signing up from the invite's accept link) or allowlist (an email at one of AUTH_SIGNUP_ALLOWED_DOMAINS, or with a valid invite; an invited address always signs up from its link). Enforced by Crove's /auth/v1 proxy in front of the bundled Supabase Auth; while restricted, OAuth, ID-token and Web3 sign-in are refused, and under allowlist an account may change its email only to an allowed domain. AUTH_SIGNUP_REFUSAL_LIMIT_PER_HOUR (default 5, per IPv4 address or IPv6 /48) and AUTH_SIGNUP_REFUSAL_GLOBAL_LIMIT_PER_HOUR (per deployment, default 0: off) cap the allowlist refusals that would tell who holds an invite. See pilot-readiness.md, section 5
File storage STORAGE=local (default, the files volume; needs a filesystem with hard links, otherwise writes fall back to a non-atomic create and the store logs a warning) or STORAGE=s3 with S3_ENDPOINT, S3_REGION, S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_FORCE_PATH_STYLE (AWS S3, MinIO, Cloudflare R2, GCS with HMAC keys). With S3 the api and worker roles need no shared volume. The credentials need s3:GetObject, s3:GetObjectVersion, s3:PutObject, s3:DeleteObject and s3:ListBucket (without ListBucket a missing file reads as 403, not 404). S3_VERSIONING (default required: the bucket needs versioning; off for stores without it). S3_PRESIGNED_URLS (default true: browsers upload and download directly, which needs a CORS rule and, on a public URL, an https endpoint for browsers, S3_PUBLIC_ENDPOINT); Cloudflare R2 needs S3_PRESIGNED_URLS=false and S3_VERSIONING=off (R2 accepts no presigned POST uploads, and Crove refuses to start on an R2 endpoint with presigned URLs on; it has no versioning, so with required the first upload fails). UPLOAD_COMPLETE_CONCURRENCY (default 4) and UPLOAD_BEGIN_LIMIT_PER_MINUTE (default 20) bound presigned uploads. Give the bucket a lifecycle rule expiring staging/ objects and their noncurrent versions after a day; the worker also deletes staging/ objects older than an hour once a day, but on a versioned bucket (the default) that only adds delete markers, so the lifecycle rule is still what frees the space (ADR-0013)
File parsing sandbox EXTRACT_URL on the kernel (Compose sets it). crove-extract takes no secrets: its image refuses to start with any variable outside its allowlist (its EXTRACT_* settings, HOST, PORT, NODE_ENV and what the container runtime sets), whatever NODE_ENV says. On Kubernetes set enableServiceLinks: false on its pod, or the injected *_SERVICE_HOST and *_PORT variables refuse the start
Realtime updates GET /api/stream (SSE) tells the app which records changed, so screens refresh without polling. Each API process keeps one LISTEN connection to DATABASE_LISTEN_URL (default DATABASE_URL), which must be direct or session mode, never a transaction-mode pooler. STREAM_MAX_CONNECTIONS (default 500 per process, 0 turns streams off and the app polls), STREAM_MAX_PER_USER (default 5), STREAM_RECHECK_SECONDS (default 30: how often an open stream re-checks the session and membership), STREAM_RECHECK_CONCURRENCY (default 3: re-checks running at once per process; the others wait). The listener sends itself a NOTIFY every 30 s and reconnects when it does not come back within 10 s, so a URL that cannot deliver notifications never opens streams. Failed ticket redeems are capped per client address (20 a minute, then 429), so set TRUSTED_PROXY_HOPS behind a reverse proxy. A reverse proxy in front must not buffer text/event-stream responses (Crove sends X-Accel-Buffering: no) and must allow idle reads of at least 30 s (a heartbeat goes out every 25 s)
Database pooling DB_RESET_ON_RELEASE (default true: each pooled connection's session is reset before reuse); set false only behind a transaction-mode pooler such as PgBouncer or Supavisor, whose own reset then governs (plan §3.3)
Sessions and MFA AUTH_ISSUER (required), AUTH_SESSION_CHECK, AUTH_STEP_UP (default required: approving and membership changes ask for a two-factor code from the last 5 minutes; users enable two-factor under Settings); without Compose, run the image's auth-grants role after Supabase Auth's first start (once its migrations are done), after each Supabase Auth upgrade and after each Crove upgrade (a release may read another Supabase Auth column; until then requests get 503 and /readyz reports auth_grants). With a remote Supabase Auth (AUTH_SESSION_CHECK=jwt, the default without AUTH_UPSTREAM_URL), set PUBLIC_AUTH_URL (and PUBLIC_AUTH_ANON_KEY for hosted Supabase): R3 actions are confirmed live there, and a sign-out outside Crove takes effect when the access token expires (ADR-0028)

See .env.example for the full list.

Monitoring and alerts

The image's prober role checks that background work is moving and alerts you when it is not. It connects as crove_prober, a login that can only call crove_kernel.health() (aggregate ages and counts, no tenant data), crove_kernel.prober_alert_gate() (the alert state below) and core functions such as pg_notification_queue_usage(), and alerts when:

Check Default limit Variable
Oldest unpublished outbox event or unrelayed inbound email 300 s PROBER_MAX_OUTBOX_AGE_SECONDS
Oldest job waiting in the queue 900 s PROBER_MAX_JOB_AGE_SECONDS
Jobs that failed after their last retry (dead letters) in the last PROBER_DEAD_LETTER_WINDOW_SECONDS (default: the --every interval plus 60 s, else 3600) more than 0 PROBER_MAX_DEAD_LETTERS
Freshest worker heartbeat (the worker writes one every 30 s); none at all also alerts 120 s PROBER_MAX_HEARTBEAT_AGE_SECONDS
How full the NOTIFY queue is (pg_notification_queue_usage()): it fills when a listening session stops reading, and once full every write that sends a stream hint fails 50% PROBER_MAX_NOTIFY_QUEUE_PERCENT
The database cannot be reached or health() fails always

Every probe logs what fails, but it notifies only on news:

  • New problem: [Crove] ALERT on <instance>: ... with one line per failing check.
  • Changed problem (another set of checks fails): an alert at once, naming the checks that failed before.
  • Still failing: [Crove] STILL FAILING on <instance> for 1 h 5 min: ... every ALERT_REPEAT_MINUTES (default 60; 0 turns repeats off) while the same checks keep failing. Values such as a growing backlog do not make a new problem.
  • Resolved: [Crove] RESOLVED on <instance>: ... once every check passes again.

The state lives in the database (crove_kernel.prober_alert_state, one row), so it survives restarts and is shared by every prober on the same database: a prober holds the row while it delivers, a second one probing at that moment sends nothing, and an alert that could not be delivered stays due for the next probe. The one problem the database cannot record is the database itself being unreachable: a long-running prober (--every, the Compose profile) de-duplicates that outage in memory and says when the database answers again, while a cron run alerts on every run until it does.

Alerts go to ALERT_WEBHOOK_URL; without it they are printed to stdout. The request shape follows ALERT_WEBHOOK_FORMAT (default auto, which picks it from the webhook host):

Format ALERT_WEBHOOK_URL JSON body
slack a Slack incoming webhook, https://hooks.slack.com/services/... {"text", "mrkdwn": false}, with &, < and > escaped
discord a Discord channel webhook, https://discord.com/api/webhooks/<id>/<token> {"content", "allowed_mentions": {"parse": []}}: Markdown escaped, no pings, at most 2000 characters
telegram https://api.telegram.org/bot<token>/sendMessage, plus ALERT_TELEGRAM_CHAT_ID (numeric id, negative for groups and channels, or @channelname) {"chat_id", "text", "link_preview_options": {"is_disabled": true}}, plain text (no parse_mode)
generic anything else (Mattermost, Google Chat, your own receiver) {"text", "content", "crove"}, where crove holds kind (alert, resolved, test; usage for the weekly usage summary below, with its counts in week and previous), state (new, changed, repeat), severity, since, the findings and the health() row

Name the deployment with ALERT_INSTANCE (default: the host of PUBLIC_URL). Exit codes report health whether or not a notification went out: 0 healthy, 1 a limit was exceeded, 2 the check could not run, 3 a due notification could not be delivered (it is printed instead, with the vendor's short reason such as no_text or Bad Request: chat not found). Use an https webhook: these URLs carry their secret (the Telegram bot token, the Slack and Discord path), and the prober logs only their host.

Set CROVE_PROBER_DB_PASSWORD (applied by migrate) and ALERT_WEBHOOK_URL in .env, then pick one way to run it:

# Check the alert path end to end: you should receive "[Crove] Test alert from ..."
pnpm alert:test                                   # from a checkout, reads .env
docker compose run --rm prober prober --test-alert   # or through the image

# Compose: a long-running prober that checks every PROBER_INTERVAL_SECONDS (default 300)
docker compose --profile prober up -d

# Or cron on the host: one check every 5 minutes; a failed job counts for 360 s, so each one alerts once (twice at most)
*/5 * * * * cd /path/to/Crove && docker compose run --rm -e PROBER_DEAD_LETTER_WINDOW_SECONDS=360 prober prober >> /var/log/crove-prober.log 2>&1

Without Compose, run node apps/kernel/src/main.ts prober from any scheduler with PROBER_DATABASE_URL=postgres://crove_prober:<password>@<host>:5432/<db>. The prober cannot report its own absence or a whole-host outage: for those, add an outside uptime check on /healthz and set PROBER_HEARTBEAT_URL to an outside heartbeat monitor (a dead man's switch), which the prober pings after each probe that read the database and delivered every alert it owed, so the monitor pages when the pings stop. Setting up all three for the owner's phone, step by step: docs/runbooks/alerts.md. Replacing the database login passwords, INBOUND_SECRET or the secrets in the webhook and heartbeat URLs: docs/runbooks/secret-rotation.md.

Worker alerts. The worker sends two kinds of alert of its own, to the same ALERT_WEBHOOK_URL (set it on the worker too, as docker-compose.yml does; without it they go to the worker's log only):

  • Security signals (#996), checked every 5 minutes: one user acting in more than SECURITY_ALERT_ORG_THRESHOLD orgs (default 20), more than SECURITY_ALERT_REFUSAL_THRESHOLD refused requests (default 500) within SECURITY_ALERT_WINDOW_SECONDS (default 300), every owner or admin role given in an org, and schema changes made outside the migration pipeline (#1202, recorded by event triggers migrate installs where its admin role may). A lasting signal repeats after SECURITY_ALERT_REPEAT_MINUTES (default 60). Messages hold ids and counts only, except the schema-change alert, which also names the database role, the command and the first changed object (never the statement text). What to do for each: docs/runbooks/alerts.md, "Security alerts".
  • Back-pressure (#894): while the oldest unpublished outbox event is older than OUTBOX_LAG_PAUSE_SECONDS (default 30), the worker pauses agent, bulk mail, archive and retention jobs and says so; they run again once the lag is under half of it. Separately, a worker whose event loop stays blocked over 5 s for 30 s logs an [alert] line and exits, so the container restarts it. See docs/runbooks/incidents.md, sections 1 and 2.

Status page. Set STATUS_FILE=/data/status/status.json and the prober writes each result to the shared status volume, the API serves PUBLIC_URL/status from it (a plain page with no script and no account, in Vietnamese), and the app links to it next to "Góp ý / Hỗ trợ". The page says, for the database, uploaded documents, mail and live updates, whether each works; it shows no count, message or anything about an org. A result older than 15 minutes (the prober stopped) reads "no recent result". The volume is mounted in the all-in-one crove service and the prober; in the split layout, mount it into api too. What to do when a line turns red: docs/runbooks/incidents.md.

Weekly usage report

The image's usage-report role prints the counts that measure the MVP goal (backlog review, section 3), per ISO week from Monday 00:00 in --time-zone (default Asia/Ho_Chi_Minh). It is for the operator of the deployment, not a tenant screen: it connects as crove_reporter, a login that can only call crove_kernel.usage_weekly(), which returns counts and no tenant content (no file names, titles, amounts, emails or user ids). With --by-org it adds one row per org that was active that week, by org id and name.

Column What it counts that week
active_orgs, active_users users with at least one successful command (upload, approve, edit, export, ...), and the orgs they acted in. Reads leave no audit row, so a user who only looked is not counted
uploaded, emailed new documents by source; a duplicate of a document the org already has is not counted again
approved, approved_unedited, unedited_pct approved proposals, and those approved with no edit of their accounts first (a re-extraction is not an edit)
rejected rejected proposals
median_upload_to_approval_h median hours from a document's upload or arrival to the approval of its proposal
journal_exports, org_exports Excel journal downloads (each download counts, also of the same range) and full org exports

Set CROVE_REPORTER_DB_PASSWORD (applied by migrate) in .env, then:

docker compose run --rm usage usage-report                      # the last 8 weeks, deployment totals
docker compose run --rm usage usage-report --weeks 12 --by-org --csv > usage.csv
pnpm usage:report --weeks 4                                     # from a checkout: set REPORTER_DATABASE_URL in .env
docker compose run --rm usage usage-report --post               # also post last week's summary to ALERT_WEBHOOK_URL

Options: --weeks <1-104> (default 8, the current week included), --by-org, --csv (RFC 4180; an org name a spreadsheet would read as a formula gets a leading '), --time-zone <name>, --post. Exit codes: 0 printed (and posted), 2 the report could not run, 3 printed but the summary could not be posted. Without Compose, run node apps/kernel/src/main.ts usage-report with REPORTER_DATABASE_URL=postgres://crove_reporter:<password>@<host>:5432/<db>; it refuses a superuser or BYPASSRLS login.

--post sends a summary of the last complete week, with the week before for comparison, to the prober's alert webhook (ALERT_WEBHOOK_URL, in the same format, see "Monitoring and alerts"): deployment totals only, never an org id or name, whatever --by-org says. The report still connects as crove_reporter; the prober's login reads no usage. Without ALERT_WEBHOOK_URL, or with invalid webhook settings, it fails with exit 2 before reading anything. It logs the webhook by host only, and prints the report to stdout as usual, so --csv --post > usage.csv still writes a clean file. It needs --weeks 3 or more. A Monday cron job for it: docs/runbooks/alerts.md, "Weekly usage summary".

Pilot readiness

Before real users put real data in a deployment, run the preflight from a checkout holding the deployment's .env:

pnpm pilot:check -- --url https://crove.example.com

It fails on any secret left at a shipped sample value, a public URL that is not https, TRUSTED_PROXY_HOPS not counting the reverse proxy, AUTH_AUTOCONFIRM not false, MFA step-up off, sign-up open to anyone (AUTH_DISABLE_SIGNUP not true and AUTH_SIGNUP_POLICY neither invite_only nor allowlist) alerts that reach nobody (ALERT_WEBHOOK_URL unset or not https) and backups that never leave the server (BACKUP_S3_ENDPOINT unset, not https or the files bucket itself), and with --url probes the certificate, plain http, a sign-up for a random address no policy admits (it must be refused) and the confirmation setting the running Supabase Auth reports. It lists what only a person can confirm (the outside uptime check and a test alert on the phone, sandbox running, RLS matrix green, backups tested). Exit codes: 0 ready, 1 a check failed, 2 bad arguments. The full checklist and how to open sign-up for a pilot's accounts: docs/runbooks/pilot-readiness.md.

Development

Node 26 and pnpm (any version; the pinned one is downloaded automatically).

pnpm install
docker compose up -d db auth auth-grants mailpit   # or point .env at your own PostgreSQL 17
pnpm db:migrate
pnpm dev                                # kernel on :8080, web app on :5173
pnpm test                               # unit tests
CROVE_TEST_ADMIN_URL=postgres://postgres:...@localhost:5432/postgres pnpm test:db
CROVE_TEST_S3_URL=http://<key>:<secret>@localhost:9000/crove-test pnpm test   # also runs the S3 tests (e.g. MinIO)

Layout:

apps/kernel        API, worker, prober, usage report and migrations runner (one image: migrate | api | worker | all | prober | usage-report)
apps/app           web app (Vite + React + TanStack Router), served by the kernel
apps/web           marketing site (Next.js)
packages/db        PostgreSQL pool, Kysely types, migration runner
packages/kernel-*  contract (zod + oRPC), runtime (the single write path), kernel module
apps/extract       crove-extract: the file parsing sandbox and every parser, e-invoice XML included (bytes in, JSON out; no secrets, no egress)
packages/extract-contract  limits, schemas and the worker's client for crove-extract
packages/l10n-vn   Vietnamese invoice checks, tax codes, TT133 accounting rules (no parser)
packages/agent     AI extraction (only package that talks to a model)
modules/finance    purchase invoices, journal entries, export
modules/social     compose, approve, schedule and publish posts
modules/crm        booking pages: event types hosted by members (calendar plan)
supabase/migrations  the only place for schema changes

Architecture and roadmap: docs/plan/crove-os-kernel.md (concise Vietnamese decision page: docs/plan/crove-os-kernel.vi.html). Design system: DESIGN.md.

AI endpoint conformance and evals

Before enabling an AI endpoint or changing the extraction prompt or model, run both suites against it. They read the same variables as the kernel and are skipped when AI_BASE_URL or AI_MODEL is not set, so pnpm test and CI never call a model.

export AI_BASE_URL=http://localhost:11434/v1 AI_MODEL=<model> AI_API_KEY=<key-if-any>
pnpm ai:conformance   # chat, tool calls, structured output (zod), streaming, error mapping
pnpm ai:eval          # extraction accuracy on synthetic invoices; fails below AI_EVAL_THRESHOLD (default 0.9)
                      # or when isInvoice or the total is wrong on any fixture
pnpm ai:eval:documents  # XML, PDF and photos through their production paths: accuracy per kind and field;
                        # fails on any wrong XML amount. AI_EVAL_DIR=<folder> scores a private set instead
                        # (docs/runbooks/ai-eval.md)

Fixtures live in packages/agent/test/fixtures/extraction (<id>.txt document text plus <id>.json expected fields) and packages/agent/test/fixtures/documents (one XML, PDF and PNG, each with its .json). They are synthetic: use invented names and checksum-invalid tax codes only (a unit test rejects checksum-valid tax codes and 12-digit IDs). 429 and 5xx mapping is tested against a local fake server in pnpm test.

License

AGPL-3.0-only, see LICENSE. © Crove / DOS. See also CONTRIBUTING.md, SECURITY.md, TRADEMARKS.md and the draft CLA.md.

About

Business OS for SME and Solo Foudners

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages