69bddaeb9a
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 23s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m49s
The receipt letter repeated what the receipt itself states. It now carries the amount, the moment of payment with its offset, and the link — the receipt is the document, and it opens without authentication, so there is nothing for the two to disagree about. Subject reworded to name what it is. The ledger and tax-export tables labelled the account link "card", meaning the user card. On pages about payments that reads as a bank card, which is exactly how it was misread. Both now show the first eight characters of the account id, so the column carries data — two rows from the same buyer are visible at a glance — while the link still holds the whole id.
337 lines
12 KiB
Go
337 lines
12 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/csv"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
|
|
"scrabble/backend/internal/adminconsole"
|
|
"scrabble/backend/internal/mynalogsync"
|
|
"scrabble/backend/internal/payments"
|
|
)
|
|
|
|
// myNalogBack is where every action on the tax-export page returns to.
|
|
const myNalogBack = "/_gm/mynalog"
|
|
|
|
// myNalogListLimit caps each of the three working lists on the page.
|
|
const myNalogListLimit = 200
|
|
|
|
// myNalogOverdueDay is the day of the following month by which a closed month's income must have
|
|
// been filed at the very latest. It is shown as a warning, not enforced.
|
|
const myNalogOverdueDay = 9
|
|
|
|
// consoleMyNalog renders the tax-export section: the session, what is owed, and the manual fallback.
|
|
func (s *Server) consoleMyNalog(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
loc := s.mynalog.Location()
|
|
view := adminconsole.MyNalogView{TimeZone: loc.String()}
|
|
|
|
stored, ok, err := s.payments.MyNalogSession(ctx)
|
|
if err != nil {
|
|
s.consoleError(c, err)
|
|
return
|
|
}
|
|
if ok {
|
|
view.SignedIn = true
|
|
view.INN = stored.INN
|
|
view.AutoEnabled = stored.AutoEnabled
|
|
view.Paused = stored.Paused()
|
|
view.PauseReason = stored.PauseReason
|
|
if stored.LastOKAt != nil {
|
|
view.LastOK = stored.LastOKAt.In(loc).Format("2006-01-02 15:04")
|
|
}
|
|
}
|
|
|
|
queue, err := s.payments.MyNalogQueue(ctx, myNalogListLimit)
|
|
if err != nil {
|
|
s.consoleError(c, err)
|
|
return
|
|
}
|
|
for _, in := range queue.Incomes {
|
|
view.Owed = append(view.Owed, s.myNalogRow(in, stored.INN, loc))
|
|
}
|
|
for _, in := range queue.Attention {
|
|
view.Attention = append(view.Attention, s.myNalogRow(in, stored.INN, loc))
|
|
}
|
|
for _, cn := range queue.Cancels {
|
|
view.Cancels = append(view.Cancels, adminconsole.MyNalogCancelRow{
|
|
LedgerID: cn.LedgerID.String(),
|
|
AccountID: cn.AccountID.String(),
|
|
AccountShort: shortID(cn.AccountID.String()),
|
|
ReceiptUUID: cn.ReceiptUUID,
|
|
Failed: cn.CancelStatus == payments.MyNalogCancelFailed,
|
|
})
|
|
}
|
|
|
|
now := time.Now().In(loc)
|
|
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc)
|
|
backlog, err := s.payments.MyNalogBacklogBefore(ctx, monthStart)
|
|
if err != nil {
|
|
s.consoleError(c, err)
|
|
return
|
|
}
|
|
if backlog.Count > 0 {
|
|
view.Backlog = adminconsole.MyNalogBacklogRow{
|
|
Count: backlog.Count,
|
|
Amount: fmtMinor(backlog.AmountMinor, payments.CurrencyRUB),
|
|
Oldest: backlog.Oldest.In(loc).Format("2006-01-02"),
|
|
Overdue: now.Day() >= myNalogOverdueDay,
|
|
}
|
|
}
|
|
s.renderConsole(c, "mynalog", "mynalog", "My Nalog", view)
|
|
}
|
|
|
|
// fmtMinor renders a minor-unit total for display, or "" when the currency is not one we know.
|
|
func fmtMinor(minor int64, cur payments.Currency) string {
|
|
m, err := payments.MoneyFromMinor(minor, cur)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return fmtMoney(m)
|
|
}
|
|
|
|
// myNalogRow projects one income into its console row, including the printable receipt link once
|
|
// the tax service has issued one.
|
|
func (s *Server) myNalogRow(in payments.MyNalogIncome, inn string, loc *time.Location) adminconsole.MyNalogRow {
|
|
row := adminconsole.MyNalogRow{
|
|
LedgerID: in.LedgerID.String(),
|
|
AccountID: in.AccountID.String(),
|
|
AccountShort: shortID(in.AccountID.String()),
|
|
At: in.OperationTime.In(loc).Format("2006-01-02 15:04"),
|
|
Amount: in.Amount.String(),
|
|
Name: s.mynalog.ReceiptName(in),
|
|
Status: in.Status,
|
|
Attempts: in.Attempts,
|
|
LastError: in.LastError,
|
|
ReceiptUUID: in.ReceiptUUID,
|
|
}
|
|
if in.ReceiptUUID != "" && inn != "" {
|
|
row.ReceiptURL = s.mynalog.ReceiptURL(inn, in.ReceiptUUID)
|
|
}
|
|
return row
|
|
}
|
|
|
|
// consoleMyNalogSignIn exchanges the tax-cabinet password for a session. The password is read from
|
|
// the form, used once and never stored: only the refresh token it yields is kept, encrypted.
|
|
func (s *Server) consoleMyNalogSignIn(c *gin.Context) {
|
|
login, password := trimForm(c, "login"), c.PostForm("password")
|
|
if login == "" || password == "" {
|
|
s.renderConsoleMessage(c, "Missing credentials",
|
|
"both the tax cabinet login (your INN) and the password are required", myNalogBack)
|
|
return
|
|
}
|
|
if err := s.mynalog.SignIn(c.Request.Context(), login, password); err != nil {
|
|
// The error text comes from the tax service and never carries the credentials back.
|
|
s.renderConsoleMessage(c, "Sign-in failed", err.Error(), myNalogBack)
|
|
return
|
|
}
|
|
s.renderConsoleMessage(c, "Signed in",
|
|
"the tax cabinet session is stored; only its refresh token was kept, not the password",
|
|
myNalogBack)
|
|
}
|
|
|
|
// consoleMyNalogSignOut forgets the stored session, which also disarms the automatic export.
|
|
func (s *Server) consoleMyNalogSignOut(c *gin.Context) {
|
|
if err := s.mynalog.SignOut(c.Request.Context()); err != nil {
|
|
s.consoleError(c, err)
|
|
return
|
|
}
|
|
s.renderConsoleMessage(c, "Signed out",
|
|
"the stored session is gone and the automatic export is off", myNalogBack)
|
|
}
|
|
|
|
// consoleMyNalogRun performs one export run and reports what it did.
|
|
func (s *Server) consoleMyNalogRun(c *gin.Context) {
|
|
sum, err := s.mynalog.RunBatch(c.Request.Context(), mynalogsync.ConsoleBudget)
|
|
if err != nil {
|
|
if errors.Is(err, mynalogsync.ErrNotSignedIn) {
|
|
s.renderConsoleMessage(c, "Not signed in",
|
|
"sign in to the tax cabinet before running an export", myNalogBack)
|
|
return
|
|
}
|
|
s.consoleError(c, err)
|
|
return
|
|
}
|
|
switch {
|
|
case sum.Busy:
|
|
s.renderConsoleMessage(c, "Already running",
|
|
"another export is in progress; wait for it to finish", myNalogBack)
|
|
return
|
|
case sum.Paused:
|
|
s.renderConsoleMessage(c, "Export is paused", sum.Stopped, myNalogBack)
|
|
return
|
|
}
|
|
body := fmt.Sprintf("filed %d, annulled %d, ruled out %d, failed %d, unresolved %d",
|
|
sum.Registered, sum.Cancelled, sum.NotRequired, sum.Failed, sum.Unknown)
|
|
if sum.Stopped != "" {
|
|
body += ". " + sum.Stopped
|
|
}
|
|
s.renderConsoleMessage(c, "Export finished", body, myNalogBack)
|
|
}
|
|
|
|
// consoleMyNalogAuto arms or disarms the automatic export.
|
|
func (s *Server) consoleMyNalogAuto(c *gin.Context) {
|
|
enabled := c.PostForm("enabled") == "1"
|
|
if err := s.payments.SetMyNalogAuto(c.Request.Context(), enabled); err != nil {
|
|
s.consoleError(c, err)
|
|
return
|
|
}
|
|
body := "the automatic export is off; nothing is filed until you press Run"
|
|
if enabled {
|
|
body = fmt.Sprintf("the automatic export is on; it wakes every %s and does nothing when "+
|
|
"there is nothing to file", mynalogsync.WorkerInterval)
|
|
}
|
|
s.renderConsoleMessage(c, "Saved", body, myNalogBack)
|
|
}
|
|
|
|
// consoleMyNalogResume clears a self-imposed pause once the operator has dealt with its cause.
|
|
func (s *Server) consoleMyNalogResume(c *gin.Context) {
|
|
if err := s.payments.ResumeMyNalog(c.Request.Context()); err != nil {
|
|
s.consoleError(c, err)
|
|
return
|
|
}
|
|
s.renderConsoleMessage(c, "Resumed", "the export will run again on the next tick", myNalogBack)
|
|
}
|
|
|
|
// consoleMyNalogRecheck asks the tax service once more whether a receipt with an unresolved outcome
|
|
// exists after all.
|
|
func (s *Server) consoleMyNalogRecheck(c *gin.Context) {
|
|
id, ok := s.myNalogLedgerID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
found, err := s.mynalog.Recheck(c.Request.Context(), id)
|
|
if err != nil {
|
|
s.renderConsoleMessage(c, "Re-check failed", err.Error(), myNalogBack)
|
|
return
|
|
}
|
|
if found {
|
|
s.renderConsoleMessage(c, "Found",
|
|
"the receipt does exist; the income is now recorded as filed", myNalogBack)
|
|
return
|
|
}
|
|
s.renderConsoleMessage(c, "Not found",
|
|
"the tax service still does not list this receipt. If you are sure it was never created, "+
|
|
"return the income to the queue; otherwise file it by hand and enter its number here",
|
|
myNalogBack)
|
|
}
|
|
|
|
// consoleMyNalogRequeue puts an unresolved income back into the queue. It is the operator asserting
|
|
// what the software may not assume on its own: that the receipt really was never created.
|
|
func (s *Server) consoleMyNalogRequeue(c *gin.Context) {
|
|
id, ok := s.myNalogLedgerID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := s.mynalog.Requeue(c.Request.Context(), id); err != nil {
|
|
s.consoleError(c, err)
|
|
return
|
|
}
|
|
s.renderConsoleMessage(c, "Queued", "the income will be filed on the next run", myNalogBack)
|
|
}
|
|
|
|
// consoleMyNalogManual records an income the operator filed by hand, together with the receipt
|
|
// number the tax cabinet gave it. That number is what lets a later refund annul the receipt
|
|
// automatically, which is why it is asked for rather than a simple "done" tick.
|
|
func (s *Server) consoleMyNalogManual(c *gin.Context) {
|
|
id, ok := s.myNalogLedgerID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
receipt := trimForm(c, "receipt_uuid")
|
|
if receipt == "" {
|
|
s.renderConsoleMessage(c, "Missing receipt number",
|
|
"enter the receipt number the tax cabinet shows for this income", myNalogBack)
|
|
return
|
|
}
|
|
if err := s.mynalog.RecordManual(c.Request.Context(), id, receipt); err != nil {
|
|
s.consoleError(c, err)
|
|
return
|
|
}
|
|
s.renderConsoleMessage(c, "Recorded",
|
|
"the income counts as filed; a refund will annul this receipt automatically", myNalogBack)
|
|
}
|
|
|
|
// consoleMyNalogSkip rules an income out of the export for a reason the operator gives.
|
|
func (s *Server) consoleMyNalogSkip(c *gin.Context) {
|
|
id, ok := s.myNalogLedgerID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
reason := trimForm(c, "reason")
|
|
if reason == "" {
|
|
reason = "ruled out by an operator"
|
|
}
|
|
if err := s.mynalog.Skip(c.Request.Context(), id, reason); err != nil {
|
|
s.consoleError(c, err)
|
|
return
|
|
}
|
|
s.renderConsoleMessage(c, "Ruled out", "this income will not be filed", myNalogBack)
|
|
}
|
|
|
|
// myNalogLedgerID parses the posted ledger id.
|
|
func (s *Server) myNalogLedgerID(c *gin.Context) (uuid.UUID, bool) {
|
|
id, err := uuid.Parse(trimForm(c, "ledger_id"))
|
|
if err != nil {
|
|
s.renderConsoleMessage(c, "Invalid id", "the ledger id is not valid", myNalogBack)
|
|
return uuid.UUID{}, false
|
|
}
|
|
return id, true
|
|
}
|
|
|
|
// consoleMyNalogExport writes the outstanding income as CSV in the shape the tax cabinet's own form
|
|
// expects — the date, the service description and the amount.
|
|
//
|
|
// This is the fallback for the API being unavailable or having changed, which for an undocumented
|
|
// interface is a matter of when rather than if. The ledger id travels with each row so that the
|
|
// receipt number can be entered back on the page afterwards, which is what keeps a hand-filed
|
|
// income annullable on a refund.
|
|
func (s *Server) consoleMyNalogExport(c *gin.Context) {
|
|
queue, err := s.payments.MyNalogQueue(c.Request.Context(), myNalogListLimit)
|
|
if err != nil {
|
|
s.consoleError(c, err)
|
|
return
|
|
}
|
|
loc := s.mynalog.Location()
|
|
c.Header("Content-Type", "text/csv; charset=utf-8")
|
|
c.Header("Content-Disposition", `attachment; filename="mynalog.csv"`)
|
|
w := csv.NewWriter(c.Writer)
|
|
_ = w.Write([]string{"operation_time", "service_name", "amount", "currency", "status", "ledger_id"})
|
|
for _, in := range append(queue.Incomes, queue.Attention...) {
|
|
_ = w.Write([]string{
|
|
in.OperationTime.In(loc).Format("2006-01-02 15:04:05"),
|
|
csvSafe(s.mynalog.ReceiptName(in)),
|
|
in.Amount.Major(),
|
|
string(in.Amount.Currency()),
|
|
in.Status,
|
|
in.LedgerID.String(),
|
|
})
|
|
}
|
|
w.Flush()
|
|
}
|
|
|
|
// myNalogEnabled reports whether the tax-export console section should exist.
|
|
func (s *Server) myNalogEnabled() bool { return s.mynalog != nil && s.payments != nil }
|
|
|
|
// registerMyNalogConsole attaches the tax-export section to the console group.
|
|
func (s *Server) registerMyNalogConsole(gm *gin.RouterGroup) {
|
|
if !s.myNalogEnabled() {
|
|
return
|
|
}
|
|
gm.GET("/mynalog", s.consoleMyNalog)
|
|
gm.GET("/mynalog.csv", s.consoleMyNalogExport)
|
|
gm.POST("/mynalog/login", s.consoleMyNalogSignIn)
|
|
gm.POST("/mynalog/logout", s.consoleMyNalogSignOut)
|
|
gm.POST("/mynalog/run", s.consoleMyNalogRun)
|
|
gm.POST("/mynalog/auto", s.consoleMyNalogAuto)
|
|
gm.POST("/mynalog/resume", s.consoleMyNalogResume)
|
|
gm.POST("/mynalog/recheck", s.consoleMyNalogRecheck)
|
|
gm.POST("/mynalog/requeue", s.consoleMyNalogRequeue)
|
|
gm.POST("/mynalog/manual", s.consoleMyNalogManual)
|
|
gm.POST("/mynalog/skip", s.consoleMyNalogSkip)
|
|
}
|