Tech stack
Deliberately close to next2 — the team knows it, the patterns are proven, and the reusable pure-Python modules drop straight in. Six things change, and each is argued in the next section.
| Layer | Choice | Same as next2? |
|---|---|---|
| Language / runtime | Python 3.12 | Same |
| API | FastAPI + uvicorn, router auto-discovery per module | Same |
| Database | PostgreSQL 17 via psycopg 3 | Changed |
| Data access | SQLAlchemy 2.0 Core + ORM for masters | Changed |
| Migrations | Alembic, single chain, own alembic_version | Same |
| Tenancy | Native Postgres RLS + SET LOCAL app.tenant_id | Reworked |
| Background jobs | Postgres queue, FOR UPDATE SKIP LOCKED | Changed |
| Auth | argon2-cffi + PyJWT, epoch-claim revocation | Same |
| WeasyPrint + Jinja2 | Same | |
| Frontend | React 19 + Vite 6 + TypeScript 5.6 | Same |
| Styling | CSS custom properties, no framework — the design-kit tokens | Changed |
| i18n | react-i18next + ICU, en / ar | New |
| Icons / type | Font Awesome 7 Pro (self-hosted) · IBM Plex Sans / Arabic / Mono | Pro is new |
| Testing | pytest + vitest + Playwright | Playwright is new |
| Container / proxy | Docker Compose + Caddy with automatic TLS | Same |
| Excel | openpyxl, export short-circuit inside the grid runner | Same |
The six deltas, argued
1 · PostgreSQL, and therefore a fresh kernel
Native row-level security, no per-tenant licensing, and the right engine for thousands of small tenants on a free tier. The consequence is unavoidable and worth stating plainly: next2's kernel is raw T‑SQL and does not port. We take the design and the pure-Python modules; the tenancy layer is written fresh. Postgres RLS is also simply better here — SET LOCAL is transaction-scoped, so a pooled connection cannot leak tenant context the way SQL Server's session context can.
2 · SQLAlchemy Core rather than raw cursors
next2 issues raw SQL through pyodbc. That was defensible for an ERP with hand-tuned queries against a legacy schema; it is the wrong default for a greenfield product whose hardest queries are financial reports composed from filters. Core gives composable, typed SQL without the identity-map surprises of the full ORM — and the ORM earns its place only for master-data CRUD.
next2 runs dramatiq on Redis. We drop Redis entirely and use SELECT … FOR UPDATE SKIP LOCKED. The reason is not simplicity, though it removes a container: a job enqueued in the same transaction as the document it concerns cannot be lost or duplicated. For a compliance product where "we submitted twice" and "we never submitted" are both reportable failures, transactional enqueue is a correctness property, not an optimisation. The e-invoicing worker's state machine already assumes exactly this claim-and-advance shape.
4 · No CSS framework
The design kit is built on CSS custom properties and logical properties, and it mirrors to Arabic by setting one attribute. Tailwind would add a build dependency and handle RTL through variants — strictly worse for a product where every rule must mirror. next2's Bootstrap is not worth carrying either.
5 · i18n from the first commit
next2 has 2,500 hard-coded English strings across 263 components and no i18n library. That is the cost of retrofitting, measured. We extract from commit one, with pseudo-locale in CI so an unextracted string fails the build rather than surfacing in an Arabic screenshot.
6 · A Playwright suite, because next2 has none
Its *_e2e/ directories hold screenshot evidence, not tests. The rule that earned its place on that programme — dogfooding finds what unit tests structurally cannot — applies doubly here: RTL layout, bilingual PDFs and the wire-action confirmations are all invisible to a unit test.
Infrastructure
A separate VM is not a preference. next2 runs one self-hosted CI runner with one job slot, and its own traffic already produces measured queue waits of 30 and 37 minutes against a 34.5-minute median. Sharing it would slow both products.
Revised 12 Aug — no new VM and no managed database. next2 is paused, so its server becomes our staging box and this development VM becomes the build machine. Both changes are already in place.
| Component | Choice | State |
|---|---|---|
| Staging server | next2app — 40.80.84.69, B2as_v2, centralindia | Live — 54 GB free, 5.1 GB RAM available |
| Development | This VM, project on /data/invoicenext | Moved — 96 GB headroom |
| Database | PostgreSQL 17 in a container, volume on disk | Movable to managed on demand |
| TLS / proxy | Caddy already running on next2app | One line per hostname |
| Orchestration | Docker Compose, separate project name from next2 | Own network, own volumes |
| CI runner | Second runner on next2app, label invoicenext | next2's runner is idle while it is paused |
| Object storage | invoicenext storage account, container invoicenext, prefixes dev/ and prod/ | Created — see below |
| DNS | app.invoicenext.ai → 40.80.84.69 | Waiting on purchase |
| Production | Its own VM, provisioned at launch | Deferred until there is something to launch |
Blob storage — provisioned 12 August
| Setting | Value |
|---|---|
| Account / container | invoicenext / invoicenext — JOBNEXTINPROD, centralindia, Standard_LRS |
| Layout | dev/ and prod/ |
| Public access | Disabled |
| Transport | HTTPS only, TLS 1.2 minimum |
| Soft delete | 30 days |
| Versioning | Enabled |
Soft delete and versioning are not conveniences — they are the ISO 27001 evidence that a record cannot be quietly destroyed, and they support the fifteen-year retention obligation. An immutability policy goes on the prod/ prefix before the first live tenant, not now, because it cannot be undone.
Running our own database saves roughly $45 a month and keeps the option of moving to a managed service later. What it does not save us is the control: ISO 27001 wants tested backup and restore with evidence, and Oman wants ten years of records. So a nightly pg_dump to dev/backups/ and prod/backups/ lands in phase 1, not later — with a quarterly restore drill that is itself a CI job, so the evidence is generated rather than remembered. That is the whole reason the blob exists from day one.
38.3 GB of Docker build cache reclaimed on next2app — its deploy script prunes images but never the builder, so free space went from 19 GB to 54 GB. Our deploy.sh carries docker builder prune from the first commit. Separately, next2app has no swap; now that two products share the box, 4 GB of swap is cheap insurance against an out-of-memory kill taking down a live site.
next2app still serves app.jobnext.ai and app.projectsnext.ai, which are live. Our containers run under a separate Compose project with their own network and volumes, and the Caddy change is purely additive — but we are sharing 2 vCPU and 8 GB with a production site. A runaway build or a memory leak on our side could degrade theirs. Acceptable while next2 is paused; it is a reason to move production onto its own VM rather than grow into this one.
Archive is a first-class store, not a folder
Access points hold documents in transit then delete them; retention sits on the taxpayer. For every submitted document we keep the canonical JSON, the wire payload, the raw response, the generated UBL, the tax document, the message-level status and the rendered PDF — write-once, with an integrity hash, addressable for fifteen years.
Repository
One new private repository: axb0234/invoicenext. Plus a small second one for the shared code, because vendoring it into two products invites drift.
invoicenext/
api/
app/
core/ # tenancy, RLS enrolment, session, config — written fresh for Postgres
authz/ # function-roles + scope; catalogue shape kept, keys rewritten
ar/ # customers, items, quotes, invoices, credit + debit notes, receipts
ap/ # suppliers, inbound inbox, bill registration, acknowledge / dispute
einv/ # canonical model, validation, enrichment, state machine, adapters
gl/ # tier two — vouchers, periods, trial balance, statements
print/ # WeasyPrint doctype registry + bilingual templates
imp/ # CSV / Excel import framework (lifted)
jobs/ # Postgres queue + workers
billing/ # plans, metering, self-serve signup — nothing to lift, all new
alembic/versions/
tests/
web/
src/
shell/ # nav, ⌘K catalogue, IA budget test
ui/ # primitives on the design-kit tokens
locales/ # en.json · ar.json · pseudo.json
ar/ ap/ einv/ gl/ billing/
e2e/ # Playwright, incl. an RTL pass
infra/ # Caddyfile · docker-compose.yml · deploy.sh
planning/ # BUILD_JOURNAL.md · PARALLEL_PROTOCOL.md · GAP_MATRIX.md
vendor/fontawesome-pro/ # licensed, self-hosted, not from a CDN
The shared package
axb0234/aspirtek-kernel — pure Python, no schema, no migrations. Money and currency precision, the import framework core, the approvals effects registry, grid and export. Consumed by pinned tag:
pip install "aspirtek-kernel @ git+ssh://git@github.com/axb0234/aspirtek-kernel@v1.0.0"
A conformance test in each product's CI asserts the Python and TypeScript money implementations agree — the same technique next2 already uses to keep money.py and currency.ts in step. No cross-repository migration link ever exists.
Branching
main is always deployable. Mission branches mission/<tag> in worktrees on /data/inx-wt/<TAG> — never under ~/projects, which is the session host's working directory. Every mission gets its own database inx_<tag> and its own virtualenv, because an editable install resolves to the checkout it came from and a shared venv silently tests the wrong code. Never git stash in a fleet — the stash stack is shared across worktrees.
Pipeline
| Job | Runner | Does | Target |
|---|---|---|---|
| api-tests | GitHub-hosted | ruff, pytest with an ephemeral Postgres service container, coverage floor | < 8 min |
| web-tests | GitHub-hosted | tsc, vitest, i18n extraction check, IA budget test | < 4 min |
| deploy | self-hosted invoicenext | fetch, build, migrate, up, health-poll, prune images and builder | < 3 min |
| integration | self-hosted | real DB, RLS isolation as a non-superuser, ASP sandbox round trip | < 10 min |
| e2e | self-hosted | Playwright: sign-up, issue, submit, resolve, acknowledge — LTR and RTL | < 8 min |
Integration jobs must not skip silently. On next2 a red unit suite buys a free pass for every integration test behind it, and a missing connection string made the catalogue tests assert against an empty result rather than fail. Every environment assertion here is paired with a positive count, so an invisible catalogue fails loudly instead of passing quietly.
Read CI at the job level, never the run rollup. The run conclusion settles before the post-deploy jobs finish. And a cancelled run means superseded by a newer push, not failure — read green off the last run that actually completed.
Environments
next2 has none beyond production. We need one more, and it is not optional: a sandbox wired to the provider's sandbox, because the alternative is testing tax submissions against the real authority.
| Environment | Database | Provider | Purpose |
|---|---|---|---|
| local — this VM | Docker Postgres on /data | Mock adapter | Day-to-day. The mock is the only Taxilla path that exists at all |
| CI | Service container, from empty each run | Mock | Proves migrations apply from empty, not just from yesterday's shape |
| staging — next2app | Container, own volume | SMARTeIS staging | app.invoicenext.ai. Real round trips, demo environment, where the pilot starts. Archives to dev/ |
| production | Own VM, provisioned at launch | Live, per-tenant credentials | Kill switch, staged OFF → MANUAL → AUTO. Archives to prod/ |
The staged rollout is carried straight from the OIG build, where it worked: a tenant starts OFF, moves to MANUAL where a human presses submit, and only then to AUTO — and switching to AUTO requires typing the company code to confirm.
Phases
Sixteen weeks to a sellable Oman product with a customer pilot running. The ledger tier follows; it is a commercial decision, not a compliance one, and the Oman deadline for our band is 1 October 2027.
Ground
Owner-led, no engineering capacity. Detailed in section 10.
- Domains, VM, repository, ISO scope decision, provider procurement.
Spine
- Kernel: tenancy, Postgres RLS, session, authz, audit trail, numbering.
- Self-serve signup and the plan/metering model — nothing to lift, and it is the commercial layer.
- Shell, design tokens, i18n scaffolding with pseudo-locale failing the build.
- CI, deploy, sandbox environment live.
Receivables and the universal on-ramp
- Customers, items, invoices, credit and debit notes, receipts with FIFO allocation.
- Bilingual document rendering, with isolate injection in the data layer from the first commit.
- CSV / Excel import with a real validator; public REST API with keys and a sandbox.
Compliance engine, both directions
- Port einv from next2; Python lifts, SQL is rewritten for Postgres.
- The enrichment layer — the missing half, and the best idea in the OIG build.
- SMARTeIS adapter completed, four known defects fixed; Complyance built against its free sandbox as the abstraction's stress test.
- Inbound rails: inbox, supplier matching, bill registration, acknowledge and dispute as wire actions.
- QR constants verified against the OTA specification. Currently best-guess.
Depth and Arabic
- Quotes, progress invoicing, retainers, recurring, expenses, customer portal, reminders.
- Full Arabic interface; RTL regression pass in Playwright.
- The settings surface — budgeted honestly at 40% of the build.
- Reports: registers, ageing, statements, VAT working paper, Checklist annexures.
Connectors and pilot
- On-prem agent and Tally as one project — Tally forces the agent to be genuinely general.
- Cloud tier: Zoho Books, QuickBooks Online, Xero, Odoo.
- Oman pilot with a real customer through SMARTeIS.
- Marketing site, published pricing, scope-and-deadline checker.
Ledger
Vouchers, periods, trial balance, IFRS statements, bilingual printed accounts. Sold to customers we already have, whose data is already in the system, against a competitor whose customers face a migration to leave.
Mission board — phases 1 and 2
Disjoint ownership, each on its own worktree and database, merged serially through one coordinator. A trap one mission pays to find is handed to the next for free.
M-KERN-1 lands before anything else merges. Every other mission enrols tables into RLS through its helper, and a second implementation appearing in parallel is how two products end up with two tenancy models. M-SHELL-1 and M-DOC-1 can start immediately against the design kit.
Verification
Eleven rules from next2, each learned by something breaking. Restated for this stack, with three added for what is new here.
| # | Rule |
|---|---|
| V1 | An isolation assertion made as a superuser proves nothing — Postgres RLS is bypassed by BYPASSRLS and by the table owner. Run isolation checks as the application role. |
| V2 | After re-pointing a migration in a rebase, your database is stale and Alembic still reports head. Rebuild from empty. |
| V3 | Freeze the tree and the database before a gate run. |
| V4 | A missing connection string does not under-test, it lies. Pair every environment assertion with a positive count. |
| V5 | A present connection string masks a unit-test failure exactly as a missing one fakes a pass. Mirror CI honestly with env -u. |
| V6 | "CI green" requires the post-deploy jobs to have succeeded, not to have been skipped. |
| V7 | Never relax a security assertion to make a shared-environment failure go away. |
| V8 | Field length limits bound the destination column, not the source. |
| V9 | Dogfood finds what tests structurally cannot. Walk it as a customer. |
| V10 | Never rebase a branch a live mission still owns — a background agent can resume. |
| V11 | pytest … | tail returns tail's exit code. Capture the status straight off pytest. A truncated log ending in a success line is indistinguishable from a pass. |
| V12 | New. Row counts read through the application role with no tenant set return zero for everything — RLS filtering, not an empty database. Verify data as the owner. |
| V13 | New. An RTL bug is invisible in a screenshot of a correct-looking page. Assert glyph order, not appearance — <bdi> works in the browser and silently fails in WeasyPrint. |
| V14 | New. Never submit to a live authority from a test. Wrong-environment submission is a real filing, and one provider selects environment by a field in the payload on the production host. |
Week one
Ordered by whether a clock is running. The first two have external deadlines.
The SMARTeIS adapter running in JobNext production has four defects against the v2.1 specification — a wrong inbound path, a malformed acknowledgement call, and an inbound mapper using outbound field names that would return empty records. That is the OIG pilot. Separately, next2 is parked mid-programme with a ranked open queue. Neither should be quietly absorbed into this plan; both need their own decision.
Cost
| Item | Monthly | One-off | Note |
|---|---|---|---|
| Staging server | $0 | — | next2app, already paid for — next2 is paused |
| Development machine | $0 | — | This VM, already paid for |
| PostgreSQL | $0 | — | Container. Managed service deferred until scale demands it |
| Blob archive | ~$5 | — | Provisioned. Grows with document volume |
| Production VM | ~$35 | — | Not yet — provisioned at launch |
| Domains | ~$15 | $100–300 | Five TLDs; .ai carries the cost |
| Domain acquisition | — | ? | Broker offer on the .com — unknown until asked |
| ISO 27001 | — | $28–55k | Via India. Surveillance audits annually thereafter |
| Provider fees | ? | — | The unresolved number. Published list is $1.10 per document; the structure matters roughly twelve times more than the rate |
| Font Awesome Pro | — | held | Existing licence; assets copied from production |
Infrastructure to get to a testable staging product is about $5 a month — the blob, and nothing else. Everything until launch runs on hardware already being paid for. The two numbers that matter are the ISO certification, which is a real commitment on an external clock, and the provider's wholesale rate, which decides whether the pricing model works at all and cannot be discovered without a conversation.