# e-taxes.gov.az Cabinet Internals — Operational Notes
Persistent notes for future Claude sessions about the ГНС cabinet (new.e-taxes.gov.az).
Add to this file when you learn something non-obvious. Keep entries dated.
---
## High-level architecture (verified 2026-05-26)
Two separate SPAs under one domain:
| Path prefix | Mount point | Purpose | webpackJsonp name |
|---|---|---|---|
| `/etaxes/` | `
` | Public services (lookup taxpayer, debt query, etc.) | `webpackJsonpetaxes` |
| `/eportal/` | `
` | **Cabinet for authenticated users — declarations live here** | (varies) |
Both: React + Redux Toolkit + RTK Query, CRA-ish bundle layout (`static/js/N.HASH.chunk.js`).
Source maps NOT exposed.
**Pitfall:** the cabinet's mount point is `#app`, NOT `#root`. A snippet that walks fibers from `#root` will return `hasRoot: false`.
---
## Auth — how the cabinet checks login
REST (Python `requests`) — **simple**: send `x-authorization: Bearer
` header. Works for every documented endpoint. No cookies needed, no session.
Browser SPA — **NOT** simple. Just injecting `x-authorization` into outgoing requests via Playwright's `route` is **NOT enough**. The SPA has a route guard that checks **Redux state** for the auth token. If Redux is empty → redirect to `/eportal/login`, regardless of what's in HTTP headers.
Verified storage usage (grepped eportal bundle):
- `localStorage` — only `surveyModal` (popup tracking) and `aztax-lang`. **No auth token**.
- `sessionStorage` — empty after fresh page load.
- Cookies — **none set** by any cabinet endpoint (verified `Set-Cookie` headers: empty on all responses including `/chooseTaxpayer`).
**Implication:** to drive the cabinet headlessly you must EITHER:
1. Login through the SPA UI (PIN1 push to phone), OR
2. Inject the token into the Redux store via `page.evaluate()` after the SPA mounts. Action type is dynamically constructed in Redux Toolkit — grep `*-api`, `Slice/` patterns to find it. Untested as of 2026-05-26.
Easier in practice: UI login + Playwright's `storage_state` to persist cookies+localStorage between runs (note: storage_state is mostly empty here, so reuse value is limited — the actual auth lives in in-memory Redux, which Playwright cannot serialize).
### REST ASAN flow (works without browser)
```
POST /api/po/auth/public/v1/asanImza/start body={"phone": "+994...", "userId": "..."}
→ returns 200 with bearer_token in x-authorization response header
→ response body: {"verificationCode": "1234"} (shown on user's phone screen)
POLL GET /api/po/auth/public/v1/asanImza/status headers={x-authorization: Bearer }
→ returns {"successful": false, "error": null} while waiting
→ when user approves PIN1, eventually returns successful: true (or just becomes silently true)
→ BETTER: poll /asanImza/certificates instead — when it returns 200 with non-empty list, you're authed
GET /api/po/auth/public/v1/asanImza/certificates
→ returns [{taxpayerType: "individual"|"legal", legalInfo|individualInfo, ...}, ...]
POST /api/po/auth/public/v1/asanImza/chooseTaxpayer body={"ownerType": "legal", "legalTin": "VOEN"}
→ returns 200 with main_token in x-authorization response header
→ main_token JWT validity: 10 min (exp = iat + 600s)
```
**Pitfall — polling format:** my first explorer.py polled `data.get("status")` but the actual field is `data.get("successful")` (bool). Wrong field name → silent infinite loop. Now using "did certificates endpoint return non-empty data?" as success signal — more robust.
**Pitfall — ГНС polling latency:** user approves PIN1 quickly, but ГНС server takes several seconds (5-30s observed) to flip `successful: true`. Use timeout >= 90s with 2s interval.
### Renewing tokens
`RENEW_URL = "https://new.e-taxes.gov.az/api/po/auth/public/v1/renew"` — used by `taxes_az/auth.py:renew_token`. Returns 401 if token expired without recent activity. There's an `ACTIVITY_TIMEOUT_MINUTES = 10` gate in the project code.
---
## Login form (UI flow, /eportal/az/login)
Initial page: `/eportal/az/login` — shows 5 method tiles. Click "Asan İmza" link (`` with text "Asan İmza", class `login-item-link`) → SPA navigates to `/eportal/az/login/asan` with form.
ASAN form selectors (verified 2026-05-26):
| Selector | Field | Notes |
|---|---|---|
| `#phone` | Phone (type=text) | placeholder `00 000 00 00` — enter LOCAL format only (e.g. `558239674`), **without** +994 |
| `#userId` | User ID (type=password) | The Asan İmza user ID, e.g. `171082` |
| `#loginPageSignInButton` | Submit (type=submit) | Text: `Daxil ol` |
Page also has a language `` (`#rc_select_0` Ant Design) and footer link.
After submit, SPA shows verification code (matching what's on phone) and polls status. After PIN1 approval, SPA shows certificate picker (multi-cert users) — need to click the right taxpayer.
### Full UI login URL flow (verified 2026-05-26)
```
1. /eportal/az/login (login methods tiles)
↓ click "Asan İmza"
2. /eportal/az/login/asan (phone + userId form)
↓ fill + click #loginPageSignInButton
3. /eportal/az/login/asan/otp (verification code shown, e.g. "4814"; SPA polls /asanImza/status)
↓ user approves PIN1 on phone (~5-30s delay)
4. /eportal/az/login/asan/certificates (cert picker — list of taxpayers)
↓ click row matching desired entity (e.g. div:has-text("JEY SOFT"))
5. /eportal/ (cabinet home — authenticated!)
```
**Cert picker selector that works:** `div:has-text("JEY SOFT")` clicked the right row.
**Note on timing:** the SPA does NOT navigate URL when going from `/otp` to `/certificates` — it does the route change AFTER PIN approval. So a wait-loop should watch for `/certificates` OR `/eportal/$` URL, not just any URL change.
### Forced wizard redirect on declaration entry
After login, navigating directly to a wizard step like `/eportal/declaration/type/property/2025.1/appendix-1?reg-number=N` causes SPA to **force-redirect to `/confirmation`**. The wizard requires passing the confirmation step first. The goto throws a networkidle timeout (60s) before settling — catch and continue.
**Verified 2026-05-26:** wizard navigation has TWO different behaviors:
- `page.goto('/calculation')` from `/confirmation` → SPA redirects back to `/confirmation`.
- Clicking the "İrəli" (Next) button after picking required radio → SPA navigates one step forward.
So you **must walk through the wizard step-by-step via "İrəli" clicks**, not via URL.
### Next button text (verified 2026-05-26)
The "next step" button has text **`İrəli`** (= Forward). Click it with `button:has-text("İrəli")`. It's disabled until required inputs are filled (e.g. radio on confirmation step).
There's likely also a "Geri" button (= Back).
### Cabinet storage_state
After login, Playwright's `ctx.storage_state()` returns: `cookies=0, origins=1`. The `origins` entry has `localStorage` with only UX prefs (`aztax-lang`, `X-Request-Id`). **None of this is auth state.** Auth lives in in-memory Redux, which storage_state cannot serialize.
**Implication:** fresh `browser.new_context(storage_state=...)` does NOT restore auth. Re-login is required each browser session. Workaround: keep one browser context alive across operations, OR find Redux action types to inject token programmatically.
---
## Cabinet Redux store shape (declaration view)
When inside a declaration (e.g. Property Tax 2025.1), the store's top-level keys include:
**UI state slices:**
- `declSlice` — wizard UI state. Key sub-fields:
- `stepData` / `originalStepData` — array of 5 wizard steps (id, link, componentName, name, errorBlock)
- `formData`, `headerData`, `tabData` — these are `false` until SPA mounts data-entry step
- many `isLoadingOf...`, `isVisible...` booleans
- `subModuleCommunicationSlice` — wizard control (save/submit loading flags, violations list)
**RTK Query API slices** — actual server data lives here, keyed by `endpointName(args)`. For declarations:
- `declaration-api.queries['regDeclarationInfo({"regNumber":"..."})']` — declaration metadata
- `declaration-api.queries['getDeclarationBaseData({"regNumber":"..."})']` — **THE FORM JSON**, in `.data.baseJson` as a STRING (JSON.parse it)
- `declaration-api.queries['getPrefillData({"regNumber":"..."})']` — prefilled values from previous declarations
- `declaration-api.queries['getControlMessages({"maxCount":1000,"offset":0,"regNumber":"..."})']` — validation messages (server-side, separate from client-side calc)
Many other `*-api` slices exist (admin-api, hr-api, karg-api, ...) — irrelevant to declaration flow.
**Pitfall:** `declSlice.formData` is `false` (not an object) during navigation. The real form data sits in `declaration-api.queries[getDeclarationBaseData(...)].data.baseJson` — that's what gets PUT'd back to `/draft/base` on autosave.
### Snippet — find Redux store from a cabinet page
```javascript
// Paste in DevTools Console after navigating to a cabinet page (e.g. declaration form)
(function(){
var app = document.getElementById('app');
function walk(fiber, depth) {
if (!fiber || depth > 500) return null;
var p = fiber.memoizedProps;
if (p && p.store && p.store.dispatch && p.store.getState) return p.store;
return walk(fiber.child, depth + 1) || walk(fiber.sibling, depth + 1);
}
var props = Object.keys(app);
var ck = props.find(function(k){ return k.indexOf('__reactContainer') === 0; });
if (!ck) return console.log('No __reactContainer key');
var store = walk(app[ck].stateNode.current, 0);
window.__store = store;
console.log('store keys:', Object.keys(store.getState()));
})();
```
---
## Server-side computation — NONE
**Critical finding (verified 2026-05-26):** the cabinet server stores but DOES NOT CALCULATE. All formulas (sums, totals, tax computation) happen client-side in JS.
Evidence:
- `PUT /api/po/declaration/public/v1/draft/base` response body: `{"saved":true}` (14 bytes), always.
- `/control/messages` returns validation messages only (empty `{"hasMore":false,"messages":[]}` for a clean declaration).
- `/push/sse/subscribe` is a heartbeat (`event:ping data:Check Life`) — **does NOT push computed values**.
**Implication for integration:** to replicate cabinet behavior, you either
(a) re-implement formulas in your own code, OR
(b) run the cabinet's JS bundle in a real or simulated browser to compute, OR
(c) skip computation entirely and POST raw inputs to /draft/base (server will store them — but the user's declaration will be wrong).
---
## Property Tax Return (taxCode `_0500`, version 2025.1)
5 wizard steps:
1. `confirmation` — landing page, period+activity type selection
2. `calculation` — main tax calculation (computed)
3. `appendix-1` — **non-taxable property** (`taxFree`), 23 rows of indicators `_1199` through `_1215`
4. `appendix-2` — **taxable property** (`taxable`), 11 rows of indicators `_5001` through `_5012`
5. `review` — final review/submit
### Appendix-1 step (verified 2026-05-26)
URL: `/eportal/declaration/type/property/2025.1/appendix-1`
DOM contains **120 input elements total, ~66 editable** (`type=text`, not disabled/readOnly). All are formatted as **`"0,00"` (Azerbaijani locale, comma as decimal separator)** with placeholder `"0,00"`. Likely Ant Design `InputNumber` components.
Structure approx: 23 rows × 5 columns (`periodStartAmount, createdAmount, removedAmount, endYearAmount, overAmount`) = 115 inputs + ~5 extras in header/footer.
Row labels reference Tax Code (`Vergi Məcəlləsi`) articles: e.g. row 1 = "Vergi Məcəlləsinin 199.1-ci maddəsinə əsasən, azaldılmalı əmlak vergisinin məbləği" (= property tax to be reduced per Tax Code article 199.1). These map to indicator codes `_1199, _1200, _1201, ...` from `appendix1.taxFree[]` array.
**Pitfall — filling inputs:** raw DOM manipulation (`Object.getOwnPropertyDescriptor.set` + `dispatchEvent('input')`) **does NOT** propagate to Redux store. Ant Design `InputNumber` ignores synthetic `input` events. Use Playwright's `locator.fill()` or `page.keyboard.type()` to simulate real keyboard — those work because React's event system processes them as real user input.
### Confirmation step (verified 2026-05-26)
4 visible inputs:
| input | type | semantics |
|---|---|---|
| `[role=checkbox]` | disabled checkbox | "Bəyan ediləcək məlumatım yoxdur" — declare nothing (opt-out) |
| `[value="OBJECT_ESTABLISHED"]` | radio | "Müəssisə il ərzində yarandığı halda" — company established mid-year |
| `[value="OBJECT_FULL_ACTIVITY"]` | radio | "Müəssisə il ərzində tam fəaliyyət göstərdiyi halda" — full-year operation |
| `input[type=text]` | disabled | (header area, non-input) |
Selecting one radio sets `baseJson.objectActivity` to that value. Then there's an **`İrəli`** button (NOT "Davam et" or "Növbəti") to advance to the next wizard step (`calculation`).
### Calculation step (verified 2026-05-26)
URL: `/eportal/declaration/type/property/2025.1/calculation`
Shows COMPUTED summary across whole declaration. All fields read-only. Sections (from JEY SOFT test data, prefilled values shown):
**Hesabat ilinin əvvəlinə** (at beginning of report year):
- 501 — Əmlakın ümumi qalıq dəyəri (total residual value of property): 8 345.70
- 501.1 — Vergiyə cəlb olunan əmlakın qalıq dəyəri (residual value of taxable property): 0.00
- 501.1.1 — Qeyri-kommersiya fəaliyyətində istifadə olunan əmlakın qalıq dəyəri: 0.00
- 501.2 — Vergiyə cəlb edilən əmlakın qalıq dəyəri: 8 345.70
**Hesabat ilinin sonuna** (at end of report year):
- 504 — total residual value: 5 007.42
- 504.1 — taxable residual: 0.00
- 504.1.1 — non-commercial activity property: 0.00
**Artıq ödəmə** (overpayment):
- 505 / 505.1 / 505.2 — overpayment-related, 0.00
**Tax rate:**
- 506 — Vergi dərəcəsi: **1.00** — confirms 1% tax rate for legal entities
**Calculated tax:**
- 509.1 — Vergi ödəyicisi il ərzində fəaliyyət göstərdiyi halda: 0.00
- 510 — Reduced by Tax Code article 199.21: 0.00
**Amount payable:**
- 511.1 — Müəssisə il ərzində tam fəaliyyət göstərdiyi halda: 0.00
The sidebar shows wizard step icons. Click "İrəli" to advance to `appendix-1`.
**Pitfall:** I keep mixing up appendix-1 vs appendix-2. **Appendix-1 = taxFree (non-taxable). Appendix-2 = taxable.** taxFree first because it determines the "exempt portion" before main calculation.
URL pattern: `/eportal/declaration/type/property/2025.1/{step-name}?reg-number={N}`
- e.g. `/eportal/declaration/type/property/2025.1/appendix-1?reg-number=2606170119282100`
JSON structure (from `getDeclarationBaseData.data.baseJson` parsed):
```
{
taxCode: "_0500",
version: "2025.1",
common: {
administrative: { departmentCode, term: { type: "YEAR", year, month } },
payerInfo: { tin, payerType, taxPayerFullName, ... },
declarationInfo: { mainActivityCode, declarationType, attachmentCount, ... }
},
objectActivity: "OBJECT_FULL_ACTIVITY",
taxInformation: {
mainTax: { calculatedTax, taxDegree, shouldBeReducedTax, ... },
prePeriodCalculations: [{ indicator, amount }, ...],
createDateCalculations: [...],
liquidateCalculations: [...],
endYearCalculations: [...],
excessPrices: [...],
calculatedTax: [...],
shouldBePaidTax: [...],
appendix1: {
taxFree: [{ indicator, periodStartAmount, createdAmount, removedAmount, endYearAmount, overAmount }, ...],
createdTotal, periodStartTotal, removedTotal, endYearTotal, overTotal
},
appendix2: {
taxable: [{ indicator, periodStart, onCancelDate, onCreateDate, periodEnd, excessInsured }, ...],
periodStartTotal, onCancelDateTotal, onCreateDateTotal, periodEndTotal, excessInsuredTotal
}
}
}
```
### COMPLETE Property Tax formula map (verified 2026-05-26 via Playwright daemon mapping)
Daemon-based mapping: filled every editable cell on each step with unique values, captured PUT bodies, computed per-cell diffs. **All 96 editable cells covered** (66 on appendix-1 + 30 on appendix-2). 3 distinct formula patterns on appendix-1, 3 on appendix-2, all rows behave identically.
**Appendix-1 (taxFree, 23 indicator rows × 3 editable columns = 66 cells):**
| Input column (per row) | Auto-updates |
|---|---|
| `taxFree[*].periodStartAmount` | `appendix1.periodStartTotal`, `prePeriodCalculations[0].amount`, `prePeriodCalculations[2].amount` |
| `taxFree[*].endYearAmount` | `appendix1.endYearTotal`, `endYearCalculations[0].amount`, `endYearCalculations[1].amount` |
| `taxFree[*].overAmount` | `appendix1.overTotal`, `excessPrices[0].amount`, `excessPrices[1].amount` |
`appendix1. Total` = SUM over all 23 `taxFree[*]` rows. Mirror coefficient = 1.000.
**Appendix-2 (taxable, 10 indicator rows × 3 editable columns = 30 cells):**
| Input column (per row) | Auto-updates |
|---|---|
| `taxable[*].periodStart` | `appendix2.periodStartTotal`, `prePeriodCalculations[0]`, `prePeriodCalculations[1]`, `calculatedTax[0]`, `mainTax.calculatedTax`, `shouldBePaidTax[0]` |
| `taxable[*].periodEnd` | `appendix2.periodEndTotal`, `endYearCalculations[1]`, `endYearCalculations[2]`, `calculatedTax[0]`, `mainTax.calculatedTax`, `shouldBePaidTax[0]` |
| `taxable[*].excessInsured` | `appendix2.excessInsuredTotal`, `excessPrices[1]`, `excessPrices[2]`, `calculatedTax[0]`, `mainTax.calculatedTax`, `shouldBePaidTax[0]` |
`appendix2. Total` = SUM over all 10 `taxable[*]` rows.
**Combined aggregates (calculation step values):**
```
prePeriodCalculations[0].amount = appendix1.periodStartTotal + appendix2.periodStartTotal
prePeriodCalculations[1].amount = appendix2.periodStartTotal
prePeriodCalculations[2].amount = appendix1.periodStartTotal
endYearCalculations[0].amount = appendix1.endYearTotal
endYearCalculations[1].amount = appendix1.endYearTotal + appendix2.periodEndTotal
endYearCalculations[2].amount = appendix2.periodEndTotal
excessPrices[0].amount = appendix1.overTotal
excessPrices[1].amount = appendix1.overTotal + appendix2.excessInsuredTotal
excessPrices[2].amount = appendix2.excessInsuredTotal
```
**THE main tax formula (1% rate for legal entities, JEY SOFT is `payerType: "LEGAL"`):**
```
calculatedTax[0].amount =
mainTax.calculatedTax =
shouldBePaidTax[0].amount =
ROUND(
(appendix2.periodStartTotal + appendix2.periodEndTotal) / 2 * 0.01
+ appendix2.excessInsuredTotal * 0.01,
2 # half-up to 2 decimals
)
```
Equivalent: `tax = ((avg of start+end residual values) + excess insured) × taxRate`, where `taxRate = mainTax.taxDegree / 100 = 1% for legal`.
Note: only `taxable[]` (appendix-2) contributes to calculated tax. `taxFree[]` (appendix-1) values feed into totals/mirrors but NOT into final tax.
### Taxable parent row structure (verified 2026-05-26)
`appendix2.taxable[]` has **11 entries**, but only **10 are user-fillable**:
| Index | Indicator | Role |
|---|---|---|
| 0 | `_5001` | **PARENT "1. Binalar, tikililər və qurğular"** — auto-sums from children |
| 1-6 | `_5002, _5003, _5004, _5005, _5006, _5007` | Children 1.1–1.6 (sub-categories of "Binalar") |
| 7 | `_5008` | Other taxable category |
| 8 | `_5009` | Other taxable category |
| 9 | `_5012` | Other taxable category |
| 10 | `_5010` | Other taxable category |
**Auto-aggregation rule:** `_5001.{col} = Σ _5002..7.{col}` for each column (periodStart, periodEnd, excessInsured).
**Total sum rule:** `appendix2.{col}Total = Σ_{i!=0} taxable[i].{col}` — i.e. **exclude parent _5001 to avoid double-count**.
Verified: server values `periodEndTotal=1085`, `excessInsuredTotal=1155` correspond exactly to sums of indicators 1-10 (excluding `_5001`). Existing implementation `calculate_elave_2_totals` and `calculate_binalar_totals` in `property_tax_return.js` match this behavior **byte-for-byte**.
### Partial-year tax formula (509.2 / il_erzinde) — VERIFIED 2026-05-29
To bypass the modal that blocks switching radios on an existing draft, **create a NEW Dəqiqləşdirilmiş (clarifying) draft** for a closed year (2024 worked) — confirmation step then allows OBJECT_ESTABLISHED radio.
Calculation step in OBJECT_ESTABLISHED mode shows extra editable field:
- **507**: `Müəssisə yarandığı aydan sonra ilin sonuna qədər olan ayların sayı` — integer field, default `0`, stores into `mainTax.monthCountFromStartPeriod`.
In OBJECT_ESTABLISHED mode the appendix-2 editable columns shift — **`onCreateDate, periodEnd, excessInsured`** become editable (column 1 → onCreateDate instead of `periodStart`). Same 3 editable / 2 disabled pattern.
Confirmed formula (verified with values onCreate=100, periodEnd=200, excessInsured=300, months=6):
```
mainTax.calculatedTax = calculatedTax[0].amount = shouldBePaidTax[0].amount =
ROUND(
(appendix2.onCreateDateTotal + appendix2.periodEndTotal) / 24 * months * 0.01
+ appendix2.excessInsuredTotal * 0.01,
2
)
```
Verification: `((100 + 200) / 24 × 6) × 0.01 + (300 × 0.01) = 0.75 + 3.00 = 3.75` — matches `mainTax.calculatedTax = 3.75` byte-exact ✓
Same totals/mirror rules as full-year:
- `createDateCalculations[0,1].amount` = `appendix2.onCreateDateTotal` (mirrors)
- `endYearCalculations[0,1].amount` = `appendix2.periodEndTotal` (mirrors)
- `excessPrices[0,1].amount` = `appendix2.excessInsuredTotal` (mirrors)
- Parent _5001 auto-sums children 1.1–1.6
- Totals exclude parent _5001 (no double-count)
**`calculate_509_2` in existing `property_tax_return.js` matches this byte-for-byte.** No discrepancies.
### Cross-check vs existing `taxes_az` implementation
Existing code lives in:
- `formula_editor` app — 10 DSL formulas under name "Əmlak vergisi" computing calculation-step cells:
- `vergi_hesab_1ci.row(1).manatla` = `table(elave_1, 23, periodStart) + table(elave_2, 12, periodStart)` = combined start
- `vergi_hesab_1ci.row(2).manatla` = appendix-2 only
- `vergi_hesab_1ci.row(4).manatla` = appendix-1 only
- `vergi_hesab_2ci.row(1,2,4).manatla` = same pattern for end-of-year (combined / appendix-2 / appendix-1)
- `vergi_hesab_1ci_1.row(1,2,4).manatla` = same pattern for newly-established case (503.1)
- `vergi_hesab_4cu.row(1).faizlə` = `1` (tax rate 1% literal)
- `taxes_az/.../property_tax_return/property_tax_return.js` — 6 hand-coded functions:
- `calculate_binalar_totals` — sums sub-categories 1.1–1.6 into parent row "1. Binalar"
- `calculate_elave_1_totals` — sums all rows into total row "Vergidən azad olunan əmlakın CƏMİ dəyəri:"
- `calculate_elave_2_totals` — same, excluding `_5001` parent
- `calculate_vergi_hesab_3cu_totals` — computes 505 totals from elave_1.overAmount + elave_2.excessInsured
- `calculate_509_1` — full-year main tax formula (verified ✓ matches my finding exactly)
- `calculate_509_2` — partial-year main tax formula (NOT verified by daemon — see above)
**Verdict: existing implementation is byte-for-byte correct for the full-year case.** Port to Python (Task C) means transcribing this proven logic; do not re-invent.
Observed coefficients confirm the formula (10 samples each, rounding to 2 decimals introduces ≤0.005% deviation):
- periodStart → calculatedTax: 0.005094 (theoretical 0.005)
- periodEnd → calculatedTax: 0.004969 (theoretical 0.005)
- excessInsured → calculatedTax: 0.010000 (theoretical 0.010)
### Verified formulas (from Playwright fill experiment, 2026-05-26)
**Experiment:** filled 4 inputs on appendix-1 step, clicked "Qaralamada saxla" after each, captured PUT /draft/base bodies:
- `taxFree[0].periodStartAmount` ← 100
- `taxFree[0].endYearAmount` ← 200
- `taxFree[0].overAmount` ← 400
- `taxFree[1].periodStartAmount` ← 800
Confirmed formulas (13 fields auto-changed):
| Output field | Formula | Verified |
|---|---|---|
| `appendix1.periodStartTotal` | `baseline_periodStart + Σ taxFree[*].periodStartAmount` | 8345.7 + 100+800 = **9245.7** ✓ |
| `appendix1.endYearTotal` | `baseline_endYear + Σ taxFree[*].endYearAmount` | 5007.42 + 200 = **5207.42** ✓ |
| `appendix1.overTotal` | `Σ taxFree[*].overAmount` (no baseline) | 0 + 400 = **400** ✓ |
| `prePeriodCalculations[0].amount` | = `appendix1.periodStartTotal` | mirror ✓ |
| `prePeriodCalculations[2].amount` | = `appendix1.periodStartTotal` | mirror ✓ |
| `endYearCalculations[0].amount` | = `appendix1.endYearTotal` | mirror ✓ |
| `endYearCalculations[1].amount` | = `appendix1.endYearTotal` | mirror ✓ |
| `excessPrices[0].amount` | = `appendix1.overTotal` | mirror ✓ |
| `excessPrices[1].amount` | = `appendix1.overTotal` | mirror ✓ |
The "baseline" for `periodStartTotal` / `endYearTotal` is the prefilled total from previous year's declaration (from `getPrefillData` query). It is NOT a row in `taxFree[]` but a separate constant added on top.
### Form state location (verified 2026-05-26)
Critical observation: while user types into an input cell:
- The visible DOM value updates ✓
- **NO Redux action fires** (only `internal_probeSubscription` noise from RTK Query) ✓
- The `getDeclarationBaseData` cache stays stale ✓
- The autosave PUT does NOT fire on its own (no timer, or timer ≥ 30s) ✓
The form state lives in **react-hook-form** (or similar non-Redux state). It is serialized + sent to the server **only when the user clicks "Qaralamada saxla" (Save as Draft)** or the "İrəli" button (which presumably also saves).
**Implication for headless probing:** to capture computed values, you MUST click the "Qaralamada saxla" button after each fill. The PUT body of that save IS the computed JSON. No way to get formulas without this round-trip.
### Save-related Redux actions (verified)
Clicking "Qaralamada saxla" dispatches these actions in order:
- `subModuleCommunicationSlice/setIsLoadingOfSaveDeclarationDraft` → `true`
- `subModuleCommunicationSlice/setIsSuccessOfSaveDeclarationDraft` → `false`
- `subModuleCommunicationSlice/setIsClickedDraftButton` → `false`
- `declSlice/setLoadingPoDraftButton` → `true`
- ...PUT /api/po/declaration/public/v1/draft/base...
- `subModuleCommunicationSlice/setIsLoadingOfSaveDeclarationDraft` → `false`
- `subModuleCommunicationSlice/setIsSuccessOfSaveDeclarationDraft` → `true`
- `declSlice/setLoadingPoDraftButton` → `false`
### Old verified formulas (from HAR diff, earlier 2026-05-26)
These were from a separate HAR analysis (different user inputs). Same shape — confirms above:
| Computed field | Formula |
|---|---|
| `appendix1.periodStartTotal` | Σ `appendix1.taxFree[*].periodStartAmount` + baseline (prefilled) |
| `appendix1.overTotal` | Σ `appendix1.taxFree[*].overAmount` |
| `appendix1.endYearTotal` | Σ `appendix1.taxFree[*].endYearAmount` |
| `endYearCalculations[0,1].amount` | mirror `appendix1.endYearTotal` |
| `excessPrices[0,1].amount` | mirror `appendix1.overTotal` |
| `prePeriodCalculations[0,2].amount` | mirror `appendix1.periodStartTotal` |
| `prePeriodCalculations[1].amount` | = `appendix2.taxable[0].periodStart` (only first row?) |
| `prePeriodCalculations[0].amount` | += `appendix2.taxable[0].periodStart` (cumulative with appendix1) |
| `mainTax.calculatedTax` | mirror `calculatedTax[0].amount` |
| `shouldBePaidTax[0].amount` | mirror `calculatedTax[0].amount` |
| `calculatedTax[0].amount` | function of `appendix2.periodStartTotal × taxDegree` (formula not fully nailed — need more HAR data) |
Most formulas are trivial (SUM or mirror). One non-trivial: `calculatedTax`. Likely `taxable_base × 1%` (Azerbaijan property tax rate for legal entities) but factor varies — needs more diff samples.
---
## Playwright on this system (Ubuntu 24.04 frappe-bench)
System layout:
- Python 3.14 at `/usr/local/bin/python3.14` (not the default `/usr/bin/python3` which is 3.12)
- `playwright` pip pkg installed for `frappe` user at `/home/frappe/.local/lib/python3.14/site-packages`
- Chromium binary installed via `playwright install chromium`
- System deps (libatk, libnss3, etc.) — installed once via:
```
sudo PYTHONPATH=/home/frappe/.local/lib/python3.14/site-packages \
/usr/local/bin/python3.14 -m playwright install-deps chromium
```
(the PYTHONPATH is needed because sudo elevates to root who doesn't have playwright; we point root's python to the user-pip location)
Run scripts with: `/usr/local/bin/python3.14 script.py`. NOT `python3` — wrong version.
**Pitfall:** `requests` was also missing from python3.14 initially. Install: `/usr/local/bin/python3.14 -m pip install requests`.
**Pitfall — networkidle is not enough:** e-taxes SPAs render in stages. `await page.goto(url, wait_until="networkidle")` succeeds but the form may not be in DOM yet. Add `await page.wait_for_timeout(2000-3000)` after navigation, or `wait_for_selector` for a known element.
---
## Useful scratch dir
`/tmp/etaxes_explorer/` contains:
- `main_token.txt` — cached JWT main_token (10 min validity)
- `explorer.py` — full ASAN+Playwright explorer (work-in-progress)
- `probe_*.py` — small one-off probes for understanding DOM/auth
- `appendix1.png`, `login_page.png`, `login_step{1,2}.png` — screenshots from runs
- `appendix1.html`, `login_page.html`, `login_step2.html` — captured DOM
- `inputs.json`, `login_elements.json` — extracted interactive elements
- `store_snapshot.json` — Redux store dump
Don't commit anything from there — it has live JWTs.
---
## Open questions / TODO
- [ ] Where exactly is the `calculatedTax` formula (need more HAR samples or running form in browser to verify)
- [ ] Does the `/register` endpoint require browser-side signing (XADES/PKCS7) or accept REST POST?
- [ ] Are formulas identical across declaration versions (2024.1 vs 2025.1)?
- [ ] Other declaration types (VAT, Income Tax, etc.) — same pattern?
- [ ] Can we inject Redux state to skip UI login? (action type unknown so far)