[recovery mode] Ведение семейных финансов на C# и Xamarin. Личный опыт

Предисловие

В данной статье пойдет речь о том, как через тернии костылей и багов я в итоге достиг приличного уровня автоматизации учета семейных финансов.

Дисклеймер: автор статьи имеет посредственные скиллы разработки, так что некоторые технические решения могут оскорбить чувства middle и senior developer-ов. 

К пониманию необходимости учета финансов я пришел в 2016 году, когда съехался со своей будущей женой. Цели учета были вполне банальные: понимать кто и сколько расходует на какие товары, выявить возможности для оптимизации расходов, а так же убедиться, что соотношение семейных расходов соразмерно соотношению размеров зарплат членов семьи. (Спойлер: разумеется, сэкономить в итоге ни на чем не получилось)

Многие советуют заводить отдельную карту для семейных покупок и перечислять на нее средства каждый месяц, но нам этот вариант не понравился. Проще контролировать расходы постфактум, а не пытаться их спрогнозировать.

Я считаю такой подход более разумным. Разумеется, мы не делим расходы абсолютно в каждом чеке из продуктового. В основном, дележке подвергаются крупные чеки из гипермаркетов на тысячи рублей. Также мы совместо финансируем коммунальные платежи, аренду и другие крупные жилищно-инфраструктурные проекты.

Версия 0

Как правило, первые попытки ведения семейного бюджета люди начинают в Excel или иных аналогах, типа Google Sheets. И мы не стали исключением.

Схема была такой. Кто-то из нас двоих в оплачивал в магазине большой чек. Далее содержимое чека вносилось в экселевский файлик, а затем суммировались ячейки с товарами, купленными на семейные нужды. Так мы понимали, кто кому сколько должен перевести денег.

Такой метод оказался весьма неудобен в эксплуатации, и вскоре я начал поиск более подходящей альтернативы.

Версия 1

Но поиск быстро зашел в тупик. Бесплатные варианты не умели делить один чек между несколькими пользователями. Да даже многопользовательский режим был далеко не везде. Платные же программы помимо факта своей платности зачастую предлагали поделиться данными о своих расходах с разработчиками. Ни платить ни делиться данными мне не хотелось.

Поэтому четко вырисовывался вариант — писать программу самому. Еще одним аргументом в пользу разработки было личное желание поизобретать велосипед и попробовать себя в качестве программиста. Я даже вынашивал идею улучшить свои жизненные условия через смену специализации с Windows-эникея на программиста.

Так как на заочном отделении в моем ВУЗе меня обучили только шлепать формы на C#, выбор стека для собственного приложения был очевиден. В качестве СУБД был выбран MSSQL Express, который хостился на домашнем сервере. Примерно за месяц был написан MVP, после чего с 1 сентября 2016 года процесс учета расходов был перенесен в свеженаписанную программу.

Параллельно с использованием программы шла ее постоянная доработка. Функционал постоянно дописывался и перекраивался. Неудачные решения выпиливались, и на их месте появлялись новые, которые в будущем могла постичь та же участь. Появлялись новые сущности, корректировался их набор параметров и логика работы, дорабатывался интерфейс. В конечном итоге был выработан наиболее удачный вариант логики приложения:

Основная сущность в программе — чек, который содержит позиции. У чека есть дата и место совершения покупки.

У каждой позиции в чеке помимо основных параметров (наименование, цена, количество, категория) есть признак «расходы на общие нужды», а также имя пользователя, который эту позицию оплатил. По этим двум важным признакам происходит расчет итоговых сумм чека на каждого члена семьи.

Любое приходно-расходное действие также генерирует операцию со знаком плюс или минус. Суммируя все операции, получаем текущее количество денег у отельно взятого пользователя.

Помимо этого существуют вспомогательные справочники магазинов, категорий товаров, географических локаций, и.т.д.

Вокруг основного функционала и данных я начал реализовывать разные полезные фичи, такие как например:

  • Отчетность. Были реализованы различные отчеты, как например статистика трат по категориям товаров или статистика трат в разрезе магазинов.

  • Отслеживание текущего баланса. Всегда хотел видеть количество денег, как у игрового персонажа :)

  • Шаблоны чеков. Ввод однотипных расходов, как например такси, кофе, проезд в метро, был значительно упрощен таким путем. При выборе шаблона автоматически подставлялись все данные из соответствующего старого чека. Оставалось только поменять сумму и сохранить новый чек.

В какой-то момент появилось и устоявшееся название, которое сохранилось и по сей день — Домашняя бухгалтерия.

Немного скриншотов того, как выглядела первая версия Домашней бухгалтерии на излете жизни.

Главное окно программыГлавное окно программыФорма добавления чековФорма добавления чековОтчет по структуре расходовОтчет по структуре расходов

После этого дальнейшее развитие программы сильно замедлилось. 2019–2020 годы прошли без каких-либо значимых изменений. Лишь изредка приходилось править баги. Все новые идеи откладывались в долгий ящик, мотивации кодить не было, и скоро поясню почему.

Небольшое лирическое отступление

За 4 года нам с женой уже порядком поднадоело вносить портянки магазинных чеков вручную. Технологии шагнули далеко вперед, и концепция ручного ввода безнадежно устарела.

Виделось два очевидных вектора автоматизации учета:

Но такие глобальные доработки упирались в один очень неприятный факт:

a2903554ea99d97993e1222b213f7b60.jpg

Программирование в гуманитарном ВУЗе, да еще и на заочном отделении преподают весьма посредственно. Поэтому весь код представлял из себя мешанину из if/else, непонятных переменных и sql-запросов, объединенных с переменными. ООП? MVC? MVVM? Паттерны проектирования? Да хотя бы банально писать комментарии? Не, не слышали. После многократных переделок код стал похож на эдакого Франкенштейна. Надо ли говорить, что ориентироваться в нем было крайне сложно?

И вот под конец бесполезно потраченных новогодних выходных 2021 года я снова взялся за Visual Studio.

8b564d9ee4d98e0b5317bf061c7c98a4.jpg

Версия 2 — текущая

Реализовывать новый функционал, как это сейчас принято, я решил через отдельный микросервис с собственной базой данных. Он должен был быть опубликован в интернет и получать данные от приложения-компаньона на смартфоне.

Опыта в мобильной разработке у меня было ровно ноль. Тратить месяцы на изучение Java или Kotlin не хотелось. И поэтому, я опять же пошел по легкому пути — Xamarin. В интернете нашел учебный шаблон приложения с вкладками и принялся его погонять под свои нужды, в процессе активно используя Google и Stackoverflow. Как только стало понятно, что мои планы на мобильное приложение реализуемы, параллельно начал разработку серверной части. Тут опять же был выбран C# и .Net Core. Мы же в 2021 живем как-никак, нужно уметь в кроссплатформенность.

Где-то около месяца у меня ушло на то, чтобы создать прототипы приложения и серверной части, которые могли общаться друг с другом. А далее снова начался процесс тестирования на проде и череда новых версий.

Мобильное приложение — CostsExporter

Мобильное приложение-компаньон умеет делать три вещи:

  • сканировать и отправлять на сервер QR-код с чека.

  • проверять inbox на предмет новых SMS от банка и так же отправлять их на сервер.

  • отправлять на сервер введенный расход в виде сумма: описание.

Для сканирования QR-кодов с чека я использую библиотеку ZXing.Net.Mobile. Примерно в 90% случаев QR-код удается отсканировать. Но если QR-код нечеткий или чек непропечатался/истерся, то тут все печально. Встроенное приложение камеры, как и официально приложение ФНС России, вполне справляются даже в таких случаях. Специально для этого пришлось добавить вариант с ручным ввводом содержимого QR-кода. Если кто может посоветовать вариант лучше, я был бы очень благодарен.

Для чтения SMS в памяти телефона постоянно висит Foreground Service, который дергает метод проверки входящих SMS каждые 3 минуты. После продолжительного штудирования интернетов я сделал вывод, что только таким путем можно обеспечить приемлемую работоспособность этого процесса, ибо своевольный Android может выгружать любые активности, когда ему вздумается.

Все SMS/QR-коды/ручные записи перед отправкой складываются в локальную базу SQLite, где так же хранится дата создания и статус отправки на сервер. При повторном сканировании SMS/QR-кода дубли будут отброшены.

Все сообщения отправляются в json-формате. JSON выглядит примерно так:

{
  "TypeId": "2",
  "Payload": "02.03.2021 9:54:33;Raiffeisen;Karta *****. Pokupka 137.00 RUB. CITYMOBIL.. Balans 4842.16 RUB. 02.03.2021"0Э
  "User": "Pavel",
  "Secret": "SomeSecret"
}

Далее немного скринов приложения:

Серверная часть — CostsReceiver

CostsReceiver представляет из себя Web-сервер на Asp.Net Core запущеный в Docker-контейнере на домашнем сервере. Наружу он опубликован через KeenDNS (сервис для владельцев роутеров Keenetic), который позаботился и о доменном имени и SSL сертификате.

CostsReceiver парсит прилетающие к нему json-ы и складывает результат в таблицу пакетов для обработки.

Каждые 3 минуты в отдельном потоке запускается воркер, который пытается обработать все необработанные входящие пакеты. Параметр TypeID подсказывает серверу, как обрабатывать полученный пакет. Если TypeID равен единице, то это QR-код, если 2 — это SMS, если 3 — то это расход, введенный вручную.

С SMS все просто — текст прогоняется через регулярное выражение. Из него вычленяются название торговой точки, время и сумма покупки. Непрошедшие регулярку сообщения удаляются.

С QR-кодом все интереснее. После непродолжительного поиска я нашел статью Николая Валиотти о реализации парсера чеков на Python. Переписать код на C# было несложно.

Сперва происходит аутентификация и запрос токена. Затем с этим токеном и содержимым QR кода воркер постоянно стучится на сервер ФНС, пока сервер не выдаст ему в ответ содержимое чека в json формате. Как быстро чек появляется на сервере ФНС, судя по всему, зависит от типа кассы. Это может занять от пары минут до пары дней. Из полученного json-а также выдергивается название торговой точки, время покупки и все позиции чека.

Возможно, кому-то этот код будет полезен

public class CheckRequesterByQR
    {

        private Envelope _envelope = new Envelope();
        public CheckRequesterByQR(Envelope envelope)
        {
            _envelope = envelope;

        }


        public async Task GetCheckByQR()
        {
            string sessionId;
            if (Variables.sessionID == null)
            {
                JObject responseSessId = JObject.Parse(await GetSessionID());
                sessionId = (string)responseSessId["sessionId"];
                Variables.sessionID = sessionId;
            }
            else sessionId = Variables.sessionID;

            try
            {
                JObject responseTicketId = JObject.Parse(await GetTicketID(sessionId, _envelope.Payload));
                var ticketId = (string)responseTicketId["id"];
                if (ticketId != null)
                {
                    JObject responseCheck = JObject.Parse(await GetCheck(sessionId, ticketId));
                    var check = responseCheck.ToString();
                    return check;
                }
                else return null;
            }
            catch (Exception ex)
            {
                if (ex.Message == "HTTP ERROR: Unauthorized" && Variables.sessionID != null)
                {
                    Variables.sessionID = null;
                    throw new Exception("HTTP ERROR: Unauthorized. Will try to renew session token next time.");
                }
                else
                {
                    throw ex;
                }
                
            }        
           
        }

        private HttpClient GetClient()
        {
            HttpClient client = new HttpClient();
            client.DefaultRequestHeaders.Add("Accept", "application/json");
            client.DefaultRequestHeaders.Add("Host", "irkkt-mobile.nalog.ru:8888");
            client.DefaultRequestHeaders.Add("Accept", "*/*");
            client.DefaultRequestHeaders.Add("Device-OS", "iOS");
            client.DefaultRequestHeaders.Add("Device-Id", "7C82010F-16CC-446B-8F66-FC4080C66521");
            client.DefaultRequestHeaders.Add("clientVersion", "2.9.0");
            client.DefaultRequestHeaders.Add("Accept-Language", "ru-RU;q=1, en-US;q=0.9");
            client.DefaultRequestHeaders.Add("User-Agent", "billchecker/2.9.0 (iPhone; iOS 13.6; Scale/2.00)");
            client.Timeout = TimeSpan.FromSeconds(7);
            return client;
        }

        private async Task GetSessionID()
        {
            HttpClient client = GetClient();

            var env_inn = Environment.GetEnvironmentVariable("INN");
            var env_pass = Environment.GetEnvironmentVariable("PASSWD");

            var payload = new
            {
                inn = env_inn,
                client_secret = "IyvrAbKt9h/8p6a7QPh8gpkXYQ4=",
                password = env_pass
            };

            try
            {
                var response = await client.PostAsync("https://irkkt-mobile.nalog.ru:8888/v2/mobile/users/lkfl/auth",
                new StringContent(
                    JsonConvert.SerializeObject(payload),
                    Encoding.UTF8, "application/json"));

                if (response.IsSuccessStatusCode)
                {
                    return await response.Content.ReadAsStringAsync();
                }
                else
                {
                    throw new Exception("HTTP ERROR: " + response.StatusCode);
                }

            }
            catch (Exception ex) when (ex is TaskCanceledException || ex is OperationCanceledException)
            {
                return null;
            }
        }

        private async Task GetTicketID(string sessionId, string qrcode)
        {
            HttpClient client = GetClient();
            client.DefaultRequestHeaders.Add("sessionId", sessionId);

            var payload = new
            {
                qr = qrcode
            };

            try
            {
                var response = await client.PostAsync("https://irkkt-mobile.nalog.ru:8888/v2/ticket",
                new StringContent(
                    JsonConvert.SerializeObject(payload),
                    Encoding.UTF8, "application/json"));

                if (response.IsSuccessStatusCode)
                {
                    return await response.Content.ReadAsStringAsync();
                }
                else
                {
                    throw new Exception("HTTP ERROR: " + response.StatusCode);
                }

            }
            catch (Exception ex) when (ex is TaskCanceledException || ex is OperationCanceledException)
            {
                return null;
            }

        }

        private async Task GetCheck(string sessionId, string ticket)
        {
            HttpClient client = GetClient();
            client.DefaultRequestHeaders.Add("sessionId", sessionId);

            try
            {
                var response = await client.GetAsync("https://irkkt-mobile.nalog.ru:8888/v2/tickets/" + ticket);

                if (response.IsSuccessStatusCode)
                {
                    return await response.Content.ReadAsStringAsync();
                }
                else
                {
                    throw new Exception("HTTP ERROR: " + response.StatusCode);
                }

            }
            catch (Exception ex) when (ex is TaskCanceledException || ex is OperationCanceledException)
            {
                return null;
            }
        }

    }

Результатом работы CostsReceiver являются черновики чеков. Они уже содержат в себе дату чека, место покупки, сумму и, если это был QR-код, то и позиции в чеке. К ним можно получить доступ из основного приложения. Черновик чека — это еще не полноценный чек. Для каждой позиции в черновике нужно указать категорию. Если не удалось определить магазин, его также нужно указать вручную. Но это все равно гораздо быстрее, чем вводить данные с нуля.

Нормализация данных

Попутно пришлось решить пару проблем по данной части.

Во-первых — это преобразование названий многочисленных ООО-шек в уже предопределенные названия магазинов. При обработке пакета воркер производит замену названия типа ОБЩЕСТВО С ОГРАНИЧЕННОЙ ОТВЕТСТВЕННОСТЬЮ «АЛЬФА ПЕНЗА» на хорошо известное название «Красное и Белое».

1403fbfea6f4fd616f46f349cc118d9a.JPG

Аналогичным путем решается вопрос нормализации названий позиций в чеке. Думаю, каждый не раз задавался вопросом, что такое, например, *KON.Печ-сэн.СУП.КОН.mars.сл288 г или *Р.ФР.Бат.КР.тв/как/вз.кар.180 г. Когда я расшифровываю эту позицию в чеке, она записывается в базу, и в следующий раз CostsReceiver автоматом заменит белиберду на понятное название и подставит категорию товара.

Так же CostsReceiver автоматически пытается подобрать соответствующий шаблон для черновика чека на основании заранее предопределенных правил. Если таковой находится, то добавить чек можно нажатием одной кнопки. Правила для шаблона нужно задать самому.

Три из пяти расходов можно одобрить нажанием одной кнопкиТри из пяти расходов можно одобрить нажанием одной кнопки

Домашняя бухгалтерия v2

Но все это было бы невозможно без переписывания десктопной версии «Домашней бухгалтерии». К марту руки дошли и до нее.

В этот раз было решено отказаться от безнадежно устаревшего WinForms и выбрать что-то посовременнее. Выбор пал на WPF. Было очень непривычно формошлепить в xaml, но после пары форм дело пошло быстрее. Еще через месяц активного кодинга большая часть старого кода была переписан с нуля с сохранением логики.

К написанию второй версии я подошел уже с некоторым багажом знаний и опыта в программировании. Теперь в нем стало немного больше ООП. Появились классы, отражающие сущности из БД, методы доступа к данным были отделены от логики форм. Попутно была реализована интеграция с CostsReceiver и разнообразные мелкие улучшения.

Снова скриншотики:

Новое главное окно программыНовое главное окно программыВвод нового чекаВвод нового чекаКрасивый график текущего балансаКрасивый график текущего балансаИ визуализация распределения расходов по категориямИ визуализация распределения расходов по категориям

В конечном итоге удалось существенно сократить время на занесение расходов. Так же теперь нет и необходимости заносить расходы каждый день из боязни что-то забыть или пропустить. Переписывание кода с нуля открыло дорогу к реализации многих идей по учету доходов и расходов, о которых я раньше только мечтал.

Из глобальных доработок в планах осталось:

  • добавление мультивалютности

  • упрощение заполнение чеков в туристических поездках, чтобы не пересчитывать вручную цену каждой позиции на курс

  • возможно, прикрутить хранение сканов гарантиек и чеков на разную технику

  • допилить учет кредитов/ипотек. Тут пока нет четкого понимания ввиду сложности рассчетов ежемесячных платежей и остатка долга

  • в перспективе добавить учет ИИС и активов в валюте (когда они появятся)

UPD: так как черновик статьи появился несколько месяцев назад, большая часть планов уже реализованы. В настоящий момент я планирую на какое-то время отойти от активного напиливания новых багов фичей и заняться полировкой существующего функционала.

Выводы

Сложно однозначно ответить на вопрос, как именно нам помогло как-то детальное знание структуры семейных и личных расходов. В экономическом плане скорее всего никак. Больше экономить на основании полученных данных мы конечно же не стали.

В историческом плане бесспорно будет интересно лет через 10–20 сравнить цены и запилить аналитический пост на тему очередного пробитого дна. Наибольший профит для себя я вижу в новом хобби и каком-никаком опыте программирования, который может пригодится мне в будущем.

© Habrahabr.ru