Estimator Tools
TechNext · Casa Escondida Estimator Tools · for developers

Estimator Tools technical docs

For developers joining the project: the stack, how the app runs, the life of a request, every /api/* route, the data model, security, client architecture, environment variables, testing, deployment, the key decisions and the working conventions.

Updated 25 Sep 2026 · source: the tn-casa-quotation-estimator repo (bff/, contracts/, supabase/, scripts/, docs/) · product docs: /estimator-docs

Part 1Stack and repo layout

LayerTechnologyNotes
RuntimeNode ≥ 24, TypeScript 5.9, npm workspacesNo Python, no global installs
ServerHono 4 · zod 4 · postgres (postgres.js)The BFF is the only Odoo caller
ClientReact 19 · Vite 8 · Tailwind CSS 4Hand-rolled router, no library
DataSupabase Postgres (local Docker) · RAM storeRLS on, no policy
Odoo contractOpenAPI pinned · openapi-typescript · openapi-fetch · Prism mocknpm run spec:check -w contracts flags a spec change
TestsVitest 5 (projects server + app) · Testing Library · jsdomReal fixtures, never invented Odoo responses
tn-casa-quotation-estimator/
├── contracts/            @casa/contracts — Odoo spec pinned + zod + generated types
│   ├── odoo/estimate-api.v1.json   pinned OpenAPI (SOURCE.md records when)
│   ├── odoo/examples/              captured Odoo responses = fixtures
│   └── src/trip.zod.ts             TripShape: the only Trip parser
├── bff/                  @casa/bff — one Vercel project
│   ├── api/index.ts                Vercel Function entry (hono/vercel)
│   ├── dev-server-entry.ts         same app inside Vite (dev:app)
│   ├── dev.ts                      API alone on :8787 (curl, AI team)
│   ├── src/app.ts                  createApp(): middleware + routes
│   ├── src/routes/                 estimates · share · submit · auth · session · settings · boats
│   ├── src/auth/                   cookies · owner · credential · key-crypto · rate-limit · fixture/odoo auth
│   ├── src/odoo/                   estimate gateway · call (timeout, retry) · breaker · cache · fixture
│   ├── src/store/                  DraftStore: memory.ts | pg.ts (same contract test)
│   ├── src/trip/                   fill · validate · derive · key · to-odoo
│   ├── src/model/                  sane (post-check) · redact (by role)
│   ├── app/src/                    React 19 SPA: pages · components · lib · i18n
│   ├── vercel.json · vite.config.ts · vitest.config.ts
├── ai/                   @casa/ai — extractor, parked (P5)
├── supabase/migrations/  4 SQL migrations, RLS on, no policy
├── scripts/              suite.mjs · suites.json · test-record.mjs · qa-accounts.mjs
└── docs/                 flows/ · ledgers/ · product/ · superpowers/ (spec, plans) · integration/

Part 2Runtime topology

One project, two halves

Runtime topology One Vercel project serves the static SPA and a single Node function for /api/*, which calls Odoo and optionally Postgres; on a developer machine one Vite process serves both the UI and the same Hono app, usually against captured Odoo fixtures. PRODUCTION · VERCELDEVELOPER MACHINEHTTPSHTTPS · api-key · 8 sDATABASE_URL (optional)local Docker · 54322FIXTURE_MODE=1localhost:5173USERBrowserSPA + cookiePROJVercel project · root bff/app/diststatic SPA · rewritesapi/index.tsFunction · 15 s maxDEVnpm run dev:app -w bffVite :5173UI + HMRHono in-processsame createApp()ODOOOdoo stagingestimate-api v1real mode onlySTORESupabase Postgreslocal Docker todayno DB → RAM storeFIXTURECaptured Odoo JSONcontracts/odoo/examplesLEGENDdeployable unitfixture: no Odoo callnetwork to Odoooptional
Production: a static SPA plus one Vercel Function for /api/*. Developer machine: one Vite process serves the UI and the very same Hono app, usually on fixtures.
  • bff/vercel.json: buildCommand: npm run build:app, outputDirectory: app/dist, function api/index.ts with maxDuration: 15.
  • Rewrites: /api/(.*) → /api/index; every other path except api/ and assets/ → /index.html for the SPA router.
  • Three entry points build the same app: api/index.ts (Vercel), dev-server-entry.ts (via @hono/vite-dev-server), dev.ts (API only, port 8787). All three run loadEnv → selectGateway → selectStore → selectAuth → createApp; settings are read once at cold start.
  • npm run dev:app -w bff: Vite hands /api/* to Hono in the same process; router paths are listed in exclude so a refresh never 404s.

Part 3Life of a priced request

POST / PATCH /api/estimates

Life of a priced request Pipeline of POST and PATCH /api/estimates: resolve the owner, pick the Odoo credential, fill and validate the trip, derive guestType, call Odoo compute once, sanity-check the model, redact it by the role Odoo used, then store it; the fill and validate steps can exit with 422 before any Odoo call. Odoo422 · fields422 · code + fields502 · 503 · 5041resolveOwnercookie → owner2credentialForguest key | session key3fillTrip6 required groups4validateTrip7 rules, both ends5deriveTripguestType · daysAhead6gateway.computeapi-key · 8 s · breaker7checkComputeSanerevenue sanity8redactForRoleby response.role9storeworking model · snapshot→ 200 / 201model · issues · computedAtLEGENDpure step, no networkthe one Odoo callredaction before any writeearly exit, nothing written
Every red branch before compute returns 422 with no Odoo call and nothing written. Redaction runs before the write, so a guest snapshot never holds cost.
#FunctionWhat it doesFile
0requestId · resolveOwnerGlobal middleware: attach a requestId, read ubg_auth then ubg_sid; read-only, never issues a cookieapp.ts · auth/owner.ts
1ensureOwnerOnly write routes issue ubg_sid when missingauth/owner.ts
2credentialForguest → ODOO_KEY_GUEST; signed in → decrypt the session keyauth/credential.ts
3TripSchema.parse · fillTripzod drops unknown keys; six required groups → 422 {fields}trip/fill.ts
4validateTripSeven app rules → 422 {code, fields, issues}; room-empty only warnstrip/validate.ts
5deriveTripForces guestType from the session role (staff keep the payload); computes bookedDaysAhead in Manila timetrip/derive.ts
6gateway.computeapi-key header, 8 s timeout, one retry on 502/503 only, breaker opens for 30 s after 5 failures in 60 sodoo/estimate.ts · call.ts · breaker.ts
7checkComputeSanePost-check for unexpected zero dive / transport / room revenue → issuesmodel/sane.ts
8redactForRoleBy Odoo's response.role, not the session role; strips cost for guestsmodel/redact.ts
9storenewScenario / saveWorkingTrip store the derived trip + redacted model (keyed by tripKey)store/pg.ts · memory.ts

Odoo errors go through app.onError: breaker open → 503, timeout → 504, Odoo 5xx → 502, Odoo 4xx → the same code. Each request writes exactly one log line: requestId, route (no real ids), role, status, odooMs; never the trip, sid, cookie or token.

Part 4API reference

Every route lives under /api/* in one Hono app. Not-yours and not-found both return 404 {error:"not found"}, never 403. The error string is Vietnamese for humans; code branches on the HTTP status, code, reason and fields.

RouteAuthBody / query2xxOwn errorsOdooPurpose
GET /api/healthnone—{ok, mode}—0Liveness, never touches Odoo
GET /api/warmupnone—{warmed, odooMs}always 2001 · cacheWakes Odoo when a page opens
GET /api/meoptional cookie—{role, session, name?, login?}—0Who am I
POST /api/session/guestissues ubg_sid—{role, session:true}—0Get a guest cookie, keep an existing one
GET /api/settingspublic—{display_currency, share_token_ttl_days}—0Public settings
GET /api/settings/allstaff—{settings}4040All settings
PUT /api/settings/:keystaff{value}{settings}404 · 4220Change one setting, reloaded at once
GET /api/ratesany—RatesResponseOdoo errors1 · cache 60 sRate card as returned
GET /api/roomsany?check_in&check_outRoomsResponse4221Rooms and availability
GET /api/boatsany?check_in&check_out{boats}4221 · cache 60 sBoats (fixture: empty)
POST /api/auth/registerpublic · 5/min/IP{name, email, password, role, agency?, attachments?}{login, role, verified}422 · 4291Register through Odoo
POST /api/auth/loginpublic · 5/min/IP{email, password}{role, name, login} + ubg_auth cookie401 · 422 · 429 · 5021Sign in, merge guest drafts
POST /api/auth/logoutsession—{ok:true}—1Revoke the key, clear the cookie
POST /api/auth/forgotpublic · 5/min/IP{email}{ok, message}, always the same422 · 4291Forgot password
POST /api/estimatesissues ubg_sid if missing{trip, ui?}201 {id, seq:0, role, pendingVerification, model, retailModel, issues, computedAt, sample}422 · 502 · 503 · 5041Create a draft, first price
GET /api/estimatescookie?scope=all (staff){items}no cookie → {items:[]}0My trips / every trip
GET /api/estimates/:idowner or staff—{id, status, label, workingTrip, workingUi, working, latest}4040Reopen a draft
PATCH /api/estimates/:idowner{trip, ui?, save}{…, saved}404 · 4221Update price (save:true)
POST /api/estimates/:id/commitowner—{id, seq, snapshotId, role, pendingVerification, computedAt}4040 | 1Save trip: revision + snapshot
GET /api/estimates/:id/revisionsowner—{items:[{seq, createdAt, hasSnapshot}]}4040List versions
GET /api/estimates/:id/revisions/:seqowner—{seq, trip, snapshot}4040One version
POST /api/estimates/:id/shareowner—{url, expiresAt}404 · 4090Get final quote: issue a link
GET /api/share/:tokenpublic; account links need a session—{scenarioId? , seq, trip, model, retailModel, computedAt, sample, currency, label}401 · 404 · 4100The quote page
POST /api/estimates/:id/submitowner with ubg_auth{seq, contact:{name, email, phone?}}{state, folioId, orderIds, sample, seq}401 · 404 · 409 · 422 · 502 · 5031 · no retrySend the reservation once
GET /api/estimates/:id/submissionowner with session—{state, folioId, …} without contact401 · 4040The "Reservation sent" banner

The four 422 shapes on POST / PATCH

CauseBody
Body is not JSON{error}
zod shape error (unknown enum, over a limit, null for an enum){error, fields:["guests[0].courses", …]}
Missing a price-relevant field{error, fields:["diveFrom", "guests[1].roomId", …]}
App rule at error level{error, code, fields, issues:[{code, fields, level}]}

Submit responses

SituationHTTPreasonsubmission row
No ubg_auth session401loginnone, no cookie issued
Not saved / seq mismatch / live row exists409no-snapshot · stale · alreadyno new row
Gate closed (live, flag off)503closednone
Odoo accepts200—confirmed
Odoo error / success:false502rejectedfailed
Breaker open, nothing sent503busyfailed
Timeout / network error502unknownunknown

The full contract for the AI channel (Trip, rules, EstimateModel, sample curl): docs/integration/schema.md.

Part 5Data model

Draft store: seven tables

Draft store data model Seven Postgres tables: a scenario owns revisions, each revision has one frozen snapshot, share tokens and submissions hang off the scenario, user sessions link to scenarios only by an owner string, and app settings stand alone. 1N111NGET QUOTE1NSENT VERSIONN1SEND RESERVATIONNO FKTABLEuser_session# id (16B b64url)roleodoo_loginodoo_key_enc bytearevoked_atTABLEscenario# id uuidowner_role · owner_refstatus draft|submitted|expiredworking_trip · working_uiworking_model jsonbexpires_atTABLErevision# id→ scenario_idseq unique per scenariotrip jsonbauthorTABLEsnapshot# id→ revision_id uniquemodel jsonb (redacted)retail_model jsonbrole · sampleTABLEshare_token# token_hash sha256→ scenario_idrequires_loginexpires_at · revoked_atTABLEsubmission# id→ scenario_id · snapshot_idstatecontact jsonb (PII)folio_id · order_idsone live row / scenarioTABLEapp_setting# keyvalue jsonbupdated_atLEGENDaggregate roottablestring reference, no foreign keyRLS on every table, no policy
A draft can change, a version cannot. An account's owner_ref is the string odoo:login, with no foreign key to user_session.
TableHoldsIndexMigration
scenarioOne trip. owner_role (guest|agent|instructor|staff), owner_ref (sid or odoo:login), source, status (draft|submitted|expired), label, working_trip, working_ui, working_model + retail + key + role + sample + computed_at, expires_atscenario_owner_idx (owner_role, owner_ref, updated_at desc)0922 · 0923 · 0924
revisionOne Save: scenario_id, seq, trip, author (human|ai)unique (scenario_id, seq)0922
snapshotFrozen model of a revision: model (redacted), retail_model, role, sample, rates_version, computed_at, odoo_msunique (revision_id)0922 · 0923
share_tokenQuote link: token_hash (sha256), scenario_id, requires_login, expires_at, revoked_atshare_token_scenario_idx0922 · 0923
app_settingkey / value jsonb; seed: draft_ttl_days 90, raw_text_ttl_days 30, share_token_ttl_days null, display_currency PHPpk key0922
user_sessionSigned-in session: id (16 bytes), role, odoo_login, display_name, odoo_key_enc (AES-256-GCM), key_expires_at, last_seen_at, revoked_atuser_session_login_idx0923
submissionOne send: scenario_id, revision_seq, snapshot_id, state, contact (PII), folio_id, order_ids, error, sample, submitted_by_*submission_one_live (partial unique) · submission_scenario_idx0925
  • RLS is on for all seven tables with no policy: anon/authenticated read 0 rows through PostgREST; only the BFF connects, over DATABASE_URL (service role).
  • submission_one_live: unique on scenario_id where state in (pending, confirmed, unknown); the pg store catches 23505 on that exact index name to answer already.
  • DraftStore (bff/src/store/types.ts) has two implementations under one test suite; latestSnapshot is the only owner-agnostic read (for public links) and is only called after getScenario in private routes.
  • Apply new migrations without data loss: npx -y supabase@latest migration up; db reset drops and re-applies everything.

Part 6Auth and security

From cookie to price card

Auth and role resolution Flowchart: a signed-in cookie resolves to a stored session whose Odoo key is decrypted, otherwise the guest service key is used; the BFF forces guestType from the session role, Odoo prices with the key, and the model is redacted by the role Odoo actually used before it reaches the price card. YESNOapi-keymodel + rolepending if roles differRequest/api/*Signed in?ubg_authSESSIONuser_sessionHMAC id → live rowdecrypt key · AES-GCMGUESTGuest service keyubg_sid or no cookieODOO_KEY_GUESTDERIVEForce guestTypefrom session rolestaff keeps payloadODOOcomputerate card of the keyreturns response.roleREDACTredactForRoleby response.rolecost never to guestPrice cardrole rate cardLEGENDanonymous pathBFF stepOdoo decides the pricelast guard before the browser
The Odoo key decides the role; the app only aligns the requested role with the session and redacts by the role Odoo returns.
TopicHow
Cookiesubg_sid (guest) and ubg_auth (session) as <id>.<hmac>, HMAC-SHA256 with SESSION_SECRET, compared with timingSafeEqual; HttpOnly, SameSite=Lax, Path=/, Max-Age 30 days, Secure in production. A bad signature counts as no cookie, not a 401.
Odoo keysThe key Odoo issues at login is stored in user_session.odoo_key_enc, AES-256-GCM with an HKDF-SHA256 key derived from SESSION_SECRET and a fresh nonce each time. Decrypted only in request memory; never in logs, responses or snapshots. Rotating SESSION_SECRET signs everyone out.
PasswordsPassed to Odoo once, never stored or logged. Wrong password and unknown email share one message; forgot always answers the same.
Rate limitregister / login / forgot: 5 per minute per IP, a fixed in-memory window (each Vercel instance counts separately: a rough brake only).
RedactionredactForRole uses Odoo's response.role and runs before the snapshot is stored; the quote page redacts again by the viewer's role. pendingVerification = session role ≠ guest && response.role = guest.
404, never 403Someone else's draft and a missing id return the same 404 {error:"not found"}: a 403 would confirm the id exists. Staff routes return 404 to non-staff; a non-staff ?scope=all is ignored.
Share tokens32 random bytes base64url (43 chars); the DB keeps only sha256; TOKEN_SHAPE rejects before hashing; expired returns 410; an account link returns 401 reason:"login" to anonymous viewers.
Submit gatePOST /api/estimates/:id/submit calls Odoo only with ODOO_SUBMIT_ENABLED=1, or when both env and gateway are fixture. Off → 503 closed, nothing written. Every live call creates a real folio.
LoggingOne line per request, maskPII; never the trip, sid, cookie, token, contact or setting values.

Part 7Client architecture

  • Router: App.tsx reads window.location.pathname and picks a page; no library. / · /quotes · /quote/:token · /trip/:id[/estimates|agent|ops] · /trip/:id/reserve · /trip/:id/booking · /signin · /register · /forgot · /ops · /settings.
  • TripWorkspace: after Plan my trip every screen of the trip sits in one frame with TripTabs; lib/tabs.ts is a pure tabsFor(role), an unknown role falls back to the narrowest set. Tabs share one useTrip; switching tabs makes no Odoo call.
  • useMe: /api/me once per page load, a module-level promise cache; resetMeCache() on sign in / sign out.
  • api.ts: one function per route; every priced call yields success | invalid | unavailable; an abort is not an error; JS never reads cookies.

Price card state machine (useTrip, F05 D5)

Price card states in useTrip State machine of the Result page price card: it opens as priced or outdated, edits make it outdated without a request, Update price moves through pricing to priced, invalid or unavailable, and only priced can be saved. has modelnoneeditUpdate pricevalidateTrip rededit2004225xxTry againSave tripseq + 1STATEpricedSave + share allowedSTATEsavingPOST commitSTATEloadingGET /api/estimates/:idSTATEoutdatedamber · 0 requestsSTATEpricingone PATCH in flightSTATEinvalidfield hintsSTATEunavailableOdoo busy404 → notfoundLEGENDpage opensthe only state that can Savesuccessful computeOdoo error
Opening never prices; editing never prices; only priced can Save, and Get final quote / Send also need savedIsCurrent.

Tabs by role (lib/tabs.ts)

Trip workspace tabs by role Matrix of the five trip workspace tabs against four session roles: everyone sees Trip and Guest Estimates, agents also see Agent View, and only staff see Ops Sheet and Settings. TAB SHOWN PER SESSION ROLETrip & Guests/trip/:idGuest Estimates/estimatesAgent View/agentOps Sheet/opsSettings/settings · globalAnonymous · guestubg_sid or noneInstructorOdoo keyAgentOdoo keyStaffOdoo keyLEGENDtab visibletab hiddenAgent View: agents and staff only
Hiding a tab is cosmetic; the server is still the real gate.
  • i18n: i18n/en.ts (source) and i18n/zh.ts; t(key, vars) through a context; the no-hardcoded-copy test blocks hard-coded strings in components.
  • Theme: Tailwind 4 tokens in the @theme block of index.css (navy #04080F, accent #4DC2E8, Playfair Display + DM Sans); the day theme overrides via [data-theme="day"] on <html>, stored in localStorage; print is always black on white.
  • validateTrip on the client mirrors the server rules, kept in step by a parity test; the client never does maths on money.

Part 8Environment variables

Read by loadEnv() (bff/src/env.ts, zod) from process.env. Real values live only in bff/.env.local (git-ignored) and in Vercel env; this page lists names only.

NameShapePurposeWhen
FIXTURE_MODE0 | 11 = use captured Odoo responses, no networkall
SESSION_SECRET≥ 32 charsSigns cookies and derives the key that encrypts Odoo keys; required even in fixturerequired
DATABASE_URLurlDraft store Postgres; absent → RAM store + one warningoptional
ODOO_BASE_URLurlThe estimate-api base URLrequired when FIXTURE_MODE=0
ODOO_KEY_GUESTstringGuest-role service key for anonymous visitorsreal mode
ODOO_KEY_AGENT · ODOO_KEY_STAFFstringLegacy per-role keys, optional; signed-in users use their own keyoptional
ODOO_API_KEY_HEADERapi-keyOdoo auth header namedefault
ODOO_TIMEOUT_COMPUTE_MS8000Time budget for compute, retries includeddefault
ODOO_TIMEOUT_SUBMIT_MS20000Timeout for booking/submitdefault
ODOO_SUBMIT_ENABLED0 | 1Safety gate for live submit; only with the lead's and the Odoo side's consentdefault 0
NODE_ENVproductionTurns on the Secure cookie flagVercel

Part 9Testing

  • Vitest 5, two projects in bff/vitest.config.ts: server (node, test/**/*.test.ts) and app (jsdom + React, app/test/**).
  • Phase suites: scripts/suites.json maps globs for p0, p1, p2, p3, p4; run npm run suite -- p1 or all. The runner expands globs itself (Vitest positional args are substring filters, not globs).
  • test:record: npm run test:record -- --suite all --label "…" runs typecheck + suite, writes one row to docs/ledgers/test-log.md and a detail file in docs/ledgers/test-runs/; exits 1 when red but still records.
  • Real fixtures: tests that pin Odoo behaviour use files in contracts/odoo/examples/; a stale fixture is recaptured, never hand-edited.
  • Store contract test: one suite for memory and Postgres; the Postgres half is skipIf(!DATABASE_URL). The p1/p3 Postgres tests truncate a shared DB and collide in parallel (B-026): use --no-file-parallelism.
  • Run one file: npm exec -w bff -- vitest run <path> (not vitest's -w, which means watch).
  • Latest all run (25 Sep 2026): 801 pass, 0 fail, 39 skipped.

Part 10Deployment

Vercel settingValue
Root Directorybff
Include source files outside of the Root DirectoryON (bff imports @casa/contracts and fixture JSON from the root)
Preview + DevelopmentFIXTURE_MODE=1, SESSION_SECRET
ProductionEmpty until the Odoo side issues keys; SESSION_SECRET required
Check after deploycurl https://<preview>/api/health → {"ok":true,"mode":"fixture"}

Pending

  • Linking the Vercel project (lead). Never vercel --prod without the lead.
  • Supabase cloud: the free tier is full, so it runs in local Docker; without DATABASE_URL on a preview every draft dies when the lambda recycles.
  • Real keys for real mode, a staff account, permission to submit on staging.
  • Settings reload only in the instance that served the PUT; others keep the old values until a cold start (B-027).

Part 11Key decisions

Taken from docs/ledgers/lessons.md (40 D-0xx decisions, 25 L-0xx bugs). Each has a "price if wrong" in the ledger.

#Decision
D-004Real fixtures captured from staging, never invented mocks: invented mocks hid the silent L-001 failure.
D-007Auth is entirely Odoo's; the app has no user table.
D-008Anonymous guests share one guest service key; each agent and staff member has their own key.
D-010Policies that change are shared settings staff edit without a deploy.
D-011Guest quotes are public; agent quotes need sign-in.
D-015Share tokens are stored as sha256 only; links point at the scenario, not a revision.
D-016Without DATABASE_URL the RAM store runs, with a warning only.
D-018Odoo keys are stored server-side with AES-256-GCM, no JWT of our own.
D-019Redact by Odoo's response.role, not the session role.
D-022The last model is stored next to the working trip; Save reuses it when tripKey matches, 0 Odoo calls.
D-028Flow A dropped live pricing: Odoo only gets compute on Plan my trip and Update price.
D-031Staff read every scenario through dedicated functions; a non-staff scope=all is ignored, not a 403.
D-033The guestType sent to Odoo is forced by the server from the session role; bookedDaysAhead is computed server-side.
D-034Validation rules are written once on the server; the client mirrors them, held by a parity test.
D-036The submission row is written as pending before the submit call and is the idempotency lock itself.
D-037Submit errors are classified by whether the request left the BFF: Odoo answered → failed, timeout → unknown, breaker → failed.
D-040Send and Get final quote open only when the latest saved version is the trip on screen (savedIsCurrent).

Part 12Working conventions

  • TDD: red test → green code → refactor, test committed with the code. Before "done": npm run typecheck + a green suite. A test that passes against unchanged code is worthless.
  • Four ledgers: Flows (docs/flows/Fxx, before coding), Logbook (when a task finishes), Lessons (on every bug or trade-off), Test-log (automatic). A task without a Logbook row is not done.
  • Diagrams before tasks: a plan that touches data flow has a Diagrams section before its task list. Changing an existing flow amends its figure with NEW / CHANGED; it is never redrawn from scratch.
  • Commits: Conventional Commits (feat|fix|hotfix|docs|chore|refactor|test), format checked by a hook (npm run hooks). Never push to main: branch, PR, one human approval, squash.
  • Red lines: never call booking/submit on staging without an order; never touch pricelists, rates/manifest, PATCH rates; never commit a secret; always send the full Trip.
  • Casa policy is never decided in code: log a Q-0xx, make it configuration, mark // Q-0xx pending.
  • Language: UI copy is English first through the dictionary with 中文 available; internal docs are Vietnamese.