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) }