Кабинет: операции по счетам депо и доходы с удержанным налогом из учётной системы

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zuevav
2026-09-17 23:11:34 +03:00
parent 26785ff3ba
commit 4b18579956
7 changed files with 551 additions and 2 deletions
+91
View File
@@ -221,6 +221,34 @@ type portfolioResp struct {
Priced int `json:"priced"`
}
// operationRow — движение по счёту депо.
type operationRow struct {
Date string `json:"date"`
DoneAt string `json:"done_at"`
Kind string `json:"kind"`
Number string `json:"number"`
Account string `json:"account"`
ISIN string `json:"isin"`
Security string `json:"security"`
Quantity float64 `json:"quantity"`
State string `json:"state"`
Note string `json:"note"`
}
// incomeRow — выплата дохода.
type incomeRow struct {
Date string `json:"date"`
Kind string `json:"kind"`
ISIN string `json:"isin"`
Security string `json:"security"`
Quantity float64 `json:"quantity"`
Accrued float64 `json:"accrued"`
Tax float64 `json:"tax"`
Paid float64 `json:"paid"`
State string `json:"state"`
Account string `json:"account"`
}
// securityCard — карточка бумаги из ядра.
type securityCard struct {
ISIN string `json:"isin"`
@@ -444,6 +472,69 @@ func (s *Server) Handler() http.Handler {
s.render(w, r, "security", map[string]any{"Nav": "p", "S": out.Security, "ISIN": isin})
})
// Операции по счетам депо
mux.HandleFunc("/operations", func(w http.ResponseWriter, r *http.Request) {
sess := sessionW(w, r)
if sess == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
from, to := r.URL.Query().Get("from"), r.URL.Query().Get("to")
if from == "" {
from = time.Now().AddDate(-1, 0, 0).Format("2006-01-02")
}
if to == "" {
to = time.Now().Format("2006-01-02")
}
var out struct {
Operations []operationRow `json:"operations"`
}
path := "/api/lk/operations?from=" + url.QueryEscape(from) + "&to=" + url.QueryEscape(to)
if err := bj.call(r.Context(), http.MethodGet, path, sess, nil, &out); err != nil {
s.render(w, r, "operations", map[string]any{"Nav": "o", "Error": err.Error(), "From": from, "To": to})
return
}
var in, outq int
for _, o := range out.Operations {
if o.Quantity < 0 {
outq++
} else {
in++
}
}
s.render(w, r, "operations", map[string]any{"Nav": "o", "Operations": out.Operations,
"From": from, "To": to, "Total": len(out.Operations), "In": in, "Out": outq})
})
// Доходы и налоги
mux.HandleFunc("/income", func(w http.ResponseWriter, r *http.Request) {
sess := sessionW(w, r)
if sess == "" {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
from, to := r.URL.Query().Get("from"), r.URL.Query().Get("to")
if from == "" {
from = time.Now().Format("2006") + "-01-01"
}
if to == "" {
to = time.Now().Format("2006-01-02")
}
var out struct {
Income []incomeRow `json:"income"`
Accrued float64 `json:"accrued"`
Paid float64 `json:"paid"`
Tax float64 `json:"tax"`
}
path := "/api/lk/income?from=" + url.QueryEscape(from) + "&to=" + url.QueryEscape(to)
if err := bj.call(r.Context(), http.MethodGet, path, sess, nil, &out); err != nil {
s.render(w, r, "income", map[string]any{"Nav": "i", "Error": err.Error(), "From": from, "To": to})
return
}
s.render(w, r, "income", map[string]any{"Nav": "i", "Income": out.Income, "From": from, "To": to,
"Accrued": out.Accrued, "Paid": out.Paid, "Tax": out.Tax})
})
// Профиль: договор, счета депо, доступ
mux.HandleFunc("/profile", func(w http.ResponseWriter, r *http.Request) {
sess := sessionW(w, r)
+32
View File
@@ -53,6 +53,26 @@ func fakeBJ(t *testing.T) *httptest.Server {
}})
}
})
mux.HandleFunc("/api/lk/operations", func(w http.ResponseWriter, r *http.Request) {
if keyOK(w, r) && r.Header.Get("X-BJ-Session") == "sess-1" {
_ = json.NewEncoder(w).Encode(map[string]any{"operations": []any{
map[string]any{"date": "16.09.2026", "kind": "зачисление", "number": "260915/1", "account": "ВТНКЦ00000001",
"isin": "RU0009029540", "security": "Сбербанк, ао", "quantity": 100, "state": "исполнено", "note": "Отчёт клиринговой организации"},
map[string]any{"date": "14.08.2026", "kind": "списание", "account": "ВТНКЦ00000001", "isin": "RU000A1014L8",
"security": "Ликвидность, пай", "quantity": -120, "state": "исполнено"},
}})
}
})
mux.HandleFunc("/api/lk/income", func(w http.ResponseWriter, r *http.Request) {
if keyOK(w, r) && r.Header.Get("X-BJ-Session") == "sess-1" {
_ = json.NewEncoder(w).Encode(map[string]any{"accrued": 540.6, "paid": 494.6, "tax": 46.0, "income": []any{
map[string]any{"date": "02.06.2026", "kind": "дивиденды", "isin": "RU000A106YF0", "security": "ВК, ао",
"quantity": 5, "accrued": 45, "tax": 6, "paid": 39},
map[string]any{"date": "03.06.2026", "kind": "купон", "isin": "RU000A1038V6", "security": "ОФЗ 26238",
"accrued": 495.6, "tax": 0, "paid": 495.6},
}})
}
})
mux.HandleFunc("/api/lk/ping", func(w http.ResponseWriter, r *http.Request) {
if keyOK(w, r) {
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
@@ -195,6 +215,18 @@ func TestBoardPages(t *testing.T) {
t.Errorf("в карточке бумаги нет %q", want)
}
}
ops := get("/operations")
for _, want := range []string{"Операции по счетам депо", "зачисление", "Ликвидность, пай", "Отчёт клиринговой организации"} {
if !strings.Contains(ops, want) {
t.Errorf("в операциях нет %q", want)
}
}
inc := get("/income")
for _, want := range []string{"Выплачено дохода", "494,60 ₽", "Удержан налог", "дивиденды"} {
if !strings.Contains(inc, want) {
t.Errorf("в доходах нет %q", want)
}
}
prof := get("/profile")
if !strings.Contains(prof, "Счета депо") || !strings.Contains(prof, "ВТНКЦ00000001") {
t.Errorf("профиль: %s", prof)
+65
View File
@@ -99,6 +99,8 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px}
<a href="/" class="lbl {{if eq .Nav "b"}}on{{end}}">Табло</a>
<a href="/portfolio" class="lbl {{if eq .Nav "p"}}on{{end}}">Реестр</a>
<a href="/orders" class="lbl {{if eq .Nav "c"}}on{{end}}">Поручения</a>
<a href="/operations" class="lbl {{if eq .Nav "o"}}on{{end}}">Операции</a>
<a href="/income" class="lbl {{if eq .Nav "i"}}on{{end}}">Доходы</a>
<a href="/profile" class="lbl {{if eq .Nav "u"}}on{{end}}">Профиль</a>
</nav>
<a href="/order" class="cta lbl"><svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M8 3v10M3 8h10"/></svg>Новое поручение</a>
@@ -422,4 +424,67 @@ input[type=checkbox]{width:20px;min-height:20px;height:20px}
</section>
</div>
</main></body></html>{{end}}
{{define "operations"}}{{template "head" .}}{{template "nav" .}}
<section class="board"><div class="board-row">
<div><span class="lbl" style="color:var(--dim)">Операции по счетам депо</span>
<div class="big" style="margin-top:8px;font-size:32px">{{.Total}}</div>
<div class="n" style="font-size:13px;color:var(--dim);margin-top:6px">за период {{.From}} — {{.To}}</div></div>
<div class="stats">
<span class="stat"><span class="lbl" style="color:var(--dim)">Зачислений</span><span class="v">{{.In}}</span></span>
<span class="stat"><span class="lbl" style="color:var(--dim)">Списаний</span><span class="v">{{.Out}}</span></span>
</div>
</div></section>
<main>
{{if .Error}}<div class="msg err">{{.Error}}</div>{{end}}
<form method="get" action="/operations" style="display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;margin-bottom:18px">
<div><label for="from">Период с</label><input id="from" type="date" name="from" value="{{.From}}" style="width:auto"></div>
<div><label for="to">по</label><input id="to" type="date" name="to" value="{{.To}}" style="width:auto"></div>
<button class="btn" type="submit">Показать</button>
</form>
{{if .Operations}}<div class="tw"><table>
<thead><tr><th>Дата</th><th>Операция</th><th>Выпуск</th><th>Счёт депо</th><th class="num">Количество</th><th>Состояние</th><th>Основание</th></tr></thead>
<tbody>{{range .Operations}}<tr>
<td class="n">{{.Date}}{{if and .DoneAt (ne .DoneAt .Date)}}<div class="mut">проведено {{.DoneAt}}</div>{{end}}</td>
<td>{{.Kind}}{{with .Number}}<div class="n mut">№ {{.}}</div>{{end}}</td>
<td>{{if .ISIN}}<a href="/security?isin={{.ISIN}}" style="text-decoration:none">{{.Security}}</a><div class="n mut">{{.ISIN}}</div>{{else}}{{.Security}}{{end}}</td>
<td class="n" style="font-size:13px">{{.Account}}</td>
<td class="num">{{if lt .Quantity 0.0}}<span style="color:var(--err-tx)">{{fmtnum .Quantity}}</span>{{else}}<span style="color:var(--ok-tx)">+{{fmtnum .Quantity}}</span>{{end}}</td>
<td>{{if eq .State "исполнено"}}<span class="chip ok">исполнено</span>{{else if eq .State "отменено"}}<span class="chip err">отменено</span>{{else}}<span class="chip go">{{.State}}</span>{{end}}</td>
<td class="mut">{{if .Note}}{{.Note}}{{else}}—{{end}}</td>
</tr>{{end}}</tbody></table></div>
<p class="mut" style="margin-top:18px">Показаны зачисления, списания и переводы между вашими счетами депо по данным учёта депозитария. Выписку за период можно заказать у депозитария.</p>
{{else}}<p class="empty">За выбранный период операций по счетам депо нет.</p>{{end}}
</main></body></html>{{end}}
{{define "income"}}{{template "head" .}}{{template "nav" .}}
<section class="board"><div class="board-row">
<div><span class="lbl" style="color:var(--dim)">Выплачено дохода</span>
<div class="big" style="margin-top:8px">{{fmtmoney .Paid}} ₽</div>
<div class="n" style="font-size:13px;color:var(--dim);margin-top:6px">за период {{.From}} — {{.To}}</div></div>
<div class="stats">
<span class="stat"><span class="lbl" style="color:var(--dim)">Начислено</span><span class="v">{{fmtmoney .Accrued}} ₽</span></span>
<span class="stat"><span class="lbl" style="color:var(--dim)">Удержан налог</span><span class="v" style="color:var(--amber)">{{fmtmoney .Tax}} ₽</span></span>
</div>
</div></section>
<main>
{{if .Error}}<div class="msg err">{{.Error}}</div>{{end}}
<form method="get" action="/income" style="display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;margin-bottom:18px">
<div><label for="from">Период с</label><input id="from" type="date" name="from" value="{{.From}}" style="width:auto"></div>
<div><label for="to">по</label><input id="to" type="date" name="to" value="{{.To}}" style="width:auto"></div>
<button class="btn" type="submit">Показать</button>
</form>
{{if .Income}}<div class="tw"><table>
<thead><tr><th>Дата</th><th>Выпуск</th><th>Вид дохода</th><th class="num">Бумаг</th><th class="num">Начислено</th><th class="num">Налог</th><th class="num">К выплате</th></tr></thead>
<tbody>{{range .Income}}<tr>
<td class="n">{{.Date}}</td>
<td>{{if .ISIN}}<a href="/security?isin={{.ISIN}}" style="text-decoration:none">{{.Security}}</a><div class="n mut">{{.ISIN}}</div>{{else}}{{.Security}}{{end}}</td>
<td>{{.Kind}}</td>
<td class="num">{{if gt .Quantity 0.0}}{{fmtnum .Quantity}}{{else}}—{{end}}</td>
<td class="num">{{fmtmoney .Accrued}}</td>
<td class="num">{{if gt .Tax 0.0}}{{fmtmoney .Tax}}{{else}}—{{end}}</td>
<td class="num" style="font-weight:700">{{fmtmoney .Paid}} ₽</td>
</tr>{{end}}</tbody></table></div>
<p class="mut" style="margin-top:18px">Суммы и удержанный налог — по данным учёта депозитария как налогового агента по выплатам эмитентов. По купонам государственных облигаций налог может удерживать брокер при выплате, тогда здесь он нулевой. Справку о доходах можно запросить у депозитария.</p>
{{else}}<p class="empty">За выбранный период выплат дохода нет.</p>{{end}}
</main></body></html>{{end}}
`