Payky is a local-first point-of-sale application for managing terminal bill flows, catalog items, bills, payments, account transactions, and background sync jobs.
The app is built with React, TypeScript, Vite, TanStack Router, Tailwind CSS, shadcn-style local UI components, Base UI primitives, Zod validation, and Evolu for persistent application data.
- Bun
For native iOS development, install Xcode and the Xcode Command Line Tools. Capacitor 8 targets iOS 15 and newer.
Install dependencies with exact versions:
bun installDependency versions are pinned through Bun. Keep exact = true in
bunfig.toml.
Install the locked Codex skills from skills-lock.json:
bunx skills experimental_installapps/web-app owns the web app, API and documentation tools.
apps/landing owns the independently deployed marketing site at /landing
(see landing deployment).
packages/web-shared owns the browser navigation contract and shared hooks.
apps/native-shell wraps its dist-native build in Capacitor; apps/cli runs
local commands. All use the single domain implementation in packages/core.
apps/native-app is the separate Expo + React Native + Tamagui application,
currently limited to a native preview and shared-core smoke check.
Core uses explicit package subpaths such as
@payky/core/modules/payment/payment-actions; @/ stays local to the web app.
Keep local .env files at the repository root (use
apps/web-app/.env.example as the template). Vite loads that directory for both
root and direct workspace commands. Run the CLI from the root, or use Bun's
--env-file option when launching it elsewhere.
bun run build emits core JavaScript for production API loading before building
the web app. bun run check:api checks every API module with Node against those
emitted exports. Browser and Bun consumers resolve core source instead.
The shared release version stays in the root package.json.
Start the Vite dev server:
bun run devBuild the app:
bun run buildPreview a production build:
bun run previewUse Node 22.13+ on a supported release line for Expo tooling, and Bun for dependencies and scripts. Install Android Studio and an Android SDK/emulator for Android; iOS requires macOS and Xcode. The existing Vite and Capacitor commands remain available independently.
From the repository root:
bun install
bun run native:android # build/install a development client on Android
bun run native:ios # build/install a development client on iOS (macOS)
bun run dev:native # start Metro for an already installed development clientnative:android and native:ios generate their native projects locally; they
are ignored by Git. The native app ships as Finito (fit.finito.app, the
finito scheme), so it coexists with the Capacitor app (me.payky); a
development build replaces an installed Finito.
Use a development client rather than relying on Expo Go availability for SDK
58. The Expo app has no environment variables or connected services yet.
The root native commands build packages/core first. Metro uses its existing
public exports; the native app never imports web application source. Keep
bun run dev running in a second terminal when developing the Vite web app
alongside Expo. For an offline Metro session use bun run dev:native --offline.
bun run check:native # native TypeScript; also included in bun run check
bun run check:lint # shared Biome configuration includes native source
bun run build:native # export Android and iOS bundles into apps/native-app/distThe preview loads bundled Inter fonts, follows the system light/dark theme,
and calls the existing core cash-rounding function with an asserted result.
entry.ts installs secure random values and UUIDs from Expo Crypto before
loading Expo Router: Evolu needs randomness while importing the shared core.
After adding or upgrading native modules, rebuild the development client with
bun run native:android or bun run native:ios; restarting Metro is insufficient.
On a device, check that 123.45 becomes 123.00 and that Run shared logic
again increments the passed-check count. Change the system theme to verify
both palettes. This verifies the shared helper in the native renderer; exporting
a bundle alone does not verify device execution.
No accounts, SQLite persistence, wallet, payment terminal or background jobs are wired up or mocked. Those remain subsequent runtime-spike tasks in the migration plan.
Capacitor is available as the Android native target:
bun run cap:android:sync
bun run cap:android:dev
bun run cap:android:build
bun run cap:ios:sync
bun run cap:ios:run
bun run cap:ios:openCapacitor builds use the native HTTP bridge through CapacitorHttp so mobile
requests are not limited by browser CORS behavior.
For Capacitor Android live reload, run:
bun run cap:android:devThe script starts the HTTP Vite dev server, waits for
http://127.0.0.1:5173, forwards the port through Capacitor's Android live
reload flow, and launches the native Android app. The HTTP server is used only
for this debug flow so Android WebView does not reject Vite's self-signed HTTPS
certificate.
For Capacitor iOS simulator development, run:
bun run cap:ios:runFor Xcode-driven iOS development, run:
bun run cap:ios:sync
bun run cap:ios:openConfigure the signing team, bundle identifier, provisioning profile, display
name, icons, launch screen, and deployment target in Xcode. The default bundle
identifier is me.payky.
For iOS live reload on a physical iPhone, start Vite on the local network in one terminal:
PAYKY_DISABLE_BASIC_SSL=1 bun run dev -- --host 0.0.0.0 --strictPortThen point Capacitor at the Mac LAN URL from another terminal:
PAYKY_CAPACITOR_SERVER_URL=http://<mac-lan-ip>:5173 bun run cap run iosThe iPhone and Mac must be on the same network. iOS does not have an adb reverse equivalent, so a physical device cannot use localhost to reach the
Mac dev server.
The Android release build signs with payky-release.keystore. Set these
environment variables before running bun run cap:android:build:
PAYKY_ANDROID_KEYSTORE_PASSWORD=...
PAYKY_ANDROID_KEY_ALIAS=...
PAYKY_ANDROID_KEY_PASSWORD=...Run the full validation suite:
bun run checkRun checks individually:
bun run check:lint
bun run check:ts
bun run check:decisions
bun run check:tests
bun run check:coveragecheck:decisions verifies that every business decision in docs/decisions
names the tests that hold it. End-to-end tests are not part of check; run
them with bun run test:e2e.
Format files with Biome:
bun run formatRun Vitest in watch mode:
bun run test:watchapps/web-app/src/main.tsxis the browser entry point.apps/web-app/src/App.tsxwires top-level providers and the TanStack Router provider.apps/web-app/src/routescontains file-based route definitions.apps/web-app/src/components/uicontains reusable shadcn-style UI primitives built on Base UI.apps/web-app/src/i18ncontains the English, Czech and Slovak translations, one<lang>/<group>.tsfile per group of keys.packages/core/src/evolucreates the Evolu client and composes the application schema.packages/core/src/modulescontains domain modules for accounts, transactions, catalog items, bills, payments, tables, reconciliation claims, settings, devices, and Fio integration.apps/cli/srccontains CLI commands for local data management and background jobs.apps/native-shellowns the Capacitor configuration and Android/iOS projects.bincontains repository checks and release scripts.
Local UI components live in apps/web-app/src/components/ui and should be imported directly
from their owning module:
import { Button } from "@/components/ui/button.tsx"When adding shadcn components, use Bun:
cd apps/web-app
bunx shadcn@latest add buttonKeep generic reusable UI in apps/web-app/src/components/ui; feature and domain logic should
live outside that directory.
Persistent application data is stored through Evolu. Register new tables and
indexes in packages/core/src/evolu/schema.ts, and keep domain code in the owning module
under packages/core/src/modules.
Domain modules generally use this structure:
*-types.tsfor branded ids, enums, unions, and exported domain types.- Module root files, such as
payment.ts, for Evolu table schemas and row exports. *-actions.tsfor Evolu mutations and command-style operations.*-queries.tsfor reusable Evolu queries and read models.*-utils.tsfor pure helpers.*.test.tsbeside the module it covers.
Each device account has a 128-bit master secret S (the MasterKey). For a
path P, derive a child key and independent 32-byte entropy:
R = BIP32.MasterKey(S)
K = BIP32.Derive(R, P)
E = HMAC-SHA512(
key = "bip-entropy-from-k",
message = K.privateKey
)[0:32]
Payky paths follow BIP-85
with the BIP-39 application: m/83696968'/39'/{language}'/{words}'/{index}',
language 0' = English. The word count encodes how much of E the consumer
uses (24 words = 32 bytes, 12 words = 16 bytes). The paths must never change
after accounts exist.
| Consumer | Path P |
Result |
|---|---|---|
| Cashu wallet seed (shared with Linky) | m/83696968'/39'/0'/24'/0' |
E → 24-word BIP-39 → mnemonicToSeed(m, ""), the 64-byte NUT-13 seed |
| Evolu master owner | m/83696968'/39'/0'/24'/1' |
E as the 32-byte Evolu owner secret |
| Default Spark wallet | m/83696968'/39'/0'/12'/0' |
E[0:16] as the 16-byte Spark wallet secret |
| Linky app (meta) owner | m/83696968'/39'/0'/24'/1'/0' |
E[0:16] → 12-word BIP-39 → Evolu mnemonicToOwnerSecret; its cashu shard owners hold the shared Cashu wallet |
The Spark wallet secret is stored as hex and used as BIP-39 entropy: wallet
initialization and the settings UI encode it as a 12-word mnemonic (never the
raw secret), so the wallet can also be restored in any BIP-39-compatible Spark
client. The Cashu seed and the Linky owner are Linky's own derivations
(packages/identity/src/derivationPaths.ts there), so a master key moved
between the two apps opens the same Cashu wallet; the cross-app vectors in
key-derivation-cross-app.test.ts pin them.
S itself is backed up as a single SLIP-39
20-word recovery mnemonic (packages/core/src/modules/shared/key-derivation.ts, via
the slip39-ts library), encoded as one group with a 1-of-1 threshold — there
is currently no multi-share Shamir splitting, so the phrase is the sole backup
of S and must be treated with the same care as a BIP-39 seed phrase. SLIP-39
mnemonics use their own wordlist and checksum and are not interchangeable with
BIP-39 mnemonics. The mnemonic's identifier (SLIP-39's 15-bit metadata field)
is derived deterministically from S via HMAC-SHA512 rather than randomized,
so encoding the same S always produces the same recovery phrase. 256-bit
master keys and their 33-word recovery mnemonics are not supported.
The CLI reads .env files automatically. Environment variables are validated at
startup with @t3-oss/env-core and Zod.
PAYKY_DATA_DIR=./.data bun run cli payments list
bun --env-file=.env.cli apps/cli/src/cli.ts accounts listSupported variables:
PAYKY_DATA_DIR: the directory of the CLI's database,payky.db, on Evolu's own SQLite (@evolu/nodejs, prebuilt for macOS and Linux). Defaults to.dataat the repository root. An explicit relative path is resolved against the caller's working directory.PAYKY_AI_BASE_URL: OpenAI-compatible endpoint. Defaults to Payky's AI proxy,https://payky.me/api/ai/v1, which holds the provider and its key and takes the account's owner id as its key (docs/decisions/ai/0001). Point it at a local Ollama,http://localhost:11434/v1, to develop without it.PAYKY_AI_MODEL: model name for an endpoint other than the proxy, which picks its own; it must support tool calling.
bun run cli replaces bun bin/cli.ts. The launcher preserves the caller's
working directory. payky ai is a prototype assistant: it reads
the local data, and the docs and source code git tracks. It never writes.
bun run cli ai "Which bills are still open?"
PAYKY_AI_BASE_URL=http://localhost:11434/v1 PAYKY_AI_MODEL=qwen3 \
bun run cli ai "How does bill coverage work?"All user-facing React text goes through t(key). Add each key to the group file
its namespace belongs to — apps/web-app/src/i18n/en/<group>.ts, then the same file under
cs/ and sk/ — and use stable, feature-scoped names such as bill.save,
catalog.items.title, or activity.empty. bun run check:ts fails while a
key is missing from any language.


