Multi-tenant positive pay platform (checks + ACH today, extensible to other payment
networks — see networks/) with pluggable OCR and an ML-assisted exception review
feedback loop.
This README covers local setup and deployment only. For architecture (data model, the
network-adapter pattern, matching engine rules, ML pipeline design), see the project's
architecture plan. For the JSON API (/api/v1/* — endpoints, auth, permissions,
schemas), see API.md. For implementing a new bank or a new customer —
prerequisites and step-by-step setup, also available as an in-app guided checklist at
/ui/wizard/bank and /ui/customers/{id}/wizard — see RUNBOOK.md.
- Message from the author
- Screenshots
- Quickstart
- Running tests
- Bulk file uploads
- Bulk-loading check images
- Backing out a bulk upload
- Users, security groups, and cross-tenant access
- Querying and exporting users (access recertification)
- Customers: segregating a tenant's own business clients
- Per-tenant branding
- Immutable action log
- Authentication
- Signing keys
- Postgres
- MSSQL
- Upgrade and downgrade support
- Web UI
- Architecture at a glance
- License
This was built using Claude Code. I am not a developer/programmer. I do have 20 years of bank systems, payments, and check processing experience. This is the Positive Pay system I want. I've also rolled in security enhancements I have never seen in commercial positive pay systems. My goal is to demonstrate banking software needs to be modernized.
More screens (accounts, customers, security groups, stop payments, check-image bulk upload, audit log, login)
Accounts |
Customers |
Security groups |
Stop payments |
Check-image bulk upload |
Audit log |
Login |
Screenshots are generated from a seeded demo tenant, not hand-captured — see
scripts/generate_screenshots.py if you change the UI enough to make these stale:
pip install -e ".[dev]"
playwright install chromium
python scripts/generate_screenshots.pyThis spins up the app against a throwaway SQLite database (never your real
.pospay-run/ or pospay.db), seeds a demo bank with realistic accounts, issued items,
exceptions, ACH activity, and users, and drives a real headless browser through each
screen to (re)write the PNGs under docs/screenshots/.
One-click (SQLite, zero manual setup):
- macOS: double-click
run_pospay.command(opens Terminal.app and runs it there). - Windows: double-click
run_pospay.bat(opens a Command Prompt window). - Linux: run
./run_pospay.shfrom a terminal — most file managers don't run a double-clicked.shfile in a terminal by default (varies by distro/desktop environment and usually needs "Allow executing file as program" enabled first in the file's properties), so this one isn't meant to be double-clicked.
On first run it creates a virtual environment, installs everything, runs migrations,
prompts you to create an organization + admin login, then starts the server and opens
your browser to it. Every re-run after that just starts the server — safe to run any
number of times. All three wrappers just find a Python interpreter and hand off to the
same OS-agnostic scripts/launcher.py; if you'd rather skip the wrapper, run that
directly on any platform:
python3 scripts/launcher.pyEverything this creates — the virtual environment, the SQLite database, uploaded check
images, and trained ML models — lives under .pospay-run/ next to the project, never in
the source tree itself. To fully reset and leave the checkout exactly as cloned, just
delete that one folder:
rm -rf .pospay-runManual setup, if you'd rather control each step yourself (and are fine with .venv,
pospay.db, data/, and ml_artifacts/ living directly in the project root as
persistent local dev state, rather than one disposable folder):
python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
alembic upgrade head
uvicorn pospay.main:app --reloadWeb UI at http://localhost:8000/ui/login. JSON API docs at http://localhost:8000/docs.
Tesseract (brew install tesseract / apt install tesseract-ocr) must be on PATH for
check-image OCR to work — it's a system binary, not pip-installable; the launcher warns
if it's missing but still runs.
PDF export of the documentation (the "Download PDF" link on /ui/docs/end-user and
/ui/docs/admin) needs the optional pdf extra plus system libraries WeasyPrint depends
on — Pango, Cairo, and GLib. run_pospay.command/scripts/launcher.py installs the
pdf extra automatically; for a manual setup, install it yourself:
pip install -e ".[pdf]"
brew install pango # macOS
apt install libpango-1.0-0 libcairo2 # Debian/UbuntuOn macOS, Homebrew's lib directory isn't on the dynamic linker's default search path, so
even with Pango installed, import weasyprint would otherwise raise OSError
(services/doc_pdf_service.py works around this automatically — no manual
DYLD_FALLBACK_LIBRARY_PATH needed). Without the pdf extra installed at all, every
other page still works — only the PDF download routes show a "not available" message
instead of a file.
The admin PDF's ER diagrams (/ui/docs/admin/data-dictionary) are pre-rendered PNGs, not
generated at request time — WeasyPrint doesn't execute JavaScript, so the live
Mermaid+pan/zoom the interactive page uses can't render there. See
scripts/render_schema_diagrams.py if you change that page's diagram content:
pip install -e ".[dev]"
playwright install chromium
python scripts/render_schema_diagrams.pyThis spins up the app against a throwaway SQLite database, drives a real headless
browser to the Data Dictionary page with pan/zoom disabled so each diagram renders at
full natural size, and (re)writes the PNGs under
src/pospay/static/generated/schema-diagrams/.
pytestBoth issued items (/ui/issued-items/bulk) and ACH transactions
(/ui/ach/transactions/bulk) accept bulk uploads, in addition to the JSON API's
/bulk endpoints (which take a pre-built JSON array, not a file):
- Delimited or Excel (
bulk_import/tabular.py): any comma/tab/semicolon/pipe file or.xlsx/.xls, header row required, column names case/spacing-insensitive, any order. Delimiter is detected by counting candidates in the header line — deliberately notcsv.Sniffer/pandas' generic auto-detection, which on a short sample can pick the most-frequent character in the text itself rather than an actual delimiter. - NACHA (
bulk_import/nacha.py, ACH only): a standard 94-character fixed-width ACH file. Lenient by design: extracts batch header fields (company id/name, SEC code, effective date) and entry detail fields (DFI account number, amount, individual id, transaction code, trace number) needed to create transactions, but does not validate file/batch control totals, entry hash, or checksums (record types 1, 7, 8, 9 are otherwise ignored) — a malformed file can partially import rather than being rejected outright.
Every row/entry carries its own account number (not a single account picked once for the whole file) — resolved against your existing accounts, so one file can span multiple accounts. Unmatched account numbers, and any other bad row, are reported individually in the results page without failing the rest of the file (one DB transaction per row).
Both upload forms have a "create missing accounts" checkbox: when checked, any
account number in the file that doesn't already exist is created automatically instead of
failing that row. Delimited/Excel files may include an optional account_name column,
used as the new account's name; NACHA files carry no such field, so accounts it creates
are named Account <number>. The same new account number appearing in multiple rows of
one file is only created once.
Every submitted file is saved and signed (bulk_import/file_storage.py,
bulk_import/signing.py, services/bulk_upload_file_service.py) — issued items, ACH
(tabular and NACHA), and the user bulk upload (see below) all keep the original bytes on
local disk (POSPAY_BULK_UPLOAD_STORAGE_DIR, default
./data/bulk_uploads, consolidated under .pospay-run/ by the quickstart launcher) plus
a SHA-256 fingerprint and an HMAC-SHA256 signature computed with a server-held secret
(POSPAY_FILE_SIGNING_SECRET — same HS256 pattern already used for JWTs). This is saved
even when the file is rejected outright (bad format, no data rows) — a malformed
submission is still evidence of what was actually sent, so it's recorded before parsing
ever runs, with a link to it shown right on the error page. Every results/error page links
to a detail view (/ui/bulk-uploads/<id>) that re-verifies the signature against a fresh
read of the file on every visit — not a cached flag — so tampering after upload (even
a direct edit of the file on disk) shows up as a mismatch, and the original file can be
downloaded byte-for-byte from there. Gated by the same permission that gated the original
upload (issued_item:write / ach_transaction:write / user:manage).
/ui/check-images/bulk (gated by both paid_item:write and check_image:write —
stricter than every other bulk import in this app, since this one creates two different
resource types in a single step, unlike the single-image upload above, which only ever
links to a paid item that already exists) accepts two formats. Both create a new
paid_item per check — running it through the same matching engine as any other
presented item — and attach its image in the same step, rather than requiring the
paid item to already exist: a real image cash letter is the presentment, not a
follow-up step.
- ZIP file (
bulk_import/zip_import.py): a zip containing exactly one manifest file (CSV/TSV/Excel — columnsaccount_number,check_number,amount,presented_date,front_image_filename, optionalback_image_filename) plus the image files it references by filename (matched by basename, case-insensitively, anywhere in the archive). Supported image formats: single- or 2-page TIFF, JPG, PNG (bulk_import/images.py) — a 2-page TIFF is split automatically (page 1 = front, page 2 = back) unlessback_image_filenameis given explicitly, which always wins. - X9.37 image cash letter (
bulk_import/x937.py): a real Check 21 cash letter file. Lenient by design, matching this app's existing NACHA parser's philosophy: supports the common ASCII, line-delimited "variable format" variant with embedded TIFF/JPEG images in Image View Data (Type 52) records; does not support the alternate fixed-length undelimited binary variant, EBCDIC encoding, return/adjustment cash letters, credit reconcilement records, or file/cash-letter/bundle control-total validation. Account number and check number come from each check detail record's On-Us and Auxiliary On-Us MICR fields respectively — a common real-world convention (the check's own serial number in Auxiliary On-Us, the payor's account number in On-Us), not a standard-mandated split, so a bank whose files use a different layout won't resolve correctly here; the ZIP+CSV format above is the explicit-column fallback for that case.
Every incoming image, regardless of source format, is decoded and re-encoded to PNG
before storage (bulk_import/images.py) — this is what makes the front/back download
links on a check image's detail page (/ui/check-images/<id>/front and /back) and
downstream OCR work identically no matter what format the original file arrived in.
Same per-row/per-item transaction isolation as every other bulk importer (one bad check
doesn't roll back the batch), and the same signed-original-file audit trail
(BulkUploadKind.CHECK_IMAGES) described above.
Every bulk upload (issued items, ACH transactions, users, accounts, check images) can be
undone from its detail page (/ui/bulk-uploads/<id>, "Back out this upload"), reusing
the same signed-file/audit infrastructure described above. This is only possible for
uploads processed after this feature shipped — bulk_upload_created_record (new)
durably links a BulkUploadFile to every row it successfully created, something no
earlier version of this app tracked; an older upload's detail page shows "nothing can be
automatically backed out" instead of the button.
Backing out is one-way (matching every other void/cancel/revoke action's own one-way design — nothing here supports "undo the undo") and, per resource type, either really reverses what was created or is a record-only annotation, depending on whether this app has any reversible concept for that resource at all:
- Issued items: voided (the same
void_issued_itema manual void uses) — skipped, not an error, if already voided or already paid (voiding a paid item would leave its matching paid item in an inconsistent state). - Users: the membership created deactivates (the same
deactivate_membershipthe manual button uses) — skipped if already inactive. Only memberships created directly by the bulk file are tracked; one confirmed later via the separate cross-tenant confirmation step is a deliberate, independent admin action and isn't automatically backed out (it can still be deactivated individually, same as any other membership). - Accounts / ACH transactions: record-only. Neither has any deactivation or
reversal concept in this app at all (no
Account.is_active, noAchTransactionstatus field) — backing these out marks the tracking record and logs an audit entry, without inventing new account-lifecycle or ACH-reversal behavior that wasn't asked for. - Check images: the hardest case, since a bulk check-image row's real object is the
paid_itemit created (see above), which can have already changed other rows. Reversing it flips a matchedPaidItem.settlement_statusto a newREVERSEDvalue (excluded from duplicate-payment detection, same asRETURNED); if it had flipped a linked issued item toPAID, that reverts toOUTSTANDING— but only if nothing else changed that issued item's status since (left alone and noted otherwise). If it had spawned an exception in the review queue that's stillOPEN/PENDING_APPROVAL, that exception is auto-withdrawn (newExceptionStatus.WITHDRAWN— shown in the exceptions queue, and rejected by the recommend/decide routes with a clear message rather than the generic "already decided" one); one that's already been paid/returned/escalated by a human is left untouched and noted, never silently overridden.
Requires the same permission that gated the original upload
(issued_item:write/ach_transaction:write/user:manage/account:write), plus, for
check images specifically, both paid_item:write and check_image:write — matching
that upload route's own stricter dual-permission gate, since backing one out can touch
both resource types.
Access control is a set of per-tenant security groups (auth/permissions.py,
services/security_group_service.py), not a fixed role enum — each group is a named,
editable set of permission keys drawn from a single catalog covering every action in the
app (read/write per resource, plus exception:recommend/exception:decide,
admin:manage, user:manage, security_group:manage). Every new tenant is seeded with 4
default groups reproducing the old fixed roles — Admin (everything), Preparer
(read/write except deciding exceptions or managing users/groups/admin), Approver
(read-only plus exception:decide), Viewer (read-only) — fully editable from there
via /ui/security-groups. Permissions are resolved from the database on every
request (auth/deps.py::decode_and_build_context), not baked into the JWT, so editing a
group's permissions — or deactivating a user — takes effect on the very next request, not
after the token expires.
Users (/ui/users) can be added one at a time or via CSV bulk upload
(/ui/users/bulk, columns: email, security_group, password), reusing the same
bulk_import/ infrastructure as issued items/ACH. A User is a global login identity
(email is unique platform-wide, not per-tenant) with zero or more TenantMembership rows,
each pointing at one tenant and one security group there — this is what makes cross-tenant
access possible: the same person, same password, can hold membership (with a different
security group) in more than one tenant. Login still takes an organization slug + email +
password exactly as before; once logged in, "Switch organization" in the nav
re-mints tokens for any other tenant you're an active member of, with no re-entry of
password or WebAuthn (see below).
Adding a user by an email that already belongs to an identity in a different tenant never attaches it silently — both the single-add form and the bulk CSV surface an explicit confirmation step ("grant this existing user access to this organization?") before a membership is created, and that confirmation re-resolves the identity by email server-side rather than trusting anything the client posted.
WebAuthn credentials are still registered per tenant-membership, not once for the whole identity — a user with memberships in two tenants currently registers a security key separately in each. Unifying that to one identity-wide credential set is a reasonable fast-follow, deliberately out of scope for the cross-tenant-membership work.
GET /api/v1/users (gated by user:manage) returns the same data /ui/users shows —
one row per TenantMembership (email, security group, customer scope or "bank-wide",
active/deactivated status, when the membership was created, and last_login_at) — built
by reusing user_service.list_tenant_users directly, so the API and the web page can
never drift apart. A user with several memberships in one tenant (bank-wide plus
per-customer scopes) correctly appears once per membership, matching how access is
actually granted; this is meant for an access-review/recertification process that needs
to answer "who has access to what, and have they actually used it."
User.last_login_at is now actually populated — it existed as a column before this but
nothing ever set it. It's stamped at the moment a login completes (password-only, or
after WebAuthn MFA finishes), on both the web and API channels; token refresh and
"switch organization" don't count as a fresh login and don't touch it.
The same list can be exported straight from /ui/users as CSV or JSON
(/ui/users/export.csv / .json, same permission, same underlying data — no separate
filtering, it exports exactly what the page shows).
A Tenant is the bank; a Customer (/ui/customers, gated by a dedicated
customer:manage permission) is one of the bank's own business clients within that
tenant — e.g. "Acme Corp," a company whose accounts and check/ACH activity the bank
processes. Customers are optional: accounts created without one are "house" accounts,
visible only to tenant-wide staff exactly as before this feature existed, so an existing
installation with no customers behaves identically to today.
One mechanism serves two use cases. TenantMembership gains an optional
customer_id: NULL is today's exact behavior (tenant-wide staff, sees everything in the
tenant), and a real value scopes that membership's security group to just that one
customer's data. The same field expresses both "a bank employee restricted to servicing
specific customers" and "a customer's own employee logging in to see only their own
company's data" — there's no separate portal or permission catalog, just a narrower scope
on an ordinary membership. A person can hold several memberships in the same tenant (one
tenant-wide, plus overrides per customer, or several customer-scoped ones with no
tenant-wide membership at all) —"Switch organization" (/ui/switch-tenant) doubles as
"switch customer scope," listing every membership and which customer (or "bank-wide") each
is scoped to. Logging in still takes just a slug + email + password; if more than one
membership exists for that tenant, login defaults to the tenant-wide one if present, else
the earliest-created customer membership, and the switcher reaches any other one — a
login-time picker for the (rare) multi-membership case is a reasonable fast-follow,
deliberately out of scope here.
Segregation is denormalized, not just joined. customer_id is stamped onto account
and onto every table that hangs off an account — issued_item, stop_payment,
paid_item, ach_authorization_rule, ach_transaction — the same way tenant_id
already is, always derived server-side from the referenced account at creation time, never
trusted from a request. repositories/base.py::CustomerScopedRepository is the
enforcement point: when a session's customer_id is set, every read on those six tables
is additionally filtered to it; when it's None (tenant-wide), it behaves exactly like
plain tenant scoping. A customer-scoped caller referencing another customer's account_id
directly — not just through a filtered dropdown — gets a clean 404, the same
anti-enumeration posture as cross-tenant isolation, because every service that creates a
child record resolves the parent account through this same repository rather than a raw
lookup.
No new "what" permissions — only "whose." A security group's permissions
(Preparer, Approver, etc.) mean the same thing whether a membership is tenant-wide or
customer-scoped; scoping only narrows whose data they apply to. One hard-coded exception:
user:manage, security_group:manage, tenant:manage, customer:manage,
admin:manage, and audit_log:read are unconditionally stripped from the resolved
permission set whenever a membership is customer-scoped
(auth/permissions.py::CUSTOMER_SCOPE_MASKED_PERMISSIONS, enforced in
auth/deps.py::decode_and_build_context) — regardless of what the underlying security
group nominally contains, so even a customer-scoped membership using the full "Admin"
group can never reach tenant-admin surfaces like /ui/users or /ui/audit-log.
Bulk loading extends the existing CSV infrastructure rather than adding a new one:
accounts (/ui/accounts/bulk, columns account_number, name, optional
customer_number, optional ach_debit_block_mode) and users
(/ui/users/bulk, existing columns plus an optional customer_number) both resolve a
human-readable customer number to the tenant's internal customer record the same way
issued-item/ACH bulk uploads already resolve account numbers. A customer-scoped uploader
can only ever create rows in their own scope — a customer_number column that names a
different customer is rejected, not silently reassigned.
Not yet done, documented as an accepted v1 limitation: Postgres RLS policies on the six
customer-scoped tables enforce tenant_id only, not customer_id — the repository layer
above is the primary, tested enforcement point, same posture as tenant isolation's own RLS
today.
Each tenant can customize a logo, favicon, accent color, and display name from
/ui/settings (gated by a dedicated tenant:manage permission, not folded into
admin:manage, so a security group can be scoped to branding alone). Uploaded images are
stored on local disk under POSPAY_TENANT_ASSET_STORAGE_DIR (default
./data/tenant_assets, consolidated under .pospay-run/ by the quickstart launcher, same
as check-image storage) — the DB only ever stores the path and content-type, never the
blob. The accent color reuses the single --accent CSS custom property app.css already
threads through every button/link/nav highlight, applied via a small inline <style>
override, so no CSS rewrite was needed.
Branding shows throughout the logged-in app shell (nav logo/name, page titles, browser-tab
favicon, accent color) and on the login page itself — via a tenant slug in the URL
(/ui/login/<slug>), not Host-header/subdomain routing. A slug-based login link needs no
DNS or reverse-proxy infrastructure and works identically locally and in production,
unlike a subdomain-per-tenant approach, which this project deliberately doesn't build
toward given its local-first, one-click-launcher design. An unknown or inactive slug falls
back to the plain generic login form rather than an error. Logo/favicon bytes are served
by two public (unauthenticated) routes keyed by slug, /ui/branding/<slug>/logo and
/ui/branding/<slug>/favicon — a company logo isn't sensitive, and the login page needs to
show it before any session exists, so both the pre-auth login page and the authenticated
app shell hit the exact same serving routes.
Every state-changing action — through either the web UI or the JSON API — is recorded
to a per-tenant, tamper-evident action log (/ui/audit-log, gated by a dedicated
audit_log:read permission that's Admin-only by default, unlike every other *:read
permission, since "who did what" is more sensitive than any single resource's own data).
Covers create/void/cancel/revoke/upload/recommend/decide across issued items, stop
payments, paid items, check images, ACH authorizations/transactions, exceptions/decisions,
accounts, users, security groups, and tenant settings — one entry per successfully-created
row for bulk uploads too, not one entry for the file as a whole (the file itself already
has its own signed audit record — see "Bulk file uploads" above).
Entries form a hash chain, not independent per-row signatures: each entry's
entry_hash is an HMAC-SHA256 (POSPAY_AUDIT_LOG_SIGNING_SECRET, a dedicated secret,
distinct from every other signing secret in this app) over its own fields plus the
previous entry's hash. A chain — not independent signatures — is what's needed here,
because the real threat to an action log is deletion or reordering, not just editing one
row: an independent per-row signature can't detect a row being deleted outright, but a
broken chain link can. services/audit_log_service.py::verify_chain (surfaced as "Verify
chain" on the audit log page) walks every entry for a tenant and recomputes each hash from
scratch — proof nothing has been edited, deleted, or reordered since it was written, not a
cached flag. This is tamper-evidence, not OS-level tamper-prevention (no DB
triggers/grants are revoked) — the same posture as bulk-upload file signing.
Logging calls live in the route handlers (both web/routers/*.py and api/v1/*.py),
right after the mutating service call succeeds and before that request's db.commit() —
so the audit entry and the business change commit together atomically, and every route
already has the actor/tenant/channel it needs without any shared service function having
to take on a new required parameter.
Username + password (bcrypt-hashed) issuing JWTs (api/v1/auth.py), with an optional
FIDO2/WebAuthn second factor (api/v1/webauthn.py, auth/webauthn_service.py):
POST /auth/webauthn/register/options+/register/verify— register a security key (authenticated with a normal access token)GET /auth/webauthn/credentials,DELETE /auth/webauthn/credentials/{id}— manage registered keys- Once a user has at least one registered key,
POST /auth/loginno longer returns real tokens after the password check — it returnsmfa_required: trueand a short-livedmfa_token(5 min, carries no permissions) instead. That token authenticatesPOST /auth/webauthn/login/options+/login/verify, which — once the assertion verifies — issue the real access/refresh token pair. - Users with no registered key log in exactly as before (
mfa_required: false); this is additive, not a breaking change for existing accounts.
WebAuthn requires a real browser to drive navigator.credentials.create()/get() — there's
no backend-only way to test the full ceremony against an actual authenticator. The test
suite (tests/test_auth/webauthn_helpers.py) instead hand-constructs cryptographically
valid registration/authentication responses (COSE-encoded EC key, CBOR attestation/
authenticator data, DER ECDSA signature) to exercise the real server-side verification
path without a browser. webauthn_rp_id/webauthn_origin default to localhost /
http://localhost:8000 — set both to your real domain before deploying, or every
registered credential will fail verification against the wrong origin.
For the full set of /api/v1/* endpoints (issued items, stop payments, paid items,
check images, ACH, exceptions/decisions, admin, users), the permission each one requires,
and request/response schemas, see API.md.
Four things get cryptographically signed: JWTs (login sessions), bulk-upload files (tamper-evidence), the immutable audit log (its hash chain — see "Immutable action log" below), and signed Written Statement of Unauthorized Debit (WSUD) attestations. Each uses its own ECDSA P-256 (ES256) key pair rather than a shared secret string, so a leaked key can't also be used to forge the others, and — unlike a guessable string — a real key pair can't accidentally ship as a usable default.
For local dev and the test suite, dev_keys/ is a checked-in, deliberately public key
pair set (see dev_keys/README.md) — zero setup required. Never use it for a real
deployment. Generate your own before deploying:
python scripts/generate_keys.py --output-dir keys
This prints the eight POSPAY_*_PRIVATE_KEY_PATH/POSPAY_*_PUBLIC_KEY_PATH env vars to
set. Prefer openssl instead? The equivalent for each of the four pairs (jwt,
file_signing, audit_log_signing, wsud_signing) is:
openssl ecparam -genkey -name prime256v1 -noout -out keys/<name>_private.pem
openssl ec -in keys/<name>_private.pem -pubout -out keys/<name>_public.pem
Then set POSPAY_ENVIRONMENT=production — this is what actually turns on the check
(config.py::assert_production_safe, run at app startup): a production deployment
still pointing at dev_keys/, or still using the default POSPAY_SSO_ENCRYPTION_KEY
(a separate, plain random secret — it encrypts stored SSO client secrets rather than
signing anything, so it stays a string, not a key pair), refuses to start. Local dev
(scripts/launcher.py) and the test suite both explicitly set
POSPAY_ENVIRONMENT=development, so neither is affected by this check.
Rotating a key pair later is a one-time, expected cost, not a bug: rotating the JWT key logs out every active session; rotating the file-signing or audit-log key means anything signed under the old key stops re-verifying (fine for a pre-launch system with no real history yet — a live system's key-rotation strategy is a separate, deliberately out-of-scope design question from this initial hardening pass).
pip install -e ".[dev,postgres]"
docker compose up -d postgres
POSPAY_DATABASE_URL=postgresql+psycopg://pospay:pospay@localhost:5432/pospay alembic upgrade headOr bring up the whole stack (app + Postgres) with docker compose up --build.
Row-Level Security: on Postgres, migrations enable RLS (FORCE ROW LEVEL SECURITY)
on the single-tenant operational tables (account, issued_item, stop_payment,
check_image, paid_item, ach_authorization_rule, ach_transaction,
security_group, tenant_membership, bulk_upload_file, audit_log_entry,
ach_return_reason, wsud_statement, wsud_statement_transaction) as
defense-in-depth alongside the primary
tenant-isolation mechanism (the repository-layer filter in repositories/base.py, which
is what's actually under test in tests/test_api/test_cross_tenant_isolation.py).
exception_item/decision (the ML pipeline reads across all tenants by design, see
ml/train.py) and user (a global login identity with no single-tenant row-ownership
story — see "Users, security groups, and cross-tenant access" above) are deliberately
excluded. This RLS migration has been schema-compile-verified against the Postgres
dialect but not yet exercised against a live Postgres instance in development (no
Docker/Postgres available in the environment this was built in) — test it thoroughly,
including the cross-tenant suite against a real Postgres backend, before relying on it in
production.
Requires the Microsoft ODBC Driver (17 or 18) installed at the OS level — not available
via pip alone (see Microsoft's docs for your platform). Also requires a running SQL
Server instance; a Linux container (mcr.microsoft.com/mssql/server) is the easiest way
to get one for local dev.
pip install -e ".[dev,mssql]"
POSPAY_DATABASE_URL="mssql+pyodbc://pospay:<password>@localhost:1433/pospay?driver=ODBC+Driver+18+for+SQL+Server&TrustServerCertificate=yes" alembic upgrade headKnown friction points, not yet exercised against a live instance in this build:
UNIQUEIDENTIFIERtype mapping for UUID primary/foreign keys — SQLAlchemy's genericUuidtype should handle this, but hasn't been verified against real MSSQL here.- The Postgres RLS migration is a no-op on MSSQL (dialect-gated) — MSSQL deployments rely solely on the repository-layer filter for tenant isolation, same as SQLite.
- Alembic's autogenerate has known rough edges on MSSQL around identity columns and server-side defaults; review generated migrations before applying against MSSQL.
- Upgrading is always supported, from any prior version straight to the latest —
alembic upgrade headmust work regardless of how old your starting version is. This is enforced bytests/test_migrations/test_upgrade_downgrade_policy.py:: test_full_upgrade_from_base_succeeds, run as part of the normal test suite. - Downgrading is only supported up to 2 minor versions back, and never across a major
version boundary — a migration introduced at a major version bump is allowed to have an
irreversible
downgrade()(raising, or a documented no-op), reserving room for genuinely breaking changes to exactly that boundary.
migrations/version_history.py records which Alembic revision was head at each release
— test_downgrade_two_minor_versions_supported resolves "2 minor versions back" from
there and actually runs the downgrade against a scratch database. It skips cleanly (not
silently, not failing) whenever there isn't yet 2 minor versions of recorded history to
test against.
Cutting a release: bump the version in pyproject.toml and src/pospay/main.py
together, then add one new entry to VERSION_HISTORY in migrations/version_history.py
mapping the new version to the current Alembic head — only if that release actually added
a migration (a patch that doesn't touch the schema doesn't need an entry). Never edit or
remove a past entry.
Server-rendered (FastAPI + Jinja2, no Node/build step) under /ui/*, covering every
resource: accounts, issued items, stop payments, paid items, check images (upload +
OCR status), ACH authorizations/transactions, the exceptions review queue
(recommend/decide), admin ML screens, users/security groups, per-tenant branding
settings, the immutable action log, and WebAuthn security-key management.
It's a second presentation layer over the same services//auth/ code the JSON API
uses (web/routers/*.py call service functions directly — never the JSON API over
HTTP), authenticated via cookies instead of a bearer token: web/deps.py::get_web_context
reads an access_token cookie, auth/deps.py::get_current_context reads the
Authorization header — the two channels never read each other's credential. Moving to
cookies reintroduces CSRF risk the header-based API doesn't have, so every /ui/* POST
is guarded by a double-submit cookie token (web/security.py); UI gating checks the same
ctx.permissions set (resolved from the caller's security group) the API enforces,
exposed to templates as a can(ctx, permission) Jinja global — hiding a button is
cosmetic, the POST route's own permission check is what actually enforces it.
db/— engine/session factory (one code path for all three backends), tenant contextdomain/— SQLAlchemy models (importpospay.domainto register every mapper — see its__init__.pydocstring for why this matters)networks/— the pluggable per-payment-network layer (check/,ach/); each implementsnetworks.base.NetworkAdapterand self-registers vianetworks.registry.register_adapter(). Adding a new network (e.g. RTP) means adding a newnetworks/<code>/package plus one import inmain.py— no changes toexception_item,decision,ml/, or the/exceptionsAPI.ocr/— pluggable OCR (OCRProviderprotocol; Tesseract is the default, cloud providers are stubbed behind optional extras)ml/— model training/scoring, one model per network, fed by human pay/return decisions (decision.features_json)api/v1/— FastAPI routers;exceptions.py/decisions.pyare network-agnosticworkers/— the ML retrain job, runnable via an opt-in in-process APScheduler (POSPAY_ENABLE_ML_SCHEDULER=true) or an external cron/k8s CronJob callingworkers.tasks.retrain_job()web/— the server-rendered UI (see "Web UI" above);templates/andstatic/live inside the package so they ship with it wherever it's installedscripts/launcher.py— the one-click local setup/run script (stdlib-only until it re-execs itself under a freshly-created venv's own interpreter);run_pospay.command(macOS),run_pospay.bat(Windows), andrun_pospay.sh(Linux) are thin platform-specific wrappers around it — all three just locate a Python interpreter and hand off to the same script
Copyright (C) 2026 Chaffed
PosPay is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This means that if you run a modified version of PosPay as a network service, you must make the modified source available to that service's users — see LICENSE for the full text.














