Разработка бэкенда банковского приложения

Поздравляем! Вы уже изучили все темы образовательного блока и сейчас подходите к заключительному заданию. Это уже четвёртое итоговое задание, вы наверняка всё уже знаете, а если нет — коротко напомним, в чём тут суть.

Итак: эта задача похожа на те, что решают Python-разработчики на практике. Она поможет вам повторить пройденный материал и применить полученные знания.

Эта задача состоит из нескольких блоков:

  1. Контекст. В реальной жизни задача не существует сама по себе, а возникает из определённого бизнес-контекста. Поэтому мы добавили небольшое описание трансформации задачи, от первого запроса до её финального вида.
  2. Подсказки. Помощь в решении, если зайдёте в тупик. Комментарии к ключевым моментам решения задачи в формате FAQ.
  3. Разбор. Подробный разбор задачи от автора, специалиста из Яндекса: от составления алгоритма решения до реализации и пояснения каждого блока кода. Открывается после первого успешно пройденного теста.

Важно: эта задача «со звёздочкой». Она посложнее предыдущих. Если не справитесь — ничего страшного: в программировании редко что-то получается с первого раза. Сделайте паузу, перечитайте теорию и возвращайтесь с новыми силами и хорошим настроением.

Повторите:

  • Объектную модель Python. Классы, поля и методы.
  • Волшебные методы. Переопределение методов. Наследование.
  • Модель исключений в Python.
  • Генераторы, декораторы.

Изучите дополнительно:

  • Класс Enum для работы с ограниченными множествами.
  • Декоратор dataclass для классов-хранилищ.
  • Организацию класса ошибок.

И ещё один совет напоследок: рекомендуем работать с задачей последовательно.

  1. Изучите контекст.
  2. Ознакомьтесь с условием задачи и пожеланиями.
  3. Попробуйте решить её самостоятельно в Яндекс Контесте.
  4. Если понадобится помощь — обратитесь к подсказкам. Если совсем тяжко — изучите разбор решения и позже попробуйте решить задачу самостоятельно.
  5. Когда решите задачу, сравните свой вариант с решением от специалиста.

Желаем удачи!

Чтобы добавить в заметки выделенный текст, нажмите Ctrl + E
Изучить контекстПогружение в условия
Решить задачуПерейти в Яндекс.Контест
Взять подсказкуЕсли возникли сложности
Посмотреть разборПолный разбор экспертом

Быстрая навигация:

От автора

Цитата автора
Аватар Максима Ломакина

Максим Ломакин

Тимлид в Яндекс Учебнике. Ревьюер задачи.

Привет!

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

Удачи!

О финальной задаче

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

В основе бизнес-логики приложения выделены следующие пять классов, из которых будут заводиться реальные объекты:

  • Bank.
  • Account.
  • Card.
  • Transaction.
  • User.

Вот подробнее табличка о задаче каждого класса и функциях:

Название класса

За что отвечает

Сценарии пользователей/банка

Bank

Выпускает банковские продукты и хранит информацию обо всём, что было когда-либо выпущено

  • Выпустить карту
  • Посмотреть все транзакции в банке

Account

Хранит номер счёта, баланс, кто владелец

Сard

Отвечает за проведение операций по карте и хранит информацию о карте

  • Посмотреть информацию по карте
  • Посмотреть баланс
  • Пополнить счёт
  • Оплатить покупку
  • Перевести деньги на другую карту
  • Посмотреть историю транзакций

Transaction

Хранит информацию о транзакции

User

Данные пользователя, карты, счёта, ПИН-код приложения

  • Поменять ПИН-код

Задача кажется сложной, поэтому лучше всего работать последовательно, шаг за шагом добавляя новые функции:

  1. Сначала наладить выпуск карт в целом.
  2. Затем добавить базовые функции для использования карт (например, оплаты, переводы).
  3. Под предпочтения пользователей выпустить несколько банковских продуктов в виде новых типов карт.
  4. Организовать поддержку работы приложения, настроить обработку исключений.

В итоге у вас получится продуманная симуляция банковского приложения, реализованная с помощью принципов ООП. При желании можете добавить дополнительные функции и положить результат задачи к себе на GitHub!

струкрура задачи

Задачка объёмная — поэтому, чтобы вы сфокусировались на проработке принципов ООП, вам будут помогать заготовленные шаблоны. Например, ниже шаблон для метода get_card_info из первой задачи. Вся текстовая заготовка уже есть, вам нужно проставить наполнение.

1   def get_card_info(self, fields: list = None):
2       #ToDo: добавьте необходимую информацию для реализации метода
3        user = self.account.owner
4        data = {
5            "bank_name": f"Банк:          {}",
6            "bank_bic": f"БИК банка:     {}",
7            "card_id": f"Карта #{}",
8            "user_id": f"Пользователь:  {}{} {}",
9            "phone": f"Телефон:       {}",
10            "pan": f"PAN:           {}",
11            "acc_id": f"Счёт:          {}",
12            "payment_system": f"Плат. система: {}",
13            "currency": f"Валюта:        {}",
14            "status": f"Статус:        {}",
15            "issue_date": f"Выпуск:        {}",
16            "expiry_date": f"Срок:          {}",
17            "user_cards": f"Карты пользователя: {}",
18            "cashback_balance": f"Кешбэк:        {:.2f}₽",
19            "balance": f"Баланс:        {:.2f}₽",
20        }
21        if fields is None:
22            fields = DEFAULT_CARD_INFO_FIELDS
23        return (
24            "\n".join([data[field] for field in fields if field in data])
25            + "\n"
26            + "-" * 50
27        )

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

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

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

  • Класс Enum позволит унифицировать повторяющиеся элементы: статусы, типы операций.
  • Dataclass упростит работу с классами, которые предназначены для хранения данных.
  • Иерархическая организация класса ошибок настроит проверку и ловлю ошибок.

Всё это плюс нюансы разработки ПО для банковской сферы мы разобрали ниже. Советуем ознакомиться!

Нюансы

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

С одной стороны, вы создаёте ценность для пользователей:

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

С другой стороны, вы поддерживаете работу банковского приложения:

  • Храните всю информацию о пользователях и их операциях.
  • Выпускаете новые банковские продукты.
  • Поддерживаете работу приложения в рамках законодательства.

С третьей стороны, как разработчик, вы думаете о том, как лучше всё это сделать:

  • Выстраиваете удобную архитектуру для реализации логики приложения:
    • Проверяете данные.
    • Придерживаетесь бизнес-правил.
    • Обрабатываете исключения.
    • Проводите каждую операцию пользователя как транзакцию.
  • Тестируете работу этой системы в рамках пользовательских сценариев.

При этом в реальности требуется учесть несколько моментов, которые характерны для банковской сферы:

  • Номер счёта собирается по ГОСТу от Центробанка.
  • Номер банковской карты собирается по определённому алгоритму.
  • При работе с числами используется тип данных decimal.

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

Сбор номера счёта

Номер счёта состоит из 20 цифр, которые разбиты на пять групп:

Тип счёта

Валюта

Проверочный код

Номер филиала

Серийный номер

40817

810

4

0000

0000001

  • Тип счёта. В данном случае — операции для физических лиц (40817). Если бы это был расчётный счёт организации, число было бы другим.
  • Валюта. В нашем случае — рубли (810).
  • Номер филиала. У нашего банка филиалов нет, поэтому стоят нули.
  • Серийный номер. Порядковый номер счёта, заведённого в банке (начинаем с 0000001).
  • Проверочный код. Рассчитывается по определённому алгоритму. О нём можно почитать в инструкции от Банка России.

Сбор номера карты

Он тоже собирается по определённому алгоритму с проверкой и состоит из 16 цифр, разбитых на три группы:

BIN (идентификатор платёжной системы)

Серийный номер

Контрольная цифра

220400

000000001

5

400000

000000002

8

510000

000000003

2

  • BIN. Зафиксирован для каждой платёжной системы. Для MIR это 220400, для VISA — 400000, а для MASTERCARD — 510000.
  • Серийный номер. Это порядковый номер заведённой банковской карты.
  • Контрольная цифра. Рассчитывается по алгоритму Луна. Подробнее про алгоритм можно почитать в статье на Википедии.

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

Тип данных decimal

decimal — модуль в Python, реализующий десятичную арифметику с заданной точностью:

1from decimal import Decimal
2
3x = Decimal('0.1')
4y = Decimal('0.2')
5print(x + y)  # 0.3

Он используется вместо float. Причина проста: у float есть погрешность при расчётах десятичных дробей, которая неприемлема, когда речь идёт о денежных суммах. Например, 0.1 + 0.2 с типом float даст не 0.3, а 0.30000000000000004.

1print(0.1 + 0.2)  # 0.30000000000000004

Чтобы вам было проще, в задаче мы будем работать с float. Но если бы вы делали реальный проект для финансовой сферы, то использовали бы везде decimal.

Остальные детали касаются непосредственно определённых классов, методов и подхода к работе с исключениями. Погружаемся дальше!

Класс Enum для работы с ограниченными множествами

Часто в коде встречаются элементы, которые принимают значения из ограниченного множества или вариантов. Чтобы каждый раз не перепроверять опечатки и не вносить изменения в каждый блок кода при правках, рекомендуем использовать класс Enum.

Еnum (англ. enumeration, перечисление) — это специальный класс для создания наборов уникальных, ограниченных значений с понятными именами.

Например, вы хотите поддерживать статус карты (активна, закрыта, заблокирована). Можно реализовать это без Еnum, как в примере ниже.

1#Код без Enum
2class Card:
3    def __init__(self, status):
4        # status может быть любой строкой!
5        self.status = status
6
7card = Card("Active")
8
9if card.status == "Active":
10    print("Карта активна")
11elif card.status == "Blocked":
12    print("Карта заблокирована")
13else:
14    print("Неизвестный статус")

Здесь статус задаётся и прописывается вручную. Таких кусочков кода в программе могут быть десятки. Основная сложность заключается в том, что где-то может быть написано в статусе «active» или «Activ». При этом ваш редактор кода (IDE) не подсветит, где введено неправильно. А если нужно переписать название статуса — то придётся править во всех местах тоже вручную.

Для осмысленной работы со статусами используют как раз класс Enum. Вот как выглядит тот же код с классом Enum ниже.

1#Код с Enum
2from enum import Enum
3
4class CardStatus(Enum):
5    ACTIVE = "Active"
6    BLOCKED = "Blocked"
7
8class Card:
9    def __init__(self, status: CardStatus):
10        self.status = status
11
12card = Card(CardStatus.ACTIVE)
13
14if card.status == CardStatus.ACTIVE:
15    print("Карта активна")
16elif card.status == CardStatus.BLOCKED:
17    print("Карта заблокирована")
18else:
19    print("Неизвестный статус")

Основные изменения:

  • Теперь IDE знает, какие бывают статусы, и помогает автодополнением.
  • Если где-то будет опечатка, например card = Card(CardStatus.Active), то программа вызовет ошибку.
  • В целом CardStatus.ACTIVE выглядит как осмысленная фраза для читаемости кода.

При этом можно по-разному обращаться к элементу Enum, а также доставать имя и значение.

1# Один и тот же вызов элемента Enum разными способами
2CardStatus.ACTIVE
3CardStatus["ACTIVE"]    # По имени, строкой
4CardStatus("Active")    # По значению
5
6# Получение имени и значения
7status = CardStatus.ACTIVE
8
9status.name    # 'ACTIVE'   (имя элемента в перечислении)
10status.value   # 'Active'   (значение, которое вы задали)

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

Подробнее об использовании Enum можно прочитать в документации.

Декоратор dataclass для классов-хранилищ

dataclass в Python — это специальный декоратор (@dataclass) из стандартного модуля dataclasses, который позволяет очень быстро и удобно создавать классы для хранения данных.

Ниже приведены два примера — с декоратором и без него.

Пример без dataclass

1
2class User:
3    def __init__(self, name, age):
4        self.name = name
5        self.age = age
6
7    def __repr__(self):
8        return f"User(name={self.name!r}, age={self.age!r})"
9
10user = User("Ivan", 23)
11print(user)

Пример с dataclass

1
2from dataclasses import dataclass
3
4@dataclass
5class User:
6    name: str
7    age: int
8
9user = User("Ivan", 23)
10print(user)

Что изменилось:

  • Конструктор (__init__) создаётся автоматически.
  • Метод __repr__ для красивого вывода — тоже.

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

В рамках задачи все классы, кроме Card, будут обёрнуты в dataclass, так как Card заточен больше на методы и ему важен __init__ с определённой логикой.

Ещё вы будете использовать функцию default_factory(). С её помощью модуль dataclasses умеет работать с изменяемыми объектами.

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

Например, вы хотите в рамках класса напрямую создать список.

1#Вот это создаст общий список на всех
2from dataclasses import dataclass
3
4@dataclass
5class User:
6    tags: list = []

Вспоминаем, что список — это изменяемый тип данных. И если в одном месте вы что-то добавите в него, то он обновится везде. Подробнее об изменяемых и неизменяемых типах данных можно прочитать в статье на Хабре. Ниже код с демонстрацией проблемы.

1#Демонстрация проблемы
2from dataclasses import dataclass
3
4@dataclass
5class User:
6    tags: list = []
7
8u1 = User()
9u2 = User()
10u3 = User()
11
12u1.tags.append("hello")
13u2.tags.append("world")
14u3.tags.append("python")
15
16print("u1:", u1.tags)
17print("u2:", u2.tags)
18print("u3:", u3.tags)

В данном случае программа выведет:

1#Вывод
2u1: ['hello', 'world', 'python']
3u2: ['hello', 'world', 'python']
4u3: ['hello', 'world', 'python']

Чтобы этого избежать, в dataclass используют функцию default_factory, которая создаёт для каждого объекта свой список.

1from dataclasses import dataclass, field
2
3@dataclass
4class User:
5    tags: list = field(default_factory=list)  # Теперь у каждого свой список
6
7u1 = User()
8u2 = User()
9u3 = User()
10
11u1.tags.append("hello")
12u2.tags.append("world")
13u3.tags.append("python")
14
15print("u1:", u1.tags)
16print("u2:", u2.tags)
17print("u3:", u3.tags)

Результат теперь:

1u1: ['hello']
2u2: ['world']
3u3: ['python']

Для класса без использования декоратора dataclass остаётся такая же проблема. Она решается через использование аргумента None.

1class User:
2    def __init__(self, tags=None):
3        if tags is None:
4            tags = []
5        self.tags = tags
6
7u1 = User()
8u2 = User()
9u3 = User()
10
11u1.tags.append("hello")
12u2.tags.append("world")
13u3.tags.append("python")

Таким образом, с помощью декоратора dataclass вы упростите код у классов-хранилищ, а с помощью default_factory() создадите уникальные списки для каждого объекта.

Организация класса ошибок

В задачах 5.4 и 5.5 вы будете обрабатывать исключения у методов. Чтобы вам было проще поднимать конкретные ошибки, в шаблоне будет представлен каталог сообщений и организация классов ошибок. Давайте разберём его устройство и то, чем оно может вам помочь.

1# ======================= КАТАЛОГ СООБЩЕНИЙ ОБ ОШИБКАХ =======================
2class BankError(Exception):
3    """Базовый класс для всех ошибок банковского приложения."""
4
5
6class ValidationError(BankError):
7    """Ошибка валидации пользовательских данных (форматы, обязательные поля, допустимые значения)."""
8
9    PIN_MISMATCH = "Введенный ПИН-код не соответствует текущему"
10    PIN_INVALID = "ПИН-код должен быть строкой из 4 символов"
11    PIN_FORMAT_INVALID = "ПИН-код должен состоять только из цифр"
12    NAME_INVALID = "Имя и фамилия должны быть на русском, без использования специальных символов"
13    AMOUNT_NEGATIVE = "Сумма должна быть положительной"
14    DEPOSIT_AMOUNT_NEGATIVE = "Сумма пополнений должна быть положительной"
15    CASHBACK_NEGATIVE = "Процент кешбэка на покупки должен быть положительным"
16    INTEREST_NEGATIVE = "Ставка по счету должна быть положительной"
17    PAY_AMOUNT_NEGATIVE = "Сумма покупки должна быть положительной"
18    INVALID_MCC = "Неверный код категории продавца (MCC)"
19    PAYMENT_SYSTEM_NOT_SUPPORTED = "Платежная система {payment_system} не поддерживается банком"
20
21
22class NotFoundError(BankError):
23    """Ошибка отсутствия объекта: карта, счёт, пользователь не найдены."""
24
25    RECIPIENT_NOT_FOUND = "Ошибка номером карты. Такой карты не существует."
26
27
28class AccessError(BankError):
29    """Ошибка доступа к объекту или операции: карта/счёт не активны, недоступны или не привязаны."""
30
31    CARD_CLOSED = "Карта закрыта или заблокирована. Невозможно провести операцию."
32    ACCOUNT_NOT_LINKED = "Карта не привязана к счёту"
33    BANK_NOT_LINKED = "Карта не привязана к банку"
34    RECIPIENT_ACCOUNT_NOT_LINKED = "Карта получателя не привязана к счёту"
35    RECIPIENT_CARD_CLOSED = "Карта получателя закрыта или заблокирована. Невозможно провести операцию."
36
37
38class BusinessRuleError(BankError):
39    """Ошибка бизнес-логики: нарушено ограничение по правилам банка
40    (лимиты, количество, уникальность, запрещённые операции)."""
41
42    DEPOSIT_LIMIT_EXCEEDED = "Превышен лимит депозита. Карта заблокирована до выяснения причин."
43    TRANSFER_LIMIT_EXCEEDED = "Подозрение на мошенническую операцию. Карта заблокирована до выяснения причин."
44    CASHBACK_LIMIT = "Процент кешбэка завышен, возможна техническая ошибка. Карта заблокирована до выяснения причин."
45    TRANSFER_TO_SELF = "Нельзя пересылать деньги самому себе"
46    USER_CONFLICT = "Пользователь с таким телефоном уже зарегистрирован"
47    TOO_MANY_DEBIT_CARDS = "У пользователя уже есть пять дебетовых карт"
48    MCC_FORBIDDEN = "Оплата отклонена. Покупки по {mcc} запрещены банком"
49    PURCHASE_LIMIT_EXCEEDED = "Сумма оплаты превышает лимит. Операция заблокирована до выяснения причин."
50    PAYMENT_NOT_ALLOWED_FOR_SAVING = "С накопительного счёта нельзя списывать покупки"
51    SAVING_RATE_TOO_HIGH = (
52        "Ставка накопления завышена, возможна техническая ошибка. "
53        "Карта заблокирована до выяснения причины."
54    )
55
56
57class InsufficientFundsError(BankError):
58    """Ошибка недостатка денег"""
59
60    INSUFFICIENT_FUNDS_FOR_PAYMENT = "Недостаточно денег для оплаты."
61    INSUFFICIENT_FUNDS_FOR_TRANSFER = "Недостаточно денег для осуществления перевода."

Все классы ошибок наследуются от одного класса BankError:

1class BankError(Exception):
2
3class ValidationError(BankError):
4
5class NotFoundError(BankError):
6
7class AccessError(BankError):
8
9class BusinessRuleError(BankError):
10
11class InsufficientFundsError(BankError):

Это позволяет ловить ошибки через BankError.

1try:
2    bank.do_something()
3except BankError as e:
4    print("Банковская ошибка:", e)

А не описывать каждый класс ошибок, который может прилететь.

1try:
2    bank.do_something()
3except (ValidationError, NotFoundError, AccessError, BusinessRuleError, InsufficientFundsError) as e:
4    print("Банковская ошибка:", e)

Последовательность классов определяет, в каком порядке обрабатываем ошибки. Чтобы самые широкие и критичные ловились в начале, а более локальные в конце.

image

Поэтому, когда в задаче описывается, какие исключения нужно обработать, последовательность исключений придерживается этой структуры. Сначала обрабатываем ValidationError — и т. д.

Ведь если есть ошибка на более высоком уровне, то нет смысла лезть ниже.

Теперь вы знаете больше об использовании и организации конкретных классов и конструкций. Время закрепить знания на практике!

Чтобы добавить в заметки выделенный текст, нажмите Ctrl + E
Оцените ясность обучающего материала
Будем признательны, если вы поделитесь своим отзывом по финальной задаче и по блоку «5. Объектно-ориентированное программирование»
Оценка и отзыв не публикуются
Предыдущий параграф5.4. Чему вы научились
Следующий параграф6.1. Модули math и numpy

В этом параграфе вы познакомитесь с двумя важными библиотеками, которые помогут вам решать математические задачи в Python быстро и эффективно. Вы узнаете, какие функции предоставляет стандартный модуль math и почему модуль numpy считается основой для научных вычислений на Python. Разберётесь, как создавать и использовать массивы, выполнять операции над ними, и оцените, насколько numpy быстрее стандартных средств языка.