feat(payments): report income to «Мой налог»
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Failing after 24s
CI / ui (pull_request) Successful in 1m17s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Failing after 24s
CI / ui (pull_request) Successful in 1m17s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
The direct rail runs on НПД, where the provider neither files with the tax service nor issues a receipt — so nobody was doing it. This registers each rouble purchase, annuls its receipt on a refund, and hands the buyer the receipt by email. Two properties of the (unofficial) lknpd API shape the design. Registering an income takes no idempotency key, so an error does not mean nothing happened: the service name is frozen before the call and carries a marker from the tail of the order id, and after a failure the taxpayer's income list is searched for that exact name. Found means filed; not found halts the queue for a human, because declaring an income twice is as wrong as not declaring it. And faults are classified rather than logged: a token is renewed silently, a throttle backs off, an outage retries, but three unfixable rejections take the rail out of service — a changed format must not become thousands of requests overnight. The console button and the worker share one RunBatch. Automatic mode is armed from the console, not from configuration, so the operator can watch a run go through by hand first. A daily watchdog runs whether or not it is armed, since the case it exists for is the export being off. An idle queue issues no call at all — not even an authentication. No payment path changed: the purchase letter rides the existing payment-event outbox on its own cursor, the receipt and annulment letters ride the export row. Decisions D53-D60.
This commit is contained in:
@@ -0,0 +1,723 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/mynalog"
|
||||
"scrabble/backend/internal/mynalogsync"
|
||||
"scrabble/backend/internal/payments"
|
||||
"scrabble/backend/internal/server"
|
||||
)
|
||||
|
||||
// taxStub stands in for the professional-income tax cabinet. It records what it was asked and, by
|
||||
// default, behaves like the real service: it mints a receipt id and remembers the income under the
|
||||
// exact service name it was filed with, which is what the recovery probe searches by.
|
||||
type taxStub struct {
|
||||
srv *httptest.Server
|
||||
|
||||
mu sync.Mutex
|
||||
calls []string
|
||||
filed map[string]string // service name -> receipt id
|
||||
annulled map[string]bool // receipt id -> annulled
|
||||
seq int
|
||||
|
||||
// onIncome overrides the default success. Returning a non-zero status makes the stub answer
|
||||
// that instead; filing is the callback's own business, which is how a "the call failed but the
|
||||
// receipt exists" case is set up.
|
||||
onIncome func(s *taxStub, name string) (status int, body string)
|
||||
}
|
||||
|
||||
// newTaxStub starts a stub cabinet and stops it when the test ends.
|
||||
func newTaxStub(t *testing.T) *taxStub {
|
||||
t.Helper()
|
||||
st := &taxStub{filed: map[string]string{}, annulled: map[string]bool{}}
|
||||
st.srv = httptest.NewServer(http.HandlerFunc(st.serve))
|
||||
t.Cleanup(st.srv.Close)
|
||||
return st
|
||||
}
|
||||
|
||||
// serve routes one stub call.
|
||||
func (s *taxStub) serve(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
s.mu.Lock()
|
||||
s.calls = append(s.calls, r.URL.Path)
|
||||
s.mu.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/auth/lkfl", "/api/v1/auth/token":
|
||||
_, _ = io.WriteString(w, `{"token":"access","refreshToken":"refresh","profile":{"inn":"770000000000"}}`)
|
||||
case "/api/v1/income":
|
||||
s.serveIncome(w, body)
|
||||
case "/api/v1/cancel":
|
||||
s.serveCancel(w, body)
|
||||
case "/api/v1/incomes":
|
||||
s.serveList(w)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
// serveIncome registers an income, or defers to the test's override.
|
||||
func (s *taxStub) serveIncome(w http.ResponseWriter, body []byte) {
|
||||
var req struct {
|
||||
Services []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"services"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &req)
|
||||
name := ""
|
||||
if len(req.Services) > 0 {
|
||||
name = req.Services[0].Name
|
||||
}
|
||||
if s.onIncome != nil {
|
||||
if status, out := s.onIncome(s, name); status != 0 {
|
||||
w.WriteHeader(status)
|
||||
_, _ = io.WriteString(w, out)
|
||||
return
|
||||
}
|
||||
}
|
||||
_, _ = io.WriteString(w, fmt.Sprintf(`{"approvedReceiptUuid":%q}`, s.file(name)))
|
||||
}
|
||||
|
||||
// file records an income under name and returns the receipt id the stub assigned.
|
||||
func (s *taxStub) file(name string) string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.seq++
|
||||
id := fmt.Sprintf("receipt-%d", s.seq)
|
||||
s.filed[name] = id
|
||||
return id
|
||||
}
|
||||
|
||||
// serveCancel annuls a receipt.
|
||||
func (s *taxStub) serveCancel(w http.ResponseWriter, body []byte) {
|
||||
var req struct {
|
||||
ReceiptUUID string `json:"receiptUuid"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &req)
|
||||
s.mu.Lock()
|
||||
s.annulled[req.ReceiptUUID] = true
|
||||
s.mu.Unlock()
|
||||
_, _ = io.WriteString(w, `{}`)
|
||||
}
|
||||
|
||||
// serveList answers the income list the recovery probe searches, omitting annulled receipts the way
|
||||
// the real service marks them.
|
||||
func (s *taxStub) serveList(w http.ResponseWriter) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var items []string
|
||||
for name, id := range s.filed {
|
||||
cancellation := "null"
|
||||
if s.annulled[id] {
|
||||
cancellation = `{"comment":"Возврат средств"}`
|
||||
}
|
||||
items = append(items, fmt.Sprintf(`{"name":%q,"approvedReceiptUuid":%q,"cancellationInfo":%s}`,
|
||||
name, id, cancellation))
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"content":[`+strings.Join(items, ",")+`]}`)
|
||||
}
|
||||
|
||||
// hits reports how many times a path was called.
|
||||
func (s *taxStub) hits(path string) int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
n := 0
|
||||
for _, c := range s.calls {
|
||||
if c == path {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// receiptFor returns the receipt id the stub assigned to a service name.
|
||||
func (s *taxStub) receiptFor(name string) (string, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
id, ok := s.filed[name]
|
||||
return id, ok
|
||||
}
|
||||
|
||||
// isAnnulled reports whether a receipt has been annulled.
|
||||
func (s *taxStub) isAnnulled(id string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.annulled[id]
|
||||
}
|
||||
|
||||
// recordingAlerter captures operator alerts instead of emailing them.
|
||||
type recordingAlerter struct {
|
||||
mu sync.Mutex
|
||||
kinds []string
|
||||
}
|
||||
|
||||
func (a *recordingAlerter) Alert(_ context.Context, kind, _, _ string) error {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.kinds = append(a.kinds, kind)
|
||||
return nil
|
||||
}
|
||||
|
||||
// saw reports whether an alert of the given kind was raised.
|
||||
func (a *recordingAlerter) saw(kind string) bool {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
for _, k := range a.kinds {
|
||||
if k == kind {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// taxKey is the encryption key the tests seal the stored refresh token with.
|
||||
const taxKey = "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY="
|
||||
|
||||
// taxServer wires a console server with the tax export pointed at the stub.
|
||||
func taxServer(t *testing.T, st *taxStub) (*server.Server, *payments.Service, *mynalogsync.Exporter, *recordingAlerter) {
|
||||
t.Helper()
|
||||
pay := newPaymentsService()
|
||||
key, err := mynalog.ParseKey(taxKey)
|
||||
if err != nil {
|
||||
t.Fatalf("key: %v", err)
|
||||
}
|
||||
alerts := &recordingAlerter{}
|
||||
exp := mynalogsync.New(pay, mynalogsync.Config{
|
||||
Key: key,
|
||||
BaseURL: st.srv.URL,
|
||||
Location: time.FixedZone("MSK", 3*60*60),
|
||||
Pace: time.Millisecond, // production paces at 2s; a test must not
|
||||
}, alerts, zap.NewNop())
|
||||
if exp == nil {
|
||||
t.Fatal("exporter not built")
|
||||
}
|
||||
srv := server.New(":0", server.Deps{
|
||||
Logger: zap.NewNop(),
|
||||
Accounts: account.NewStore(testDB),
|
||||
Games: newGameService(),
|
||||
Registry: testRegistry,
|
||||
DictDir: dictDir(),
|
||||
Payments: pay,
|
||||
MyNalog: exp,
|
||||
})
|
||||
drainTaxQueue(t, pay)
|
||||
return srv, pay, exp, alerts
|
||||
}
|
||||
|
||||
// drainTaxQueue rules out every rouble income already in the shared test database, so a test starts
|
||||
// from an empty queue and can assert on counts. The database is shared across the package, and
|
||||
// other payment tests fund YooKassa orders that this export would otherwise pick up.
|
||||
func drainTaxQueue(t *testing.T, pay *payments.Service) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
for {
|
||||
queue, err := pay.MyNalogQueue(ctx, 500)
|
||||
if err != nil {
|
||||
t.Fatalf("drain queue: %v", err)
|
||||
}
|
||||
pending := append(append([]payments.MyNalogIncome{}, queue.Incomes...), queue.NotRequired...)
|
||||
pending = append(pending, queue.Attention...)
|
||||
if len(pending) == 0 {
|
||||
return
|
||||
}
|
||||
for _, in := range pending {
|
||||
if err := pay.MarkMyNalogNotRequired(ctx, in, "seeded by another test"); err != nil {
|
||||
t.Fatalf("drain row: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fundTaxableOrder funds one YooKassa rouble order and returns the account and the order id.
|
||||
func fundTaxableOrder(t *testing.T, pay *payments.Service, chips int, minor int64) (uuid.UUID, uuid.UUID) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
acc := provisionAccount(t)
|
||||
prod := seedPackProduct(t, chips, methodPrice{method: "direct", currency: "RUB", amount: minor})
|
||||
res, err := pay.CreateOrder(ctx, acc, payments.NewContext("direct", "web"),
|
||||
[]payments.Source{payments.SourceDirect}, prod, "yookassa")
|
||||
if err != nil {
|
||||
t.Fatalf("order: %v", err)
|
||||
}
|
||||
paid, err := payments.MoneyFromMinor(minor, payments.CurrencyRUB)
|
||||
if err != nil {
|
||||
t.Fatalf("money: %v", err)
|
||||
}
|
||||
if _, err := pay.Fund(ctx, res.OrderID, "yookassa", "pay-"+res.OrderID.String(), paid); err != nil {
|
||||
t.Fatalf("fund: %v", err)
|
||||
}
|
||||
return acc, res.OrderID
|
||||
}
|
||||
|
||||
// signInTax signs the export into the stub cabinet through the console.
|
||||
func signInTax(t *testing.T, srv *server.Server) {
|
||||
t.Helper()
|
||||
code, body := consoleDo(srv.Handler(), http.MethodPost, "http://admin.test/_gm/mynalog/login",
|
||||
"login=770000000000&password=secret", "http://admin.test")
|
||||
if code != http.StatusOK || !strings.Contains(body, "Signed in") {
|
||||
t.Fatalf("sign in = %d: %s", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
// onlyIncome returns the single owed income, failing when the queue does not hold exactly one.
|
||||
func onlyIncome(t *testing.T, pay *payments.Service) payments.MyNalogIncome {
|
||||
t.Helper()
|
||||
queue, err := pay.MyNalogQueue(context.Background(), 500)
|
||||
if err != nil {
|
||||
t.Fatalf("queue: %v", err)
|
||||
}
|
||||
if len(queue.Incomes) != 1 {
|
||||
t.Fatalf("owed incomes = %d, want 1", len(queue.Incomes))
|
||||
}
|
||||
return queue.Incomes[0]
|
||||
}
|
||||
|
||||
// statusOf reads the export status recorded for one income.
|
||||
func statusOf(t *testing.T, pay *payments.Service, ledgerID uuid.UUID) payments.MyNalogIncome {
|
||||
t.Helper()
|
||||
in, ok, err := pay.MyNalogIncomeAt(context.Background(), ledgerID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("income %s: ok=%v err=%v", ledgerID, ok, err)
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
// TestMyNalogFilesIncomeAndAnnulsOnRefund walks the happy path end to end: a rouble purchase is
|
||||
// filed with the tax service under a name carrying its order marker, and a later refund annuls that
|
||||
// receipt without any further human involvement.
|
||||
func TestMyNalogFilesIncomeAndAnnulsOnRefund(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTaxStub(t)
|
||||
srv, pay, exp, _ := taxServer(t, st)
|
||||
signInTax(t, srv)
|
||||
|
||||
_, orderID := fundTaxableOrder(t, pay, 100, 14900)
|
||||
owed := onlyIncome(t, pay)
|
||||
name := exp.ReceiptName(owed)
|
||||
// The marker is the TAIL of the order id: a UUIDv7's leading digits are a millisecond clock and
|
||||
// would be shared by every order placed within about a minute, which would make the recovery
|
||||
// probe unable to tell two purchases apart.
|
||||
marker := orderID.String()[len(orderID.String())-8:]
|
||||
if !strings.Contains(name, marker) {
|
||||
t.Fatalf("service name %q does not carry the order marker %q — the recovery probe depends on it", name, marker)
|
||||
}
|
||||
|
||||
sum, err := exp.RunBatch(ctx, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
if sum.Registered != 1 {
|
||||
t.Fatalf("registered = %d, want 1 (%+v)", sum.Registered, sum)
|
||||
}
|
||||
receipt, ok := st.receiptFor(name)
|
||||
if !ok {
|
||||
t.Fatalf("the stub cabinet has no receipt for %q", name)
|
||||
}
|
||||
if got := statusOf(t, pay, owed.LedgerID); got.Status != payments.MyNalogSent || got.ReceiptUUID != receipt {
|
||||
t.Fatalf("status = %q receipt = %q, want sent/%s", got.Status, got.ReceiptUUID, receipt)
|
||||
}
|
||||
|
||||
// Refund the order: the annulment is owed automatically.
|
||||
if _, err := pay.RefundOrderFull(ctx, orderID); err != nil {
|
||||
t.Fatalf("refund: %v", err)
|
||||
}
|
||||
sum, err = exp.RunBatch(ctx, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("run after refund: %v", err)
|
||||
}
|
||||
if sum.Cancelled != 1 {
|
||||
t.Fatalf("cancelled = %d, want 1 (%+v)", sum.Cancelled, sum)
|
||||
}
|
||||
if !st.isAnnulled(receipt) {
|
||||
t.Fatal("the receipt was not annulled at the tax service")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMyNalogProbeRecoversAnAmbiguousCall covers the failure this whole design exists for: the
|
||||
// registration call has no idempotency key, so a receipt can exist even though the call reported an
|
||||
// error. The probe must find it and record it, rather than the income being filed a second time.
|
||||
func TestMyNalogProbeRecoversAnAmbiguousCall(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTaxStub(t)
|
||||
// File the income, then answer as if the service fell over.
|
||||
st.onIncome = func(s *taxStub, name string) (int, string) {
|
||||
s.file(name)
|
||||
return http.StatusBadGateway, `{"message":"gateway"}`
|
||||
}
|
||||
srv, pay, exp, alerts := taxServer(t, st)
|
||||
signInTax(t, srv)
|
||||
fundTaxableOrder(t, pay, 100, 14900)
|
||||
owed := onlyIncome(t, pay)
|
||||
|
||||
sum, err := exp.RunBatch(ctx, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
if sum.Registered != 1 || sum.Unknown != 0 {
|
||||
t.Fatalf("registered = %d unknown = %d, want the probe to recover it (%+v)", sum.Registered, sum.Unknown, sum)
|
||||
}
|
||||
if got := statusOf(t, pay, owed.LedgerID); got.Status != payments.MyNalogSent {
|
||||
t.Fatalf("status = %q, want sent", got.Status)
|
||||
}
|
||||
if st.hits("/api/v1/incomes") == 0 {
|
||||
t.Fatal("the probe was never issued")
|
||||
}
|
||||
if alerts.saw("unknown") {
|
||||
t.Fatal("a recovered call must not alert the operator")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMyNalogUnresolvedOutcomeHaltsTheQueue asserts the rule that keeps income from being filed
|
||||
// twice: when the call fails and the probe finds nothing, the income is left unresolved and NOTHING
|
||||
// further is filed until a human has looked at it.
|
||||
func TestMyNalogUnresolvedOutcomeHaltsTheQueue(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTaxStub(t)
|
||||
st.onIncome = func(*taxStub, string) (int, string) { return http.StatusBadGateway, `{}` }
|
||||
srv, pay, exp, alerts := taxServer(t, st)
|
||||
signInTax(t, srv)
|
||||
|
||||
fundTaxableOrder(t, pay, 100, 14900)
|
||||
first := onlyIncome(t, pay)
|
||||
|
||||
sum, err := exp.RunBatch(ctx, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
if sum.Unknown != 1 || sum.Registered != 0 {
|
||||
t.Fatalf("unknown = %d registered = %d, want one unresolved (%+v)", sum.Unknown, sum.Registered, sum)
|
||||
}
|
||||
if got := statusOf(t, pay, first.LedgerID); got.Status != payments.MyNalogUnknown {
|
||||
t.Fatalf("status = %q, want unknown", got.Status)
|
||||
}
|
||||
if !alerts.saw("unknown") {
|
||||
t.Fatal("an unresolved outcome must alert the operator")
|
||||
}
|
||||
|
||||
// A second, perfectly fine purchase must NOT be filed while the first is unresolved.
|
||||
st.onIncome = nil
|
||||
fundTaxableOrder(t, pay, 50, 9900)
|
||||
sum, err = exp.RunBatch(ctx, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("second run: %v", err)
|
||||
}
|
||||
if sum.Registered != 0 {
|
||||
t.Fatalf("registered = %d while an outcome is unresolved, want 0", sum.Registered)
|
||||
}
|
||||
if st.hits("/api/v1/income") != 1 {
|
||||
t.Fatalf("income calls = %d, want no new filing attempt", st.hits("/api/v1/income"))
|
||||
}
|
||||
|
||||
// The operator asserts nothing was created; the income goes back into the queue and files.
|
||||
if err := exp.Requeue(ctx, first.LedgerID); err != nil {
|
||||
t.Fatalf("requeue: %v", err)
|
||||
}
|
||||
sum, err = exp.RunBatch(ctx, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("run after requeue: %v", err)
|
||||
}
|
||||
if sum.Registered != 2 {
|
||||
t.Fatalf("registered = %d after requeue, want both (%+v)", sum.Registered, sum)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMyNalogPausesAfterRepeatedRejections asserts the circuit breaker: an API that rejects
|
||||
// everything — the shape a format change takes — must take the rail out of service rather than
|
||||
// producing thousands of futile requests.
|
||||
func TestMyNalogPausesAfterRepeatedRejections(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTaxStub(t)
|
||||
st.onIncome = func(*taxStub, string) (int, string) {
|
||||
return http.StatusBadRequest, `{"message":"unknown field"}`
|
||||
}
|
||||
srv, pay, exp, alerts := taxServer(t, st)
|
||||
signInTax(t, srv)
|
||||
for i := 0; i < 4; i++ {
|
||||
fundTaxableOrder(t, pay, 10, 1000)
|
||||
}
|
||||
|
||||
sum, err := exp.RunBatch(ctx, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
if !sum.Paused {
|
||||
t.Fatalf("the rail did not pause after repeated rejections (%+v)", sum)
|
||||
}
|
||||
if st.hits("/api/v1/income") != 3 {
|
||||
t.Fatalf("income calls = %d, want the run to stop after 3", st.hits("/api/v1/income"))
|
||||
}
|
||||
if !alerts.saw("paused") {
|
||||
t.Fatal("a self-imposed pause must alert the operator")
|
||||
}
|
||||
|
||||
// While paused, a run does nothing at all.
|
||||
before := st.hits("/api/v1/income")
|
||||
sum, err = exp.RunBatch(ctx, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("run while paused: %v", err)
|
||||
}
|
||||
if !sum.Paused || st.hits("/api/v1/income") != before {
|
||||
t.Fatalf("a paused rail still called the service (%+v)", sum)
|
||||
}
|
||||
|
||||
// Resuming through the console lets it run again.
|
||||
st.onIncome = nil
|
||||
if code, _ := consoleDo(srv.Handler(), http.MethodPost, "http://admin.test/_gm/mynalog/resume",
|
||||
"", "http://admin.test"); code != http.StatusOK {
|
||||
t.Fatalf("resume = %d", code)
|
||||
}
|
||||
sum, err = exp.RunBatch(ctx, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("run after resume: %v", err)
|
||||
}
|
||||
if sum.Registered != 4 {
|
||||
t.Fatalf("registered = %d after resume, want 4 (%+v)", sum.Registered, sum)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMyNalogSkipsIncomeRefundedBeforeFiling asserts that a purchase the buyer got back before any
|
||||
// receipt was issued is ruled out locally: nothing is filed, nothing is annulled, and the tax
|
||||
// service is not contacted at all.
|
||||
func TestMyNalogSkipsIncomeRefundedBeforeFiling(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTaxStub(t)
|
||||
srv, pay, exp, _ := taxServer(t, st)
|
||||
signInTax(t, srv)
|
||||
callsAfterSignIn := len(st.calls)
|
||||
|
||||
_, orderID := fundTaxableOrder(t, pay, 100, 14900)
|
||||
if _, err := pay.RefundOrderFull(ctx, orderID); err != nil {
|
||||
t.Fatalf("refund: %v", err)
|
||||
}
|
||||
|
||||
sum, err := exp.RunBatch(ctx, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
if sum.NotRequired != 1 || sum.Registered != 0 {
|
||||
t.Fatalf("not required = %d registered = %d, want it ruled out (%+v)", sum.NotRequired, sum.Registered, sum)
|
||||
}
|
||||
if len(st.calls) != callsAfterSignIn {
|
||||
t.Fatalf("the tax service was contacted for an income that needed nothing: %v", st.calls[callsAfterSignIn:])
|
||||
}
|
||||
}
|
||||
|
||||
// TestMyNalogIdleRunContactsNobody asserts the property that keeps an idle installation invisible to
|
||||
// the tax service: with an empty queue a run does not even authenticate.
|
||||
func TestMyNalogIdleRunContactsNobody(t *testing.T) {
|
||||
st := newTaxStub(t)
|
||||
srv, _, exp, _ := taxServer(t, st)
|
||||
signInTax(t, srv)
|
||||
before := len(st.calls)
|
||||
|
||||
sum, err := exp.RunBatch(context.Background(), time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
if sum.Registered != 0 || sum.Cancelled != 0 {
|
||||
t.Fatalf("an idle run did something: %+v", sum)
|
||||
}
|
||||
if len(st.calls) != before {
|
||||
t.Fatalf("an idle run issued %v", st.calls[before:])
|
||||
}
|
||||
}
|
||||
|
||||
// TestMyNalogManualEntryStaysAnnullable asserts why the console asks for the receipt number rather
|
||||
// than a simple "done" tick: an income filed by hand must still have its receipt annulled
|
||||
// automatically when the money goes back.
|
||||
func TestMyNalogManualEntryStaysAnnullable(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTaxStub(t)
|
||||
srv, pay, exp, _ := taxServer(t, st)
|
||||
signInTax(t, srv)
|
||||
_, orderID := fundTaxableOrder(t, pay, 100, 14900)
|
||||
owed := onlyIncome(t, pay)
|
||||
|
||||
// The operator files it in the cabinet by hand and types the number back in.
|
||||
form := "ledger_id=" + owed.LedgerID.String() + "&receipt_uuid=hand-1"
|
||||
if code, body := consoleDo(srv.Handler(), http.MethodPost, "http://admin.test/_gm/mynalog/manual",
|
||||
form, "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "Recorded") {
|
||||
t.Fatalf("manual entry = %d: %s", code, body)
|
||||
}
|
||||
if got := statusOf(t, pay, owed.LedgerID); got.Status != payments.MyNalogSent || got.ReceiptUUID != "hand-1" {
|
||||
t.Fatalf("status = %q receipt = %q, want sent/hand-1", got.Status, got.ReceiptUUID)
|
||||
}
|
||||
|
||||
if _, err := pay.RefundOrderFull(ctx, orderID); err != nil {
|
||||
t.Fatalf("refund: %v", err)
|
||||
}
|
||||
sum, err := exp.RunBatch(ctx, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
if sum.Cancelled != 1 || !st.isAnnulled("hand-1") {
|
||||
t.Fatalf("a hand-filed receipt was not annulled (%+v)", sum)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMyNalogConsoleExportListsWhatWouldBeSent asserts the manual fallback carries exactly the
|
||||
// fields the tax cabinet's own form asks for, plus the ledger id needed to enter the receipt number
|
||||
// back afterwards.
|
||||
func TestMyNalogConsoleExportListsWhatWouldBeSent(t *testing.T) {
|
||||
st := newTaxStub(t)
|
||||
srv, pay, exp, _ := taxServer(t, st)
|
||||
fundTaxableOrder(t, pay, 100, 14900)
|
||||
owed := onlyIncome(t, pay)
|
||||
|
||||
code, body := consoleDo(srv.Handler(), http.MethodGet, "http://admin.test/_gm/mynalog.csv", "", "")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("export = %d", code)
|
||||
}
|
||||
// Parsed rather than substring-matched: the service name contains quotes, so the CSV encoding of
|
||||
// it is not the string an operator has to type.
|
||||
rows, err := csv.NewReader(strings.NewReader(body)).ReadAll()
|
||||
if err != nil {
|
||||
t.Fatalf("parse CSV: %v\n%s", err, body)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("CSV rows = %d, want a header and one income\n%s", len(rows), body)
|
||||
}
|
||||
wantHeader := []string{"operation_time", "service_name", "amount", "currency", "status", "ledger_id"}
|
||||
if !slices.Equal(rows[0], wantHeader) {
|
||||
t.Fatalf("header = %v, want %v", rows[0], wantHeader)
|
||||
}
|
||||
got := rows[1]
|
||||
if got[1] != exp.ReceiptName(owed) {
|
||||
t.Errorf("service name = %q, want exactly what would be sent (%q)", got[1], exp.ReceiptName(owed))
|
||||
}
|
||||
if got[2] != "149.00" || got[3] != "RUB" {
|
||||
t.Errorf("amount = %q %q, want 149.00 RUB", got[2], got[3])
|
||||
}
|
||||
if got[5] != owed.LedgerID.String() {
|
||||
t.Errorf("ledger id = %q, want %s — without it the receipt number cannot be entered back", got[5], owed.LedgerID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMyNalogConsoleRequiresSameOrigin asserts the export actions carry the console's only CSRF
|
||||
// defence, since a forged sign-in would hand an attacker's credentials to the rail.
|
||||
func TestMyNalogConsoleRequiresSameOrigin(t *testing.T) {
|
||||
st := newTaxStub(t)
|
||||
srv, _, _, _ := taxServer(t, st)
|
||||
for _, path := range []string{"/_gm/mynalog/login", "/_gm/mynalog/run", "/_gm/mynalog/auto"} {
|
||||
if code, _ := consoleDo(srv.Handler(), http.MethodPost, "http://admin.test"+path, "", ""); code != http.StatusForbidden {
|
||||
t.Errorf("%s without an origin = %d, want 403", path, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMyNalogBuyerMails asserts the three letters a buyer gets and that none of them is sent twice.
|
||||
func TestMyNalogBuyerMails(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTaxStub(t)
|
||||
srv, pay, exp, _ := taxServer(t, st)
|
||||
signInTax(t, srv)
|
||||
|
||||
accounts := account.NewStore(testDB)
|
||||
acc, orderID := fundTaxableOrder(t, pay, 100, 14900)
|
||||
// Derived from the account so the address is unique in the shared test database: identities are
|
||||
// unique on (kind, external_id), and a fixed literal collides with another suite's fixture.
|
||||
addr := "mynalog-" + acc.String() + "@example.test"
|
||||
if err := accounts.AttachIdentity(ctx, acc, account.KindEmail, addr, true); err != nil {
|
||||
t.Fatalf("attach email: %v", err)
|
||||
}
|
||||
// The crediting path records the outbox event the purchase letter rides.
|
||||
payload, _ := json.Marshal(map[string]any{"chips": 100, "source": "direct"})
|
||||
if err := pay.RecordPaymentEvent(ctx, acc, &orderID, "succeeded", payload); err != nil {
|
||||
t.Fatalf("record event: %v", err)
|
||||
}
|
||||
|
||||
box := &recordingMailer{}
|
||||
mailer := mynalogsync.NewMailer(pay, accounts, box, "no-reply@example.test",
|
||||
st.srv.URL, time.FixedZone("MSK", 3*60*60), zap.NewNop())
|
||||
if mailer == nil {
|
||||
t.Fatal("mailer not built")
|
||||
}
|
||||
|
||||
// 1. The purchase confirmation, which must say plainly that it is not the fiscal receipt.
|
||||
mailer.Tick(ctx)
|
||||
first := box.to(addr)
|
||||
if len(first) != 1 {
|
||||
t.Fatalf("purchase letters = %d, want 1", len(first))
|
||||
}
|
||||
if !strings.Contains(first[0].Text, "не чек") {
|
||||
t.Errorf("the purchase letter does not say it is not the receipt:\n%s", first[0].Text)
|
||||
}
|
||||
|
||||
// 2. The receipt letter, once the income is filed, carrying the printable link.
|
||||
if _, err := exp.RunBatch(ctx, time.Minute); err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
mailer.Tick(ctx)
|
||||
if got := len(box.to(addr)); got != 2 {
|
||||
t.Fatalf("letters after filing = %d, want 2", got)
|
||||
}
|
||||
if !strings.Contains(box.to(addr)[1].Text, "/api/v1/receipt/770000000000/") {
|
||||
t.Errorf("the receipt letter carries no receipt link:\n%s", box.to(addr)[1].Text)
|
||||
}
|
||||
|
||||
// 3. The annulment letter after a refund.
|
||||
if _, err := pay.RefundOrderFull(ctx, orderID); err != nil {
|
||||
t.Fatalf("refund: %v", err)
|
||||
}
|
||||
if _, err := exp.RunBatch(ctx, time.Minute); err != nil {
|
||||
t.Fatalf("run after refund: %v", err)
|
||||
}
|
||||
mailer.Tick(ctx)
|
||||
if got := len(box.to(addr)); got != 3 {
|
||||
t.Fatalf("letters after the refund = %d, want 3", got)
|
||||
}
|
||||
if !strings.Contains(box.to(addr)[2].Text, "аннулирован") {
|
||||
t.Errorf("the third letter is not the annulment notice:\n%s", box.to(addr)[2].Text)
|
||||
}
|
||||
|
||||
// Draining again must send nothing: every letter is decided exactly once.
|
||||
mailer.Tick(ctx)
|
||||
if got := len(box.to(addr)); got != 3 {
|
||||
t.Fatalf("letters after a second drain = %d, want 3 — a letter was repeated", got)
|
||||
}
|
||||
}
|
||||
|
||||
// recordingMailer captures outgoing mail instead of relaying it.
|
||||
type recordingMailer struct {
|
||||
mu sync.Mutex
|
||||
sent []account.Message
|
||||
}
|
||||
|
||||
func (m *recordingMailer) Send(_ context.Context, msg account.Message) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.sent = append(m.sent, msg)
|
||||
return nil
|
||||
}
|
||||
|
||||
// to returns the messages addressed to one recipient, in order.
|
||||
func (m *recordingMailer) to(addr string) []account.Message {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var out []account.Message
|
||||
for _, msg := range m.sent {
|
||||
if msg.To == addr {
|
||||
out = append(out, msg)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user