Неофициальный Go API клиент lknpd.nalog.ru ("Мой Налог")
Позволяет автоматизировать отправку информации о доходах для самозанятых, получать информацию о созданных чеках и удалять их. Поддерживается аутентификация по ИНН и паролю, а также по номеру телефона.
Go-порт PHP-библиотеки shoman4eg/moy-nalog. Структура клиента повторяет google/go-github.
go get github.com/shoman4eg/go-moy-nalogТребуется Go 1.25 или новее.
import "github.com/shoman4eg/go-moy-nalog/moynalog"// Клиент с настройками по умолчанию
client := moynalog.NewClient()
// Свой *http.Client — например, с прокси и таймаутом
proxyURL, _ := url.Parse("http://12.34.56.78:3128")
httpClient := &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)},
}
client := moynalog.NewClient(moynalog.WithHTTPClient(httpClient))
// Другой endpoint или версия API (по умолчанию https://lknpd.nalog.ru/api и v1)
client := moynalog.NewClient(
moynalog.WithEndpoint("https://lknpd.nalog.ru/api"),
moynalog.WithVersion("v1"),
moynalog.WithUserAgent("my-app/1.0"),
)Все таймауты и ретраи транспортного уровня задаются через ваш *http.Client —
библиотека своего таймаута не навязывает. Каждый метод принимает
context.Context, отмена контекста прерывает запрос.
deviceId вычисляется один раз при создании клиента и дальше не меняется:
API привязывает чеки и refresh-токен к устройству, с которого они созданы.
// По умолчанию — PlatformIDStrategy, deviceId стабилен для одной машины
client := moynalog.NewClient()
// Зафиксировать конкретный deviceId
client := moynalog.NewClient(moynalog.WithDeviceID("abcdefghijklmnopqrstu"))
// Полезно, если вы обслуживаете несколько самозанятых:
// свой стабильный deviceId на каждого пользователя
client := moynalog.NewClient(
moynalog.WithDeviceIDGenerator(moynalog.NewStaticDeviceIDGenerator("example id")),
)
// Случайный deviceId на каждый запуск
client := moynalog.NewClient(
moynalog.WithDeviceIDGenerator(moynalog.NewRandomDeviceIDGenerator()),
)
// Своя стратегия
client := moynalog.NewClient(
moynalog.WithDeviceIDGenerator(moynalog.NewDeviceIDGenerator(
moynalog.IDStrategyFunc(func() (string, error) {
return "my raw material", nil
}),
moynalog.WithIDLength(21),
moynalog.WithIDLowercase(true),
)),
)
// Или полностью свой генератор
client := moynalog.NewClient(
moynalog.WithDeviceIDGenerator(moynalog.DeviceIDFunc(func() (string, error) {
return loadDeviceIDFromDB()
})),
)Логирование включается своим http.RoundTripper — библиотека не тянет
логгер в зависимости.
type loggingTransport struct {
base http.RoundTripper
logger *slog.Logger
}
func (t *loggingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
resp, err := t.base.RoundTrip(req)
if err != nil {
t.logger.Error("request failed", "method", req.Method, "url", req.URL, "err", err)
return nil, err
}
t.logger.Info("request", "method", req.Method, "url", req.URL, "status", resp.StatusCode)
return resp, nil
}
client := moynalog.NewClient(moynalog.WithHTTPClient(&http.Client{
Transport: &loggingTransport{base: http.DefaultTransport, logger: logger},
}))Не логируйте заголовок Authorization и тело запросов аутентификации — там
токены и пароль.
При аутентификации методами CreateAccessToken (по ИНН и паролю) или
CreateAccessTokenByPhone (по номеру телефона) вместе с токеном доступа
возвращается токен обновления (refreshToken) с неограниченным сроком
действия. Сохраните весь *AccessToken целиком и переиспользуйте его через
WithToken.
При повторном вызове
CreateAccessTokenиCreateAccessTokenByPhoneпредыдущий accessToken становится недействительным.
Клиент сам обновляет протухший токен: при ответе 401 он вызывает
Auth.Refresh и повторяет запрос (не более 2 раз). Актуальный токен можно
забрать методом Token() и сохранить.
Если Вам нужно восстановить пароль от сервиса "Мой налог", это возможно сделать только через "Личный кабинет налогоплательщика". Аккаунты на обоих сервисах одинаковые.
client := moynalog.NewClient()
token, _, err := client.Auth.CreateAccessToken(ctx, username, password)
if err != nil {
if errors.Is(err, moynalog.ErrUnauthorized) {
log.Print("неверные логин или пароль")
}
return err
}
// Аутентифицированный клиент
client = client.WithToken(token)Сохранить и восстановить токен между запусками:
raw, err := json.Marshal(client.Token())
// ...
token := new(moynalog.AccessToken)
if err := json.Unmarshal(raw, token); err != nil {
return err
}
client := moynalog.NewClient().WithToken(token)Аутентификация по номеру телефона происходит в 2 шага:
- Запросите SMS с кодом подтверждения и сохраните возвращённый challengeToken;
- Обменяйте номер телефона, challengeToken и код подтверждения на accessToken.
Внимание: запрос нового кода подтверждения возможен только если предыдущий код истёк (2 минуты), или по предыдущему коду произошла успешная аутентификация. Повторная отправка выпущенного кода невозможна, только одновременно с созданием нового.
// Шаг 1. Запросить SMS
client := moynalog.NewClient()
challenge, _, err := client.Auth.CreatePhoneChallenge(ctx, "79000000000")
if err != nil {
return err
}
// challenge.ChallengeToken — "00000000-0000-0000-0000-000000000000"
// challenge.ExpireDate — moynalog.Time
// challenge.ExpireIn — 120
// Шаг 2. Обменять код из SMS на токен
token, _, err := client.Auth.CreateAccessTokenByPhone(
ctx,
"79000000000", // Номер телефона
challenge.ChallengeToken, // challengeToken из шага 1
"123456", // Код из СМС
)
if err != nil {
return err
}
client = client.WithToken(token)created, _, err := client.Income.CreateItem(
ctx,
"Предоставление информационных услуг #970/2495", // Наименование
decimal.NewFromFloat(1800.30), // Стоимость в рублях
decimal.NewFromInt(1), // Количество
)
if err != nil {
return err
}
// UUID чека для операций запроса данных чека или его отмены
receiptUUID := created.ApprovedReceiptUUIDcreated, _, err := client.Income.Create(ctx, &moynalog.IncomeCreateRequest{
Services: []moynalog.IncomeServiceItem{
{
Name: "Предоставление информационных услуг #970/2495",
Amount: decimal.NewFromFloat(1800.30),
Quantity: decimal.NewFromInt(1),
},
{
Name: "Предоставление информационных услуг #971/2495",
Amount: decimal.NewFromInt(900),
Quantity: decimal.NewFromInt(2),
},
},
// Дата продажи, по умолчанию — сейчас
OperationTime: time.Date(2020, time.December, 31, 12, 12, 0, 0, time.Local),
})TotalAmount считает сама библиотека как сумму Amount × Quantity по всем
позициям. Суммы — decimal.Decimal из
shopspring/decimal, чтобы не терять
копейки на float.
Часовой пояс берётся из переданного time.Time: используйте
time.Local, time.LoadLocation("Europe/Kaliningrad") или нужную зону явно.
// По умолчанию физ. лицо без указания контактных данных
counterparty := &moynalog.IncomeClient{}
// Или физ. лицо с указанием контактных данных
counterparty := &moynalog.IncomeClient{
ContactPhone: "+79009000000",
DisplayName: "Вася Пупкин",
IncomeType: moynalog.IncomeTypeIndividual,
Inn: "390000000000", // ИНН физ. лица (12 символов)
}
// Или юр. лицо (ИП, ООО и т.п.)
counterparty := &moynalog.IncomeClient{
DisplayName: "ИП Вася Пупкин Валерьевич",
IncomeType: moynalog.IncomeTypeLegalEntity,
Inn: "7700000000", // ИНН юр. лица (10 символов)
}
// Или иностранная организация
counterparty := &moynalog.IncomeClient{
DisplayName: "Facebook Inc.",
IncomeType: moynalog.IncomeTypeForeignAgency,
Inn: "9909000000",
}
created, _, err := client.Income.Create(ctx, &moynalog.IncomeCreateRequest{
Services: []moynalog.IncomeServiceItem{{
Name: "Предоставление информационных услуг #970/2495",
Amount: decimal.NewFromFloat(1800.30),
Quantity: decimal.NewFromInt(1),
}},
Client: counterparty,
})*moynalog.Client безопасен для конкурентного использования, поэтому один
клиент можно гонять из нескольких горутин.
import "golang.org/x/sync/errgroup"
created := make([]*moynalog.IncomeCreated, len(requests))
g, gctx := errgroup.WithContext(ctx)
g.SetLimit(4) // не больше 4 запросов одновременно
for i, request := range requests {
g.Go(func() error {
income, _, err := client.Income.Create(gctx, request)
if err != nil {
return fmt.Errorf("чек %d: %w", i, err)
}
created[i] = income
return nil
})
}
if err := g.Wait(); err != nil {
return err
}Создание чека не идемпотентно: если запрос упал по таймауту, чек мог всё равно
зарегистрироваться — перед повтором сверьтесь через client.Income.List.
Счёт на оплату выставляется для оплаты по банковским реквизитам
(тип оплаты ACCOUNT).
invoice, _, err := client.Invoice.CreateItem(
ctx,
"Предоставление информационных услуг #970/2495",
decimal.NewFromFloat(1800.30),
decimal.NewFromInt(1),
)
// Или с несколькими позициями и своей датой
invoice, _, err := client.Invoice.Create(ctx, &moynalog.InvoiceCreateRequest{
Services: []moynalog.InvoiceServiceItem{{
Name: "Предоставление информационных услуг #970/2495",
Amount: decimal.NewFromFloat(1800.30),
Quantity: decimal.NewFromInt(1),
}},
OperationTime: operationTime,
})Методы
Invoice.Cancel()иInvoice.UpdatePaymentInfo()не реализованы в API и возвращаютmoynalog.ErrNotImplemented.
// Получить список чеков (по умолчанию 100 последних)
incomes, _, err := client.Income.List(ctx, nil)
// Фильтрация
incomes, _, err := client.Income.List(ctx, &moynalog.IncomeListOptions{
From: moynalog.NewTime(from), // начало периода (по умолчанию: нет)
To: moynalog.NewTime(to), // конец периода (по умолчанию: нет)
Offset: 10, // смещение для пагинации (по умолчанию: 0)
Limit: 25, // количество результатов (по умолчанию: 100, максимум: 100)
BuyerType: moynalog.BuyerTypePerson, // тип покупателя (по умолчанию: нет)
ReceiptType: moynalog.ReceiptTypeRegistered, // тип чека (по умолчанию: нет)
SortBy: moynalog.SortByOperationTimeAsc, // сортировка (по умолчанию: SortByOperationTimeDesc)
})
for _, item := range incomes.Content {
fmt.Println(item.ApprovedReceiptUUID, item.TotalAmount, item.Cancelled())
}
if incomes.HasMore {
// есть следующая страница
}Возможные значения фильтрации:
| Константа | Значение |
|---|---|
BuyerTypePerson |
физлицо |
BuyerTypeCompany |
юрлицо |
BuyerTypeForeignAgency |
иностранная организация |
ReceiptTypeRegistered |
действителен |
ReceiptTypeCancelled |
аннулирован |
SortByOperationTimeAsc |
дата: сначала старые |
SortByOperationTimeDesc |
дата: сначала новые |
SortByTotalAmountAsc |
стоимость: по возрастанию |
SortByTotalAmountDesc |
стоимость: по убыванию |
receiptUUID := "20hykdxbp8"
// Ссылка на чек для печати
printURL, err := client.Receipt.PrintURL(ctx, receiptUUID)
// Скачать печатную форму чека (PDF)
pdf, _, err := client.Receipt.Print(ctx, receiptUUID)
// Данные по чеку
receipt, _, err := client.Receipt.JSON(ctx, receiptUUID)ИНН для этих запросов берётся из профиля в токене; если профиля нет, клиент
сам сходит за ним в /user.
receiptUUID := "20hykdxbp8"
cancelled, _, err := client.Income.Cancel(ctx, &moynalog.IncomeCancelRequest{
ReceiptUUID: receiptUUID,
// Причина отмены: "Чек сформирован ошибочно"
Comment: moynalog.CancelCommentMistake,
// Или причина отмены: "Возврат средств"
// Comment: moynalog.CancelCommentRefund,
OperationTime: operationTime, // дата возврата (по умолчанию: сейчас)
RequestTime: requestTime, // дата запроса отмены (по умолчанию: сейчас)
PartnerCode: "", // код партнёра (по умолчанию: нет)
})user, _, err := client.Users.Get(ctx)tax, _, err := client.Tax.Get(ctx)// Второй аргумент — OKTMO ("" для всех регионов), третий — только оплаченные
payments, _, err := client.Tax.Payments(ctx, "", false)history, _, err := client.Tax.History(ctx, "")// Информация о задолженности
debts, _, err := client.Taxpayer.Debts(ctx)
debts.HasDebts // bool — есть ли задолженность
debts.TotalUnpaid // decimal.Decimal — сумма неоплаченного
debts.Debts // decimal.Decimal
// Налоговый бонус (вычет) и лимиты годового дохода
bonus, _, err := client.Taxpayer.Bonus(ctx)
bonus.BonusAmount // остаток бонуса
bonus.TotalIncomeAmount // доход за текущий год
bonus.AnnualIncomeThreshold // годовой лимит (2 400 000)
bonus.AvailableIncomeToExceedThreshold // сколько осталось до лимита
bonus.AnnualIncomeStatus // string — например, "NORMAL"
// Разбивка дохода по годам: map[string]*AnnualIncome
for year, annual := range bonus.TotalIncomeByYears {
fmt.Println(year, annual.TotalIncomeAmount)
}// Список сохранённых способов оплаты
accounts, _, err := client.PaymentType.Table(ctx)
// Избранный (по умолчанию) способ оплаты, либо nil
favorite, _, err := client.PaymentType.Favorite(ctx)Ошибки API оборачиваются в *moynalog.ErrorResponse и разворачиваются в
одну из sentinel-ошибок, так что работают оба способа:
user, resp, err := client.Users.Get(ctx)
switch {
case errors.Is(err, moynalog.ErrUnauthorized): // 401
case errors.Is(err, moynalog.ErrValidation): // 400
case errors.Is(err, moynalog.ErrForbidden): // 403
case errors.Is(err, moynalog.ErrNotFound): // 404
case errors.Is(err, moynalog.ErrClient): // 406
case errors.Is(err, moynalog.ErrPhone): // 422
case errors.Is(err, moynalog.ErrServer): // 500
case errors.Is(err, moynalog.ErrUnknown): // прочее
}
var errResp *moynalog.ErrorResponse
if errors.As(err, &errResp) {
fmt.Println(errResp.StatusCode(), errResp.Code, errResp.Message, errResp.AdditionalInfo)
}
// resp — обёртка над *http.Response, возвращается и вместе с ошибкойПроблема #47: Не приходят СМС для получения токена
- Описание: Проблемы с получением СМС-кода для авторизации
- Решение: Проблема связана с API сервиса nalog.ru, временная недоступность сервиса отправки СМС
Проблема #22: Авторизация по телефону или внешним ключам
- Описание: Невозможность авторизоваться без пароля (только по номеру телефона)
- Решение: Для получения пароля нужно восстановить его через веб-интерфейс "Мой налог"
Проблема #49: Токен истекает несмотря на наличие RefreshToken
- Описание: RefreshToken не срабатывает для получения нового токена
- Настоящая причина: Токен API "Мой налог" привязывается к конкретному IP-адресу или Device ID, с которого он был сгенерирован
- Решение: Токен нужно генерировать на нужном окружении. Токен, созданный с одного IP-адреса/Device ID, не будет работать с другого. Можно попробовать использовать в обоих окружениях
NewStaticDeviceIDGenerator, чтобы удостовериться, что refresh-токен привязывается к IP, с которого сделан запрос - Рекомендация: Генерировать токен на том же сервере, с которого будут выполняться API-запросы
Проблема #21: Ошибка Could not resolve host: lknpd.nalog.ru
- Описание: Невозможность резолва DNS для API сервера
- Решение: Временная проблема на стороне сервера или DNS. Проверить настройки DNS на сервере
- Описание: Одна из причин — на одном сервере обслуживается множество пользователей и Device ID одинаковый для всех. Можно получить такой ответ:
{
"code": "authentication.failed",
"message": "Устройство <GENERATED DEVICE ID> для пользователя <INN> не может быть зарегистрировано/обновлено. Так как устройство заблокировано. Причина: Согласно обращению УОК о неправомерном доступе к ЛК",
"additionalInfo": {}
}- Решение: Использовать отдельный Device ID на каждого пользователя:
client := moynalog.NewClient(
moynalog.WithDeviceIDGenerator(moynalog.NewStaticDeviceIDGenerator("<example id>")),
)make init # установить golangci-lint в ./bin
make format # gofumpt + goimports + gci
make lint # golangci-lint + go-consistent
make test # go test -race -cover ./...
make check # всё сразу, без изменения файлов — то же, что гоняет CI
make help # список всех целейПодробнее — в CONTRIBUTING.md.
PHP-реализация: shoman4eg/moy-nalog
Статья на Habr: Автоматизация для самозанятых: как интегрировать налог с IT проектом
Реализация на JS: alexstep/moy-nalog
Если этот проект поможет Вам сократить время разработки, вы можете угостить меня чашкой кофе :)
The MIT License (MIT). Please see License File for more information.