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

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

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

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

Важно

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

Перед решением задачи

Повторите:

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

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

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

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

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

Проект

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

Максим Ломакин
Тимлид в Яндекс Учебнике

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

Контекст

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

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

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

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

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

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

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

Bank

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

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

Account

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

Сard

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

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

Transaction

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

User

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

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

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

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

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

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

   def get_card_info(self, fields: list = None):
       #ToDo: добавьте необходимую информацию для реализации метода
        user = self.account.owner
        data = {
            "bank_name": f"Банк:          {}",
            "bank_bic": f"БИК банка:     {}",
            "card_id": f"Карта #{}",
            "user_id": f"Пользователь:  {} — {} {}",
            "phone": f"Телефон:       {}",
            "pan": f"PAN:           {}",
            "acc_id": f"Счёт:          {}",
            "payment_system": f"Плат. система: {}",
            "currency": f"Валюта:        {}",
            "status": f"Статус:        {}",
            "issue_date": f"Выпуск:        {}",
            "expiry_date": f"Срок:          {}",
            "user_cards": f"Карты пользователя: {}",
            "cashback_balance": f"Кешбэк:        {:.2f}₽",
            "balance": f"Баланс:        {:.2f}₽",
        }
        if fields is None:
            fields = DEFAULT_CARD_INFO_FIELDS
        return (
            "\n".join([data[field] for field in fields if field in data])
            + "\n"
            + "-" * 50
        )

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

Примечание

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

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

  • Класс 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, реализующий десятичную арифметику с заданной точностью:

from decimal import Decimal

x = Decimal('0.1')
y = Decimal('0.2')
print(x + y)  # 0.3

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

print(0.1 + 0.2)  # 0.30000000000000004

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

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

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

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

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

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

#Код без Enum
class Card:
    def __init__(self, status):
        # status может быть любой строкой!
        self.status = status

card = Card("Active")

if card.status == "Active":
    print("Карта активна")
elif card.status == "Blocked":
    print("Карта заблокирована")
else:
    print("Неизвестный статус")

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

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

#Код с Enum
from enum import Enum

class CardStatus(Enum):
    ACTIVE = "Active"
    BLOCKED = "Blocked"

class Card:
    def __init__(self, status: CardStatus):
        self.status = status

card = Card(CardStatus.ACTIVE)

if card.status == CardStatus.ACTIVE:
    print("Карта активна")
elif card.status == CardStatus.BLOCKED:
    print("Карта заблокирована")
else:
    print("Неизвестный статус")

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

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

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

# Один и тот же вызов элемента Enum разными способами
CardStatus.ACTIVE
CardStatus["ACTIVE"]    # По имени, строкой
CardStatus("Active")    # По значению

# Получение имени и значения
status = CardStatus.ACTIVE

status.name    # 'ACTIVE'   (имя элемента в перечислении)
status.value   # 'Active'   (значение, которое вы задали)

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

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

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

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

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

Пример без dataclass

class User:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def __repr__(self):
        return f"User(name={self.name!r}, age={self.age!r})"

user = User("Ivan", 23)
print(user)

Пример с dataclass

from dataclasses import dataclass

@dataclass
class User:
    name: str
    age: int

user = User("Ivan", 23)
print(user)

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

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

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

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

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

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

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

#Вот это создаст общий список на всех
from dataclasses import dataclass

@dataclass
class User:
    tags: list = []

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

#Демонстрация проблемы
from dataclasses import dataclass

@dataclass
class User:
    tags: list = []

u1 = User()
u2 = User()
u3 = User()

u1.tags.append("hello")
u2.tags.append("world")
u3.tags.append("python")

print("u1:", u1.tags)
print("u2:", u2.tags)
print("u3:", u3.tags)

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

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

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

from dataclasses import dataclass, field

@dataclass
class User:
    tags: list = field(default_factory=list)  # Теперь у каждого свой список

u1 = User()
u2 = User()
u3 = User()

u1.tags.append("hello")
u2.tags.append("world")
u3.tags.append("python")

print("u1:", u1.tags)
print("u2:", u2.tags)
print("u3:", u3.tags)

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

u1: ['hello']
u2: ['world']
u3: ['python']

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

class User:
    def __init__(self, tags=None):
        if tags is None:
            tags = []
        self.tags = tags

u1 = User()
u2 = User()
u3 = User()

u1.tags.append("hello")
u2.tags.append("world")
u3.tags.append("python")

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

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

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

# ======================= КАТАЛОГ СООБЩЕНИЙ ОБ ОШИБКАХ =======================
class BankError(Exception):
    """Базовый класс для всех ошибок банковского приложения."""


class ValidationError(BankError):
    """Ошибка валидации пользовательских данных (форматы, обязательные поля, допустимые значения)."""

    PIN_MISMATCH = "Введенный ПИН-код не соответствует текущему"
    PIN_INVALID = "ПИН-код должен быть строкой из 4 символов"
    PIN_FORMAT_INVALID = "ПИН-код должен состоять только из цифр"
    NAME_INVALID = "Имя и фамилия должны быть на русском, без использования специальных символов"
    AMOUNT_NEGATIVE = "Сумма должна быть положительной"
    DEPOSIT_AMOUNT_NEGATIVE = "Сумма пополнений должна быть положительной"
    CASHBACK_NEGATIVE = "Процент кешбэка на покупки должен быть положительным"
    INTEREST_NEGATIVE = "Ставка по счету должна быть положительной"
    PAY_AMOUNT_NEGATIVE = "Сумма покупки должна быть положительной"
    INVALID_MCC = "Неверный код категории продавца (MCC)"
    PAYMENT_SYSTEM_NOT_SUPPORTED = "Платежная система {payment_system} не поддерживается банком"


class NotFoundError(BankError):
    """Ошибка отсутствия объекта: карта, счёт, пользователь не найдены."""

    RECIPIENT_NOT_FOUND = "Ошибка номером карты. Такой карты не существует."


class AccessError(BankError):
    """Ошибка доступа к объекту или операции: карта/счёт не активны, недоступны или не привязаны."""

    CARD_CLOSED = "Карта закрыта или заблокирована. Невозможно провести операцию."
    ACCOUNT_NOT_LINKED = "Карта не привязана к счёту"
    BANK_NOT_LINKED = "Карта не привязана к банку"
    RECIPIENT_ACCOUNT_NOT_LINKED = "Карта получателя не привязана к счёту"
    RECIPIENT_CARD_CLOSED = "Карта получателя закрыта или заблокирована. Невозможно провести операцию."


class BusinessRuleError(BankError):
    """Ошибка бизнес-логики: нарушено ограничение по правилам банка
    (лимиты, количество, уникальность, запрещённые операции)."""

    DEPOSIT_LIMIT_EXCEEDED = "Превышен лимит депозита. Карта заблокирована до выяснения причин."
    TRANSFER_LIMIT_EXCEEDED = "Подозрение на мошенническую операцию. Карта заблокирована до выяснения причин."
    CASHBACK_LIMIT = "Процент кешбэка завышен, возможна техническая ошибка. Карта заблокирована до выяснения причин."
    TRANSFER_TO_SELF = "Нельзя пересылать деньги самому себе"
    USER_CONFLICT = "Пользователь с таким телефоном уже зарегистрирован"
    TOO_MANY_DEBIT_CARDS = "У пользователя уже есть пять дебетовых карт"
    MCC_FORBIDDEN = "Оплата отклонена. Покупки по {mcc} запрещены банком"
    PURCHASE_LIMIT_EXCEEDED = "Сумма оплаты превышает лимит. Операция заблокирована до выяснения причин."
    PAYMENT_NOT_ALLOWED_FOR_SAVING = "С накопительного счёта нельзя списывать покупки"
    SAVING_RATE_TOO_HIGH = (
        "Ставка накопления завышена, возможна техническая ошибка. "
        "Карта заблокирована до выяснения причины."
    )


class InsufficientFundsError(BankError):
    """Ошибка недостатка денег"""

    INSUFFICIENT_FUNDS_FOR_PAYMENT = "Недостаточно денег для оплаты."
    INSUFFICIENT_FUNDS_FOR_TRANSFER = "Недостаточно денег для осуществления перевода."

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

class BankError(Exception):

class ValidationError(BankError):

class NotFoundError(BankError):

class AccessError(BankError):

class BusinessRuleError(BankError):

class InsufficientFundsError(BankError):

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

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

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

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

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

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

Примечание

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

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

Если возникли сложности, обратитесь к подсказкам по задачам и отдельным приёмам Python.

Подсказки

Если вы зашли в тупик, обратитесь к подсказкам. Мы собрали ответы на частые вопросы по этой задаче. Они наведут вас на мысль и помогут сдвинуться с мёртвой точки.

Подсказки

Не знаю, с чего начать решение задачи № 1

Первым делом изучите выданный шаблон. В нём уже есть готовые кусочки кода, которые не нужно переписывать. А также выделены места, где нужно поработать: они помечены как #TODO.

def get_card_info(self, fields: list = None):
       #TODO: добавьте необходимую информацию для реализации метода
        user = self.account.owner
        data = {
            "bank_name": f"Банк:          {}",
            "bank_bic": f"БИК банка:     {}",
            "card_id": f"Карта #{}",
            "user_id": f"Пользователь:  {} — {} {}",
            "phone": f"Телефон:       {}",
            "pan": f"PAN:           {}",
            "acc_id": f"Счёт:          {}",
            "payment_system": f"Плат. система: {}",
            "currency": f"Валюта:        {}",
            "status": f"Статус:        {}",
            "issue_date": f"Выпуск:        {}",
            "expiry_date": f"Срок:          {}",
            "user_cards": f"Карты пользователя: {}",
            "cashback_balance": f"Кешбэк:        {:.2f}₽",
            "balance": f"Баланс:        {:.2f}₽",
        }
        if fields is None:
            fields = DEFAULT_CARD_INFO_FIELDS
        return (
            "\n".join([data[field] for field in fields if field in data])
            + "\n"
            + "-" * 50
        )
   def apply_for_card(
        self,
        last_name,
        first_name,
        pin,
        phone,
        payment_system=DEFAULT_PAYMENT_SYSTEM,
        card_class: type = Card,
        **kwargs,
    ):
    #TODO: реализуйте заданный метод по условию задачи
        return card

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

Тогда вам будет проще понимать, как и где достать необходимый атрибут.

Не знаю, с чего начать решение задачи № 2

Начните реализовывать методы прямо подряд.

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

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

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

Не знаю, с чего начать решение задачи № 3

Для начала необходимо определить: а как новые типы карт отличаются от текущей, то есть своего родителя? Можно собрать небольшую табличку в следующем формате:

А дальше эта задача становится похожей на задачу № 2, когда нам нужно реализовать конкретные методы.

Не знаю, с чего начать решение задачи № 4

Рекомендуем последовательно обрабатывать исключения в каждом методе. По факту для каждого исключения вам нужно прописать условие, поднять определённую ошибку, а где-то ещё и заблокировать карту.

Например, если у метода transfer() хотим обработать исключение «превышение лимита», то это может выглядеть так:

   def transfer(self, to_card, amount: float):
        …
        # 8. Проверка лимита
        if amount > TRANSFER_LIMIT:
            self.status = CardStatus.BLOCKED
            raise BusinessRuleError(BusinessRuleError.TRANSFER_LIMIT_EXCEEDED)

Какую ошибку нужно поднять, мы указали в таблице задания. Можно просто её скопировать.

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

  • Как осуществить проверку типа объекта?
  • Как осуществить проверку на принадлежность к подклассу?
  • Как осуществить проверку строки фамилии и имени на наличие только русских букв?
Не знаю, с чего начать решение задачи № 5

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

В общем виде обработка исключения выглядит следующим образом:

try
    …
except
    …
finally
    …

Текст ошибки выводится следующим образом:

except BankError as e:
    error_text = str(e)
Как вставлять данные в текстовые константы из шаблона?

Часть констант из шаблона содержит заготовки, в которые можно помещать данные. Например, сообщение о балансе.

BALANCE_DESCRIPTION ="Баланс: {balance:.2f}₽"

Чтобы вставить в текст конкретное значение balance, воспользуемся методом у строк .format(). Это метод подставляет выбранное значение в шаблон.

 def get_balance(self):
        balance = self.account.balance
        return BALANCE_DESCRIPTION.format(balance=balance)

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

Как осуществить проверку типа объекта?

Воспользуйтесь функцией isinstance().

Это встроенная функция Python, которая проверяет, относится ли объект к определённому классу. Выдаёт True, если да, False, если нет.

Вот как можно проверить, является ли переданный ПИН-код строкой:

pin = "1234"

if isinstance(pin, str):
    print("Да, ПИН-код — строка")
else:
    print("Нет, ПИН-код не строка")

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

Ссылка на документацию по функции isinstance().

Как осуществить проверку на принадлежность к подклассу?

Воспользуйтесь функцией issubclass().

Это встроенная функция Python, которая проверяет, является ли один класс подклассом другого.

Например, хотим проверить, принадлежит ли карта к конкретному типу.

issubclass(CashbackDebitCard, Card)           # True
issubclass(SavingCard, Card)                  # True
issubclass(SimpleDebitCard, SavingCard)       # False
issubclass(Card, Card)                        # True

DEBIT_CARD_CLASSES = (SimpleDebitCard, CashbackDebitCard, SavingCard)

issubclass(CashbackDebitCard, DEBIT_CARD_CLASSES)   # True
issubclass(Card, DEBIT_CARD_CLASSES)                # False

Ловите ссылку на документацию по этой функции.

Как осуществить проверку строки фамилии и имени на наличие только русских букв?

Возможны два подхода.

Первый — просто пройтись по каждому элементу строки и проверить, что каждый элемент — это буква кириллицы.

text = "Привет"

result = all(
    ch in "Ёё" or 'А' <= ch <= 'Я' or 'а' <= ch <= 'я'
    for ch in text
)

print(result)  # True

Учитывайте, что могут быть ещё двойные имена и фамилии. Доработайте проверку для этого случая.

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

Их мы оставим вам на самостоятельное изучение. Начать можно с подробной статьи на Хабре.

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

А ниже — решение от автора: изучите его и сравните со своим. Плюс автор описал размышления о подходе к задачам 1–5 и разобрал ключевые моменты. Советуем просмотреть их все — для развития насмотренности и вдохновения.

Разбор

Посмотреть решение задачи 5.1. Оформление заявки на выпуск карты

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

Скачать Solution_51.py

import datetime as _dt
import itertools as _it
from dataclasses import dataclass, field
from enum import Enum

# =============================== КОНСТАНТЫ ===============================
DEFAULT_CARD_INFO_FIELDS = [
    "card_id",
    "user_id",
    "phone",
    "bank_name",
    "bank_bic",
    "acc_id",
    "pan",
    "payment_system",
    "currency",
    "status",
    "issue_date",
    "expiry_date",
    "balance",
    "cashback_balance",
    "user_cards",
]
DEFAULT_ACCOUNT_BALANCE = 0.00
DEFAULT_CASHBACK_BALANCE = 0.00
CARD_CURRENCY = "RUB"
DEFAULT_PAYMENT_SYSTEM = "MIR"

ACCOUNT_TYPE_CODE = "40817"  # тип счёта для физлиц
ACCOUNT_BRANCH = "0000"  # отсутствие филиалов у банка
ACCOUNT_CURRENCY = "810"  # идентификатор для рублёвых операций


EMPTY_PAN = "0000000000000000"

BIN_BY_SYSTEM = {
    "MIR": "220400",
    "VISA": "400000",
    "MASTERCARD": "510000",
}

# =============================== ГЕНЕРАТОРЫ ДАННЫХ ===============================
ISSUE_DATE_START = _dt.date(2022, 1, 1)
ISSUE_DATE_GENERATOR = (ISSUE_DATE_START + _dt.timedelta(days=i) for i in _it.count())
EXPIRY_YEARS = 4


# =============================== ENUM’Ы ===============================
class CardStatus(Enum):
    ACTIVE = "Active"
    CLOSED = "Closed"
    BLOCKED = "Blocked"


CARD_STATUS = CardStatus.ACTIVE


# ============================== ОСНОВНЫЕ КЛАССЫ ===============================
@dataclass
class User:
    last_name: str
    first_name: str
    pin: str
    phone: str
    user_id: int

    accounts: list = field(default_factory=list)
    cards: list = field(default_factory=list)


@dataclass
class Account:
    owner: "User"
    acc_id: str
    balance: float = DEFAULT_ACCOUNT_BALANCE
    cashback_balance: float = DEFAULT_CASHBACK_BALANCE


class Card:
    def __init__(
        self,
        account,
        card_id,
        payment_system=DEFAULT_PAYMENT_SYSTEM,
        pan=EMPTY_PAN,
        issue_date=None,
        expiry_date=None,
        currency=CARD_CURRENCY,
        status=CARD_STATUS,
        bank=None,
    ):
        self.account = account
        self.card_id = card_id
        self.payment_system = payment_system
        self.pan = pan
        self.issue_date = issue_date
        self.expiry_date = expiry_date
        self.currency = currency
        self.status = status
        self.bank = bank

        # Обновляем дату заведения и срок окончания карты
        if self.issue_date is None:
            self.issue_date = next(ISSUE_DATE_GENERATOR)
        if self.expiry_date is None and self.issue_date is not None:
            self.expiry_date = _dt.date(
                self.issue_date.year + EXPIRY_YEARS,
                self.issue_date.month,
                self.issue_date.day,
            )

    def get_card_info(self, fields: list = None):
        user = self.account.owner
        data = {
            "bank_name": f"Банк:          {self.bank.name}",
            "bank_bic": f"БИК банка:     {self.bank.bic}",
            "card_id": f"Карта #{self.card_id}",
            "user_id": f"Пользователь:  {user.user_id} — {user.last_name} {user.first_name}",
            "phone": f"Телефон:       {user.phone}",
            "pan": f"PAN:           {self.pan}",
            "acc_id": f"Счёт:          {self.account.acc_id}",
            "payment_system": f"Плат. система: {self.payment_system}",
            "currency": f"Валюта:        {self.currency}",
            "status": f"Статус:        {self.status.value}",
            "issue_date": f"Выпуск:        {self.issue_date}",
            "expiry_date": f"Срок:          {self.expiry_date}",
            "user_cards": f"Карты пользователя: {[c.pan for c in user.cards]}",
            "cashback_balance": f"Кешбэк:        {self.account.cashback_balance:.2f}₽",
            "balance": f"Баланс:        {self.account.balance:.2f}₽",
        }
        if fields is None:
            fields = DEFAULT_CARD_INFO_FIELDS
        return (
            "\n".join([data[field] for field in fields if field in data])
            + "\n"
            + "-" * 50
        )

    def __repr__(self):
        return (
            f"Card(card_id={self.card_id}, pan={self.pan}, account={self.account}, "
            f"status={self.status}, issue_date={self.issue_date}, expiry_date={self.expiry_date})"
        )


@dataclass
class Bank:
    name: str
    bic: str

    _user_seq: int = field(default_factory=lambda: _it.count(1), init=False)
    _account_seq: int = field(default_factory=lambda: _it.count(1), init=False)
    _card_seq: int = field(default_factory=lambda: _it.count(1), init=False)
    _pan_seq: int = field(default_factory=lambda: _it.count(1), init=False)

    customers: dict = field(default_factory=dict)
    accounts: dict = field(default_factory=dict)
    cards: dict = field(default_factory=dict)
    transaction_log: list = field(default_factory=list)

    def _next_account_number(self):
        prefix_left = ACCOUNT_TYPE_CODE + ACCOUNT_CURRENCY
        prefix_right = ACCOUNT_BRANCH
        bic_tail = self.bic[-3:]
        serial = f"{next(self._account_seq):07d}"

        for control_digit in range(10):
            candidate_account_number = (
                prefix_left + str(control_digit) + prefix_right + serial
            )
            digits = [int(d) for d in bic_tail + candidate_account_number]
            weights = [7, 1, 3] * 8
            weighted = [a * b for a, b in zip(digits, weights[:23])]
            control_sum = sum(x % 10 for x in weighted)
            if control_sum % 10 == 0:
                return candidate_account_number

    def _generate_pan(self, system):
        bin_code = BIN_BY_SYSTEM.get(
            system.upper(), BIN_BY_SYSTEM[DEFAULT_PAYMENT_SYSTEM]
        )
        seq = f"{next(self._pan_seq):09d}"
        partial = bin_code + seq
        check = self._luhn(partial)
        return partial + str(check)

    def _luhn(self, number15):
        digits = [int(d) for d in number15[::-1]]
        for i in range(1, len(digits), 2):
            doubled = digits[i] * 2
            digits[i] = doubled - 9 if doubled > 9 else doubled
        return (10 - sum(digits) % 10) % 10

    def apply_for_card(
        self,
        last_name,
        first_name,
        pin,
        phone,
        payment_system=DEFAULT_PAYMENT_SYSTEM,
        card_class: type = Card,
        **kwargs,
    ):
        # Поиск существующего пользователя по телефону
        user = next(
            (
                user
                for user in self.customers.values()
                if user.last_name == last_name
                and user.first_name == first_name
                and user.phone == phone
            ),
            None,
        )
        if not user:
            user_id = next(self._user_seq)
            user = User(last_name, first_name, pin, phone, user_id)
            self.customers[user_id] = user

        acc_id = self._next_account_number()
        acc = Account(owner=user, acc_id=acc_id)
        self.accounts[acc_id] = acc
        user.accounts.append(acc)

        card_id = next(self._card_seq)
        pan = self._generate_pan(payment_system)
        issue_date = next(ISSUE_DATE_GENERATOR)

        card = card_class(
            account=acc,
            card_id=card_id,
            payment_system=payment_system,
            pan=pan,
            issue_date=issue_date,
            currency=CARD_CURRENCY,
            status=CARD_STATUS,
            bank=self,
            **kwargs,
        )

        self.cards[card_id] = card
        user.cards.append(card)
        return card
Посмотреть решение задачи 5.2. Использование банковской карты и ведение журнала операций

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

Скачать Solution_52.py

import datetime as _dt
import itertools as _it
from dataclasses import dataclass, field
from enum import Enum

# =============================== КОНСТАНТЫ ===============================
DEFAULT_CARD_INFO_FIELDS = [
    "card_id",
    "user_id",
    "phone",
    "bank_name",
    "bank_bic",
    "acc_id",
    "pan",
    "payment_system",
    "currency",
    "status",
    "issue_date",
    "expiry_date",
    "balance",
    "cashback_balance",
    "user_cards",
]
DEFAULT_ACCOUNT_BALANCE = 0.00
DEFAULT_CASHBACK_BALANCE = 0.00
DEFAULT_CASHBACK_TRANSACTION = 0.00
CARD_CURRENCY = "RUB"
DEFAULT_PAYMENT_SYSTEM = "MIR"

ACCOUNT_TYPE_CODE = "40817"  # тип счёта для физлиц
ACCOUNT_BRANCH = "0000"  # отсутствие филиалов у банка
ACCOUNT_CURRENCY = "810"  # идентификатор для рублёвых операций

EMPTY_PAN = "0000000000000000"

BIN_BY_SYSTEM = {
    "MIR": "220400",
    "VISA": "400000",
    "MASTERCARD": "510000",
}

TRANSACTION_HISTORY_HEADER = [
    "timestamp,type,from_card,to_card,amount,mcc,cashback,description"
]
DEPOSIT_DESCRIPTION = "{amount:.2f}₽ → карта #{card_id}"
TRANSFER_DESCRIPTION = "{amount:.2f}₽: карта #{from_card} → карта #{to_card}"
PAY_DESCRIPTION = "{amount:.2f}₽ (MCC: {mcc}) с карты #{card_id}"
BALANCE_DESCRIPTION = "Баланс: {balance:.2f}₽"

# =============================== ГЕНЕРАТОРЫ ДАННЫХ ===============================
ISSUE_DATE_START = _dt.date(2022, 1, 1)
ISSUE_DATE_GENERATOR = (ISSUE_DATE_START + _dt.timedelta(days=i) for i in _it.count())
EXPIRY_YEARS = 4

TIMESTAMP_START = _dt.datetime(2022, 1, 1, 9, 0, 0)


def timestamp_generator():
    for i in _it.count():
        base_date = TIMESTAMP_START + _dt.timedelta(days=i)
        hour = 9 + (i * 3) % 10  # цикличное смещение часа
        minute = (i * 7) % 60  # цикличное смещение минут
        second = (i * 11) % 60  # цикличное смещение секунд
        yield base_date.replace(hour=hour % 24, minute=minute, second=second)


TIMESTAMP_GENERATOR = timestamp_generator()


def next_timestamp_after(issue_date: _dt.date) -> _dt.datetime:
    """
    Возвращает ближайший timestamp из генератора, который позже issue_date.
    """
    while True:
        ts = next(TIMESTAMP_GENERATOR)
        if ts.date() > issue_date:
            return ts


# =============================== ENUM’Ы ===============================
class CardStatus(Enum):
    ACTIVE = "Active"
    CLOSED = "Closed"
    BLOCKED = "Blocked"


CARD_STATUS = CardStatus.ACTIVE


class TransactionType(Enum):
    DEPOSIT = "deposit"
    TRANSFER = "transfer"
    PAY = "pay"
    INTEREST = "interest"


# ============================== ОСНОВНЫЕ КЛАССЫ ===============================
@dataclass
class Transaction:
    from_card: int | None
    to_card: int | None
    amount: float
    type: TransactionType
    mcc: str | None
    description: str
    timestamp: _dt.datetime
    cashback: float = DEFAULT_CASHBACK_TRANSACTION


@dataclass
class User:
    last_name: str
    first_name: str
    pin: str
    phone: str
    user_id: int

    accounts: list = field(default_factory=list)
    cards: list = field(default_factory=list)

    def change_pin(self, old_pin: str, new_pin: str):
        if self.pin == old_pin:
            self.pin = new_pin


@dataclass
class Account:
    owner: "User"
    acc_id: str
    balance: float = DEFAULT_ACCOUNT_BALANCE
    cashback_balance: float = DEFAULT_CASHBACK_BALANCE


class Card:
    def __init__(
        self,
        account,
        card_id,
        payment_system=DEFAULT_PAYMENT_SYSTEM,
        pan=EMPTY_PAN,
        issue_date=None,
        expiry_date=None,
        currency=CARD_CURRENCY,
        status=CARD_STATUS,
        bank=None,
    ):
        self.account = account
        self.card_id = card_id
        self.payment_system = payment_system
        self.pan = pan
        self.issue_date = issue_date
        self.expiry_date = expiry_date
        self.currency = currency
        self.status = status
        self.bank = bank

        # Обновляем дату заведения и срок окончания карты
        if self.issue_date is None:
            self.issue_date = next(ISSUE_DATE_GENERATOR)
        if self.expiry_date is None and self.issue_date is not None:
            self.expiry_date = _dt.date(
                self.issue_date.year + EXPIRY_YEARS,
                self.issue_date.month,
                self.issue_date.day,
            )

    def get_card_info(self, fields: list = None):
        user = self.account.owner
        data = {
            "bank_name": f"Банк:          {self.bank.name}",
            "bank_bic": f"БИК банка:     {self.bank.bic}",
            "card_id": f"Карта #{self.card_id}",
            "user_id": f"Пользователь:  {user.user_id} — {user.last_name} {user.first_name}",
            "phone": f"Телефон:       {user.phone}",
            "pan": f"PAN:           {self.pan}",
            "acc_id": f"Счёт:          {self.account.acc_id}",
            "payment_system": f"Плат. система: {self.payment_system}",
            "currency": f"Валюта:        {self.currency}",
            "status": f"Статус:        {self.status.value}",
            "issue_date": f"Выпуск:        {self.issue_date}",
            "expiry_date": f"Срок:          {self.expiry_date}",
            "user_cards": f"Карты пользователя: {[c.pan for c in user.cards]}",
            "cashback_balance": f"Кешбэк:        {self.account.cashback_balance:.2f}₽",
            "balance": f"Баланс:        {self.account.balance:.2f}₽",
        }
        if fields is None:
            fields = DEFAULT_CARD_INFO_FIELDS
        return (
            "\n".join([data[field] for field in fields if field in data])
            + "\n"
            + "-" * 50
        )

    def get_balance(self):
        balance = self.account.balance
        return BALANCE_DESCRIPTION.format(balance=balance)

    def deposit(self, amount: float):
        self.account.balance += amount

        self.bank.transaction_log.append(
            Transaction(
                from_card=None,
                to_card=self.card_id,
                amount=amount,
                type=TransactionType.DEPOSIT,
                mcc=None,
                cashback=DEFAULT_CASHBACK_TRANSACTION,
                description=DEPOSIT_DESCRIPTION.format(
                    amount=amount, card_id=self.card_id
                ),
                timestamp=next_timestamp_after(self.issue_date),
            )
        )

    def transfer(self, to_card, amount: float):
        self.account.balance -= amount
        to_card.account.balance += amount
        latest_issue = max(self.issue_date, to_card.issue_date)

        self.bank.transaction_log.append(
            Transaction(
                from_card=self.card_id,
                to_card=to_card.card_id,
                amount=amount,
                type=TransactionType.TRANSFER,
                mcc=None,
                cashback=DEFAULT_CASHBACK_TRANSACTION,
                description=TRANSFER_DESCRIPTION.format(
                    amount=amount, from_card=self.card_id, to_card=to_card.card_id
                ),
                timestamp=next_timestamp_after(latest_issue),
            )
        )

    def pay(self, amount: float, mcc: str):
        self.account.balance -= amount
        self.bank.transaction_log.append(
            Transaction(
                from_card=self.card_id,
                to_card=None,
                amount=amount,
                type=TransactionType.PAY,
                mcc=mcc,
                cashback=DEFAULT_CASHBACK_TRANSACTION,
                description=PAY_DESCRIPTION.format(
                    amount=amount, mcc=mcc, card_id=self.card_id
                ),
                timestamp=next_timestamp_after(self.issue_date),
            )
        )

    def get_transaction_history(self):
        rows = list(TRANSACTION_HISTORY_HEADER)
        # Сортируем по времени!
        sorted_transactions = sorted(
            self.bank.transaction_log, key=lambda x: x.timestamp
        )
        for tx in sorted_transactions:
            if tx.from_card == self.card_id or tx.to_card == self.card_id:
                dt_str = tx.timestamp.strftime("%Y-%m-%d %H:%M:%S")
                from_card = tx.from_card if tx.from_card is not None else ""
                to_card = tx.to_card if tx.to_card is not None else ""
                # Знак для пользователя:
                if tx.from_card == self.card_id:
                    sign = "-"
                elif tx.to_card == self.card_id:
                    sign = "+"
                else:
                    sign = ""
                amount_str = f"{sign}{tx.amount:.2f}₽"
                mcc_str = tx.mcc if tx.mcc else ""
                tx_type = tx.type.value
                cashback_str = f"{getattr(tx, 'cashback', 0):.2f}₽"
                rows.append(
                    f"{dt_str},{tx_type},{from_card},{to_card},{amount_str},{mcc_str},{cashback_str},{tx.description}"
                )
        return rows

    def close(self):
        self.status = CardStatus.CLOSED

    def __repr__(self):
        return (
            f"Card(card_id={self.card_id}, pan={self.pan}, account={self.account}, "
            f"status={self.status}, issue_date={self.issue_date}, expiry_date={self.expiry_date})"
        )


@dataclass
class Bank:
    name: str
    bic: str

    _user_seq: int = field(default_factory=lambda: _it.count(1), init=False)
    _account_seq: int = field(default_factory=lambda: _it.count(1), init=False)
    _card_seq: int = field(default_factory=lambda: _it.count(1), init=False)
    _pan_seq: int = field(default_factory=lambda: _it.count(1), init=False)

    customers: dict = field(default_factory=dict)
    accounts: dict = field(default_factory=dict)
    cards: dict = field(default_factory=dict)
    transaction_log: list = field(default_factory=list)

    def _next_account_number(self):
        prefix_left = ACCOUNT_TYPE_CODE + ACCOUNT_CURRENCY
        prefix_right = ACCOUNT_BRANCH
        bic_tail = self.bic[-3:]
        serial = f"{next(self._account_seq):07d}"

        for control_digit in range(10):
            candidate_account_number = (
                prefix_left + str(control_digit) + prefix_right + serial
            )
            digits = [int(d) for d in bic_tail + candidate_account_number]
            weights = [7, 1, 3] * 8
            weighted = [a * b for a, b in zip(digits, weights[:23])]
            control_sum = sum(x % 10 for x in weighted)
            if control_sum % 10 == 0:
                return candidate_account_number

    def _generate_pan(self, system):
        bin_code = BIN_BY_SYSTEM.get(
            system.upper(), BIN_BY_SYSTEM[DEFAULT_PAYMENT_SYSTEM]
        )
        seq = f"{next(self._pan_seq):09d}"
        partial = bin_code + seq
        check = self._luhn(partial)
        return partial + str(check)

    def _luhn(self, number15):
        digits = [int(d) for d in number15[::-1]]
        for i in range(1, len(digits), 2):
            doubled = digits[i] * 2
            digits[i] = doubled - 9 if doubled > 9 else doubled
        return (10 - sum(digits) % 10) % 10

    def apply_for_card(
        self,
        last_name,
        first_name,
        pin,
        phone,
        payment_system=DEFAULT_PAYMENT_SYSTEM,
        card_class: type = Card,
        **kwargs,
    ):
        # Поиск существующего пользователя по телефону
        user = next(
            (
                user
                for user in self.customers.values()
                if user.last_name == last_name
                and user.first_name == first_name
                and user.phone == phone
            ),
            None,
        )
        if not user:
            user_id = next(self._user_seq)
            user = User(last_name, first_name, pin, phone, user_id)
            self.customers[user_id] = user

        acc_id = self._next_account_number()
        acc = Account(owner=user, acc_id=acc_id)
        self.accounts[acc_id] = acc
        user.accounts.append(acc)

        card_id = next(self._card_seq)
        pan = self._generate_pan(payment_system)
        issue_date = next(ISSUE_DATE_GENERATOR)

        card = card_class(
            account=acc,
            card_id=card_id,
            payment_system=payment_system,
            pan=pan,
            issue_date=issue_date,
            currency=CARD_CURRENCY,
            status=CARD_STATUS,
            bank=self,
            **kwargs,
        )

        self.cards[card_id] = card
        user.cards.append(card)
        return card

    def get_global_history(self) -> list:
        rows = list(TRANSACTION_HISTORY_HEADER)
        # Сортировка по времени (timestamp) по возрастанию:
        for tx in sorted(self.transaction_log, key=lambda x: x.timestamp):
            dt_str = tx.timestamp.strftime("%Y-%m-%d %H:%M:%S")
            tx_type = tx.type.value
            from_card = tx.from_card if tx.from_card is not None else ""
            to_card = tx.to_card if tx.to_card is not None else ""
            amount_str = f"{tx.amount:.2f}₽"
            cashback_str = f"{getattr(tx, 'cashback', 0):.2f}₽"
            mcc_str = tx.mcc if tx.mcc else ""
            rows.append(
                f"{dt_str},{tx_type},{from_card},{to_card},{amount_str},{mcc_str},{cashback_str},{tx.description}"
            )
        return rows
Посмотреть решение задачи 5.3. Запускаем новые типы банковских карт

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

Скачать Solution_53.py

import datetime as _dt
import itertools as _it
from dataclasses import dataclass, field
from enum import Enum

# =============================== КОНСТАНТЫ ===============================
DEFAULT_CARD_INFO_FIELDS = [
    "card_id",
    "user_id",
    "phone",
    "bank_name",
    "bank_bic",
    "acc_id",
    "pan",
    "payment_system",
    "currency",
    "status",
    "issue_date",
    "expiry_date",
    "balance",
    "cashback_balance",
    "user_cards",
]
DEFAULT_ACCOUNT_BALANCE = 0.00
DEFAULT_CASHBACK_BALANCE = 0.00
DEFAULT_CASHBACK_TRANSACTION = 0.00
CARD_CURRENCY = "RUB"
DEFAULT_PAYMENT_SYSTEM = "MIR"

ACCOUNT_TYPE_CODE = "40817"  # тип счёта для физлиц
ACCOUNT_BRANCH = "0000"  # отсутствие филиалов у банка
ACCOUNT_CURRENCY = "810"  # идентификатор для рублёвых операций

EMPTY_PAN = "0000000000000000"

BIN_BY_SYSTEM = {
    "MIR": "220400",
    "VISA": "400000",
    "MASTERCARD": "510000",
}

TRANSACTION_HISTORY_HEADER = [
    "timestamp,type,from_card,to_card,amount,mcc,cashback,description"
]
DEPOSIT_DESCRIPTION = "{amount:.2f}₽ → карта #{card_id}"
TRANSFER_DESCRIPTION = "{amount:.2f}₽: карта #{from_card} → карта #{to_card}"
PAY_DESCRIPTION = "{amount:.2f}₽ (MCC: {mcc}) с карты #{card_id}"
BALANCE_DESCRIPTION = "Баланс: {balance:.2f}₽"
CB_DEBIT_PAY_DESCRIPTION = "{amount:.2f}₽ (MCC: {mcc}) с карты #{card_id} (кешбэк {cashback_amount:.2f}₽)"
SAVING_INTEREST_DESCRIPTION = "Начислены проценты {interest:.2f}₽ по накопительной карте #{card_id}"

DEBIT_DEFAULT_CASHBACK_RATE = 0.03
SAVING_CARD_DEFAULT_INTEREST = 0.015


# =============================== ГЕНЕРАТОРЫ ДАННЫХ ===============================
ISSUE_DATE_START = _dt.date(2022, 1, 1)
ISSUE_DATE_GENERATOR = (ISSUE_DATE_START + _dt.timedelta(days=i) for i in _it.count())
EXPIRY_YEARS = 4

TIMESTAMP_START = _dt.datetime(2022, 1, 1, 9, 0, 0)


def timestamp_generator():
    for i in _it.count():
        base_date = TIMESTAMP_START + _dt.timedelta(days=i)
        hour = 9 + (i * 3) % 10  # цикличное смещение часа
        minute = (i * 7) % 60  # цикличное смещение минут
        second = (i * 11) % 60  # цикличное смещение секунд
        yield base_date.replace(hour=hour % 24, minute=minute, second=second)


TIMESTAMP_GENERATOR = timestamp_generator()


def next_timestamp_after(issue_date: _dt.date) -> _dt.datetime:
    """
    Возвращает ближайший timestamp из генератора, который позже issue_date.
    """
    while True:
        ts = next(TIMESTAMP_GENERATOR)
        if ts.date() > issue_date:
            return ts


# =============================== ENUM’Ы ===============================
class CardStatus(Enum):
    ACTIVE = "Active"
    CLOSED = "Closed"
    BLOCKED = "Blocked"


CARD_STATUS = CardStatus.ACTIVE


class TransactionType(Enum):
    DEPOSIT = "deposit"
    TRANSFER = "transfer"
    PAY = "pay"
    INTEREST = "interest"


# ============================== ОСНОВНЫЕ КЛАССЫ ===============================
@dataclass
class Transaction:
    from_card: int | None
    to_card: int | None
    amount: float
    type: TransactionType
    mcc: str | None
    description: str
    timestamp: _dt.datetime
    cashback: float = DEFAULT_CASHBACK_TRANSACTION


@dataclass
class User:
    last_name: str
    first_name: str
    pin: str
    phone: str
    user_id: int

    accounts: list = field(default_factory=list)
    cards: list = field(default_factory=list)

    def change_pin(self, old_pin: str, new_pin: str):
        if self.pin == old_pin:
            self.pin = new_pin


@dataclass
class Account:
    owner: "User"
    acc_id: str
    balance: float = DEFAULT_ACCOUNT_BALANCE
    cashback_balance: float = DEFAULT_CASHBACK_BALANCE


class Card:
    def __init__(
        self,
        account,
        card_id,
        payment_system=DEFAULT_PAYMENT_SYSTEM,
        pan=EMPTY_PAN,
        issue_date=None,
        expiry_date=None,
        currency=CARD_CURRENCY,
        status=CARD_STATUS,
        bank=None,
    ):
        self.account = account
        self.card_id = card_id
        self.payment_system = payment_system
        self.pan = pan
        self.issue_date = issue_date
        self.expiry_date = expiry_date
        self.currency = currency
        self.status = status
        self.bank = bank

        # Обновляем дату заведения карты и срок окончания карты
        if self.issue_date is None:
            self.issue_date = next(ISSUE_DATE_GENERATOR)
        if self.expiry_date is None and self.issue_date is not None:
            self.expiry_date = _dt.date(
                self.issue_date.year + EXPIRY_YEARS,
                self.issue_date.month,
                self.issue_date.day,
            )

    def get_card_info(self, fields: list = None):
        user = self.account.owner
        data = {
            "bank_name": f"Банк:          {self.bank.name}",
            "bank_bic": f"БИК банка:     {self.bank.bic}",
            "card_id": f"Карта #{self.card_id}",
            "user_id": f"Пользователь:  {user.user_id} — {user.last_name} {user.first_name}",
            "phone": f"Телефон:       {user.phone}",
            "pan": f"PAN:           {self.pan}",
            "acc_id": f"Счёт:          {self.account.acc_id}",
            "payment_system": f"Плат. система: {self.payment_system}",
            "currency": f"Валюта:        {self.currency}",
            "status": f"Статус:        {self.status.value}",
            "issue_date": f"Выпуск:        {self.issue_date}",
            "expiry_date": f"Срок:          {self.expiry_date}",
            "user_cards": f"Карты пользователя: {[c.pan for c in user.cards]}",
            "cashback_balance": f"Кешбэк:        {self.account.cashback_balance:.2f}₽",
            "balance": f"Баланс:        {self.account.balance:.2f}₽",
        }
        if fields is None:
            fields = DEFAULT_CARD_INFO_FIELDS
        return (
            "\n".join([data[field] for field in fields if field in data])
            + "\n"
            + "-" * 50
        )

    def get_balance(self):
        balance = self.account.balance
        return BALANCE_DESCRIPTION.format(balance=balance)

    def deposit(self, amount: float):
        self.account.balance += amount

        self.bank.transaction_log.append(
            Transaction(
                from_card=None,
                to_card=self.card_id,
                amount=amount,
                type=TransactionType.DEPOSIT,
                mcc=None,
                cashback=DEFAULT_CASHBACK_TRANSACTION,
                description=DEPOSIT_DESCRIPTION.format(
                    amount=amount, card_id=self.card_id
                ),
                timestamp=next_timestamp_after(self.issue_date),
            )
        )

    def transfer(self, to_card, amount: float):
        self.account.balance -= amount
        to_card.account.balance += amount
        latest_issue = max(self.issue_date, to_card.issue_date)

        self.bank.transaction_log.append(
            Transaction(
                from_card=self.card_id,
                to_card=to_card.card_id,
                amount=amount,
                type=TransactionType.TRANSFER,
                mcc=None,
                cashback=DEFAULT_CASHBACK_TRANSACTION,
                description=TRANSFER_DESCRIPTION.format(
                    amount=amount, from_card=self.card_id, to_card=to_card.card_id
                ),
                timestamp=next_timestamp_after(latest_issue),
            )
        )

    def pay(self, amount: float, mcc: str):
        self.account.balance -= amount
        self.bank.transaction_log.append(
            Transaction(
                from_card=self.card_id,
                to_card=None,
                amount=amount,
                type=TransactionType.PAY,
                mcc=mcc,
                cashback=DEFAULT_CASHBACK_TRANSACTION,
                description=PAY_DESCRIPTION.format(
                    amount=amount, mcc=mcc, card_id=self.card_id
                ),
                timestamp=next_timestamp_after(self.issue_date),
            )
        )

    def get_transaction_history(self):
        rows = list(TRANSACTION_HISTORY_HEADER)
        for tx in self.bank.transaction_log:
            if tx.from_card == self.card_id or tx.to_card == self.card_id:
                dt_str = tx.timestamp.strftime("%Y-%m-%d %H:%M:%S")
                from_card = tx.from_card if tx.from_card is not None else ""
                to_card = tx.to_card if tx.to_card is not None else ""
                # Знак для пользователя:
                if tx.from_card == self.card_id:
                    sign = "-"
                elif tx.to_card == self.card_id:
                    sign = "+"
                else:
                    sign = ""
                amount_str = f"{sign}{tx.amount:.2f}₽"
                mcc_str = tx.mcc if tx.mcc else ""
                tx_type = tx.type.value
                cashback_str = f"{getattr(tx, 'cashback', 0):.2f}₽"
                rows.append(
                    f"{dt_str},{tx_type},{from_card},{to_card},{amount_str},{mcc_str},{cashback_str},{tx.description}"
                )
        return rows

    def close(self):
        self.status = CardStatus.CLOSED

    def __repr__(self):
        return (
            f"Card(card_id={self.card_id}, pan={self.pan}, account={self.account}, "
            f"status={self.status}, issue_date={self.issue_date}, expiry_date={self.expiry_date})"
        )


class SimpleDebitCard(Card):
    pass


class CashbackDebitCard(Card):
    def __init__(self, *, cashback_rate: float = DEBIT_DEFAULT_CASHBACK_RATE, **kwargs):
        super().__init__(**kwargs)
        self.cashback_rate = cashback_rate

    def pay(self, amount: float, mcc: str):
        self.account.balance -= amount
        cashback_amount = round(float(amount * self.cashback_rate), 2)
        self.account.cashback_balance += cashback_amount

        self.bank.transaction_log.append(
            Transaction(
                from_card=self.card_id,
                to_card=None,
                amount=amount,
                type=TransactionType.PAY,
                mcc=mcc,
                cashback=cashback_amount,
                description=CB_DEBIT_PAY_DESCRIPTION.format(
                    amount=amount,
                    mcc=mcc,
                    card_id=self.card_id,
                    cashback_amount=cashback_amount,
                ),
                timestamp=next_timestamp_after(self.issue_date),
            )
        )


class SavingCard(Card):
    def __init__(
        self, *, interest_rate: float = SAVING_CARD_DEFAULT_INTEREST, **kwargs
    ):
        super().__init__(**kwargs)
        self.interest_rate = interest_rate

    # Копим проценты на положительный остаток
    def accrue_interest(self):
        interest = round(self.account.balance * self.interest_rate, 2)
        self.account.balance += interest

        self.bank.transaction_log.append(
            Transaction(
                from_card=None,
                to_card=self.card_id,
                amount=interest,
                cashback=DEFAULT_CASHBACK_TRANSACTION,
                type=TransactionType.INTEREST,
                mcc=None,
                description=SAVING_INTEREST_DESCRIPTION.format(
                    interest=interest, card_id=self.card_id
                ),
                timestamp=next_timestamp_after(self.issue_date),
            )
        )


@dataclass
class Bank:
    name: str
    bic: str

    _user_seq: int = field(default_factory=lambda: _it.count(1), init=False)
    _account_seq: int = field(default_factory=lambda: _it.count(1), init=False)
    _card_seq: int = field(default_factory=lambda: _it.count(1), init=False)
    _pan_seq: int = field(default_factory=lambda: _it.count(1), init=False)

    customers: dict = field(default_factory=dict)
    accounts: dict = field(default_factory=dict)
    cards: dict = field(default_factory=dict)
    transaction_log: list = field(default_factory=list)

    def _next_account_number(self):
        prefix_left = ACCOUNT_TYPE_CODE + ACCOUNT_CURRENCY
        prefix_right = ACCOUNT_BRANCH
        bic_tail = self.bic[-3:]
        serial = f"{next(self._account_seq):07d}"

        for control_digit in range(10):
            candidate_account_number = (
                prefix_left + str(control_digit) + prefix_right + serial
            )
            digits = [int(d) for d in bic_tail + candidate_account_number]
            weights = [7, 1, 3] * 8
            weighted = [a * b for a, b in zip(digits, weights[:23])]
            control_sum = sum(x % 10 for x in weighted)
            if control_sum % 10 == 0:
                return candidate_account_number

    def _generate_pan(self, system):
        bin_code = BIN_BY_SYSTEM.get(
            system.upper(), BIN_BY_SYSTEM[DEFAULT_PAYMENT_SYSTEM]
        )
        seq = f"{next(self._pan_seq):09d}"
        partial = bin_code + seq
        check = self._luhn(partial)
        return partial + str(check)

    def _luhn(self, number15):
        digits = [int(d) for d in number15[::-1]]
        for i in range(1, len(digits), 2):
            doubled = digits[i] * 2
            digits[i] = doubled - 9 if doubled > 9 else doubled
        return (10 - sum(digits) % 10) % 10

    def apply_for_card(
        self,
        last_name,
        first_name,
        pin,
        phone,
        payment_system=DEFAULT_PAYMENT_SYSTEM,
        card_class: type = Card,
        **kwargs,
    ):
        # Поиск существующего пользователя по телефону
        user = next(
            (
                user
                for user in self.customers.values()
                if user.last_name == last_name
                and user.first_name == first_name
                and user.phone == phone
            ),
            None,
        )
        if not user:
            user_id = next(self._user_seq)
            user = User(last_name, first_name, pin, phone, user_id)
            self.customers[user_id] = user

        acc_id = self._next_account_number()
        acc = Account(owner=user, acc_id=acc_id)
        self.accounts[acc_id] = acc
        user.accounts.append(acc)

        card_id = next(self._card_seq)
        pan = self._generate_pan(payment_system)
        issue_date = next(ISSUE_DATE_GENERATOR)

        card = card_class(
            account=acc,
            card_id=card_id,
            payment_system=payment_system,
            pan=pan,
            issue_date=issue_date,
            currency=CARD_CURRENCY,
            status=CARD_STATUS,
            bank=self,
            **kwargs,
        )

        self.cards[card_id] = card
        user.cards.append(card)
        return card

    def issue_simple_debit_card(
        self, last_name, first_name, pin, phone, payment_system, **kwargs
    ):
        return self.apply_for_card(
            last_name,
            first_name,
            pin,
            phone,
            payment_system,
            card_class=SimpleDebitCard,
            **kwargs,
        )

    def issue_cashback_debit_card(
        self, last_name, first_name, pin, phone, payment_system, **kwargs
    ):
        return self.apply_for_card(
            last_name,
            first_name,
            pin,
            phone,
            payment_system,
            card_class=CashbackDebitCard,
            **kwargs,
        )

    def issue_saving_card(
        self, last_name, first_name, pin, phone, payment_system, **kwargs
    ):
        return self.apply_for_card(
            last_name,
            first_name,
            pin,
            phone,
            payment_system,
            card_class=SavingCard,
            **kwargs,
        )

    def get_global_history(self) -> list:
        rows = list(TRANSACTION_HISTORY_HEADER)
        for tx in self.transaction_log:
            dt_str = tx.timestamp.strftime("%Y-%m-%d %H:%M:%S")
            tx_type = tx.type.value
            from_card = tx.from_card if tx.from_card is not None else ""
            to_card = tx.to_card if tx.to_card is not None else ""
            amount_str = f"{tx.amount:.2f}₽"
            cashback_str = f"{getattr(tx, 'cashback', 0):.2f}₽"
            mcc_str = tx.mcc if tx.mcc else ""
            rows.append(
                f"{dt_str},{tx_type},{from_card},{to_card},{amount_str},{mcc_str},{cashback_str},{tx.description}"
            )
        return rows
Посмотреть решение задачи 5.4. Обработка исключений

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

Скачать Solution_54.py

import datetime as _dt
import itertools as _it
import re
from dataclasses import dataclass, field
from enum import Enum

# =============================== КОНСТАНТЫ ===============================
DEFAULT_CARD_INFO_FIELDS = [
    "card_id",
    "user_id",
    "phone",
    "bank_name",
    "bank_bic",
    "acc_id",
    "pan",
    "payment_system",
    "currency",
    "status",
    "issue_date",
    "expiry_date",
    "balance",
    "cashback_balance",
    "user_cards",
]
DEFAULT_ACCOUNT_BALANCE = 0.00
DEFAULT_CASHBACK_BALANCE = 0.00
DEFAULT_CASHBACK_TRANSACTION = 0.00
CARD_CURRENCY = "RUB"
DEFAULT_PAYMENT_SYSTEM = "MIR"

ACCOUNT_TYPE_CODE = "40817"  # тип счета для физлиц
ACCOUNT_BRANCH = "0000"  # отсутствие филиалов у банка
ACCOUNT_CURRENCY = "810"  # идентификатор для рублёвых операций


EMPTY_PAN = "0000000000000000"

BIN_BY_SYSTEM = {
    "MIR": "220400",
    "VISA": "400000",
    "MASTERCARD": "510000",
}

TRANSACTION_HISTORY_HEADER = [
    "timestamp,type,from_card,to_card,amount,mcc,cashback,description"
]
DEPOSIT_DESCRIPTION = "{amount:.2f}₽ → карта #{card_id}"
TRANSFER_DESCRIPTION = "{amount:.2f}₽: карта #{from_card} → карта #{to_card}"
PAY_DESCRIPTION = "{amount:.2f}₽ (MCC: {mcc}) с карты #{card_id}"
BALANCE_DESCRIPTION = "Баланс: {balance:.2f}₽"
CB_DEBIT_PAY_DESCRIPTION = "{amount:.2f}₽ (MCC: {mcc}) с карты #{card_id} (кешбэк {cashback_amount:.2f}₽)"
SAVING_INTEREST_DESCRIPTION = "Начислены проценты {interest:.2f}₽ по накопительной карте #{card_id}"

FORBIDDEN_MCC = {"7995", "4829", "6051"}  # Пример: азартные игры, переводы, квази-кеш

DEBIT_DEFAULT_CASHBACK_RATE = 0.03
DEPOSIT_LIMIT = 1_000_000.00
TRANSFER_LIMIT = 500_000.00
PAY_LIMIT = 500_000.00
MAX_CASHBACK_RATE = 0.10  # 10%
MAX_SAVING_INTEREST_RATE = 0.3

SAVING_CARD_DEFAULT_INTEREST = 0.015

# =============================== ГЕНЕРАТОРЫ ДАННЫХ ===============================
ISSUE_DATE_START = _dt.date(2022, 1, 1)
ISSUE_DATE_GENERATOR = (ISSUE_DATE_START + _dt.timedelta(days=i) for i in _it.count())
EXPIRY_YEARS = 4

TIMESTAMP_START = _dt.datetime(2022, 1, 1, 9, 0, 0)


def timestamp_generator():
    for i in _it.count():
        base_date = TIMESTAMP_START + _dt.timedelta(days=i)
        hour = 9 + (i * 3) % 10  # цикличное смещение часа
        minute = (i * 7) % 60  # цикличное смещение минут
        second = (i * 11) % 60  # цикличное смещение секунд
        yield base_date.replace(hour=hour % 24, minute=minute, second=second)


TIMESTAMP_GENERATOR = timestamp_generator()


def next_timestamp_after(issue_date: _dt.date) -> _dt.datetime:
    """
    Возвращает ближайший timestamp из генератора, который позже issue_date.
    """
    while True:
        ts = next(TIMESTAMP_GENERATOR)
        if ts.date() > issue_date:
            return ts


# =============================== ENUM'Ы ===============================
class CardStatus(Enum):
    ACTIVE = "Active"
    CLOSED = "Closed"
    BLOCKED = "Blocked"


CARD_STATUS = CardStatus.ACTIVE


class TransactionType(Enum):
    DEPOSIT = "deposit"
    TRANSFER = "transfer"
    PAY = "pay"
    INTEREST = "interest"


# ======================= КАТАЛОГ СООБЩЕНИЙ ОБ ОШИБКАХ =======================
class BankError(Exception):
    """Базовый класс для всех ошибок банковского приложения."""


class ValidationError(BankError):
    """Ошибка валидации пользовательских данных (форматы, обязательные поля, допустимые значения)."""

    PIN_MISMATCH = "Введенный ПИН-код не соответствует текущему"
    PIN_INVALID = "ПИН-код должен быть строкой из 4 символов"
    PIN_FORMAT_INVALID = "ПИН-код должен состоять только из цифр"
    NAME_INVALID = "Имя и фамилия должны быть на русском, без использования специальных символов"
    AMOUNT_NEGATIVE = "Сумма должна быть положительной"
    DEPOSIT_AMOUNT_NEGATIVE = "Сумма пополнений должна быть положительной"
    CASHBACK_NEGATIVE = "Процент кешбэка на покупки должен быть положительным"
    INTEREST_NEGATIVE = "Ставка по счету должна быть положительной"
    PAY_AMOUNT_NEGATIVE = "Сумма покупки должна быть положительной"
    INVALID_MCC = "Неверный код категории продавца (MCC)"
    PAYMENT_SYSTEM_NOT_SUPPORTED = "Платежная система {payment_system} не поддерживается банком"


class NotFoundError(BankError):
    """Ошибка отсутствия объекта: карта, счёт, пользователь не найдены."""

    RECIPIENT_NOT_FOUND = "Ошибка номером карты. Такой карты не существует."


class AccessError(BankError):
    """Ошибка доступа к объекту или операции: карта/счёт не активны, недоступны или не привязаны."""

    CARD_CLOSED = "Карта закрыта или заблокирована. Невозможно провести операцию."
    ACCOUNT_NOT_LINKED = "Карта не привязана к счёту"
    BANK_NOT_LINKED = "Карта не привязана к банку"
    RECIPIENT_ACCOUNT_NOT_LINKED = "Карта получателя не привязана к счёту"
    RECIPIENT_CARD_CLOSED = "Карта получателя закрыта или заблокирована. Невозможно провести операцию."


class BusinessRuleError(BankError):
    """Ошибка бизнес-логики: нарушено ограничение по правилам банка
    (лимиты, количество, уникальность, запрещённые операции)."""

    DEPOSIT_LIMIT_EXCEEDED = "Превышен лимит депозита. Карта заблокирована до выяснения причин."
    TRANSFER_LIMIT_EXCEEDED = "Подозрение на мошенническую операцию. Карта заблокирована до выяснения причин."
    CASHBACK_LIMIT = "Процент кешбэка завышен, возможна техническая ошибка. Карта заблокирована до выяснения причин."
    TRANSFER_TO_SELF = "Нельзя пересылать деньги самому себе"
    USER_CONFLICT = "Пользователь с таким телефоном уже зарегистрирован"
    TOO_MANY_DEBIT_CARDS = "У пользователя уже есть пять дебетовых карт"
    MCC_FORBIDDEN = "Оплата отклонена. Покупки по {mcc} запрещены банком"
    PURCHASE_LIMIT_EXCEEDED = "Сумма оплаты превышает лимит. Операция заблокирована до выяснения причин."
    PAYMENT_NOT_ALLOWED_FOR_SAVING = "С накопительного счёта нельзя списывать покупки"
    SAVING_RATE_TOO_HIGH = (
        "Ставка накопления завышена, возможна техническая ошибка. "
        "Карта заблокирована до выяснения причины."
    )


class InsufficientFundsError(BankError):
    """Ошибка недостатка денег"""

    INSUFFICIENT_FUNDS_FOR_PAYMENT = "Недостаточно денег для оплаты."
    INSUFFICIENT_FUNDS_FOR_TRANSFER = "Недостаточно денег для осуществления перевода."


# ============================== ОСНОВНЫЕ КЛАССЫ ===============================


@dataclass
class Transaction:
    from_card: int | None
    to_card: int | None
    amount: float
    type: TransactionType
    mcc: str | None
    description: str
    timestamp: _dt.datetime
    cashback: float = DEFAULT_CASHBACK_TRANSACTION


@dataclass
class User:
    last_name: str
    first_name: str
    pin: str
    phone: str
    user_id: int

    accounts: list = field(default_factory=list)
    cards: list = field(default_factory=list)

    def change_pin(self, old_pin: str, new_pin: str):
        # 1,2: Оба ПИН-кода должны быть строками из 4 цифр
        for pin_value in [old_pin, new_pin]:
            if not (isinstance(pin_value, str) and len(pin_value) == 4):
                raise ValidationError(ValidationError.PIN_INVALID)
            if not pin_value.isdigit():
                raise ValidationError(ValidationError.PIN_FORMAT_INVALID)
        # 3: Старый ПИН-код совпадает с введённым
        if self.pin != old_pin:
            raise ValidationError(ValidationError.PIN_MISMATCH)
        # Всё хорошо — меняем пин
        self.pin = new_pin


@dataclass
class Account:
    owner: "User"
    acc_id: str
    balance: float = DEFAULT_ACCOUNT_BALANCE
    cashback_balance: float = DEFAULT_CASHBACK_BALANCE  # Баланс кешбэка, по умолчанию


class Card:
    def __init__(
        self,
        account,
        card_id,
        payment_system=DEFAULT_PAYMENT_SYSTEM,
        pan=EMPTY_PAN,
        issue_date=None,
        expiry_date=None,
        currency=CARD_CURRENCY,
        status=CARD_STATUS,
        bank=None,
    ):
        self.account = account
        self.card_id = card_id
        self.payment_system = payment_system
        self.pan = pan
        self.issue_date = issue_date
        self.expiry_date = expiry_date
        self.currency = currency
        self.status = status
        self.bank = bank

        # Обновляем дату заведения карты и срок окончания карты
        if self.issue_date is None:
            self.issue_date = next(ISSUE_DATE_GENERATOR)
        if self.expiry_date is None and self.issue_date is not None:
            self.expiry_date = _dt.date(
                self.issue_date.year + EXPIRY_YEARS,
                self.issue_date.month,
                self.issue_date.day,
            )

    def get_card_info(self, fields: list = None):
        # 1. Сначала проверка: есть ли счёт
        if not self.account:
            raise AccessError(AccessError.ACCOUNT_NOT_LINKED)
        # 2. Потом статус (CLOSED или BLOCKED)
        if self.status in [CardStatus.CLOSED, CardStatus.BLOCKED]:
            raise AccessError(AccessError.CARD_CLOSED)

        user = self.account.owner
        data = {
            "bank_name": f"Банк:          {self.bank.name}",
            "bank_bic": f"БИК банка:     {self.bank.bic}",
            "card_id": f"Карта #{self.card_id}",
            "user_id": f"Пользователь:  {user.user_id} — {user.last_name} {user.first_name}",
            "phone": f"Телефон:       {user.phone}",
            "pan": f"PAN:           {self.pan}",
            "acc_id": f"Счёт:          {self.account.acc_id}",
            "payment_system": f"Плат. система: {self.payment_system}",
            "currency": f"Валюта:        {self.currency}",
            "status": f"Статус:        {self.status.value}",
            "issue_date": f"Выпуск:        {self.issue_date}",
            "expiry_date": f"Срок:          {self.expiry_date}",
            "user_cards": f"Карты пользователя: {[c.pan for c in user.cards]}",
            "cashback_balance": f"Кешбэк:        {self.account.cashback_balance:.2f}₽",
            "balance": f"Баланс:        {self.account.balance:.2f}₽",
        }
        if fields is None:
            fields = DEFAULT_CARD_INFO_FIELDS
        return (
            "\n".join([data[field] for field in fields if field in data])
            + "\n"
            + "-" * 50
        )

    def get_balance(self):
        # 1. Карта должна быть привязана к счёту
        if not self.account:
            raise AccessError(AccessError.ACCOUNT_NOT_LINKED)
        # 2. Карта не должна быть закрыта/заблокирована
        if self.status in [CardStatus.CLOSED, CardStatus.BLOCKED]:
            raise AccessError(AccessError.CARD_CLOSED)
        balance = self.account.balance
        return BALANCE_DESCRIPTION.format(balance=balance)

    def deposit(self, amount: float):
        # 1. Сумма пополнения положительная
        if amount <= 0:
            raise ValidationError(ValidationError.DEPOSIT_AMOUNT_NEGATIVE)
        # 2. Карта привязана к счёту
        if not self.account:
            raise AccessError(AccessError.ACCOUNT_NOT_LINKED)
        # 3. Карта не закрыта/не заблокирована
        if self.status in [CardStatus.CLOSED, CardStatus.BLOCKED]:
            raise AccessError(AccessError.CARD_CLOSED)
        # 4. Не превышен лимит пополнения
        if amount > DEPOSIT_LIMIT:
            self.status = CardStatus.BLOCKED
            raise BusinessRuleError(BusinessRuleError.DEPOSIT_LIMIT_EXCEEDED)

        # Основное действие
        self.account.balance += amount

        self.bank.transaction_log.append(
            Transaction(
                from_card=None,
                to_card=self.card_id,
                amount=amount,
                type=TransactionType.DEPOSIT,
                mcc=None,
                cashback=DEFAULT_CASHBACK_TRANSACTION,
                description=DEPOSIT_DESCRIPTION.format(
                    amount=amount, card_id=self.card_id
                ),
                timestamp=next_timestamp_after(self.issue_date),
            )
        )

    def transfer(self, to_card, amount: float):
        # 1. Сумма перевода положительная
        if amount <= 0:
            raise ValidationError(ValidationError.AMOUNT_NEGATIVE)
        # 2. Карта получателя должна существовать
        if to_card is None:
            raise NotFoundError(NotFoundError.RECIPIENT_NOT_FOUND)
        # 3. Карта отправителя привязана к счёту
        if not self.account:
            raise AccessError(AccessError.ACCOUNT_NOT_LINKED)
        # 4. Карта получателя привязана к счёту
        if not to_card.account:
            raise AccessError(AccessError.RECIPIENT_ACCOUNT_NOT_LINKED)
        # 5. Карта отправителя не заблокирована/не закрыта
        if self.status in [CardStatus.CLOSED, CardStatus.BLOCKED]:
            raise AccessError(AccessError.CARD_CLOSED)
        # 6. Карта получателя не заблокирована/не закрыта
        if to_card.status in [CardStatus.CLOSED, CardStatus.BLOCKED]:
            raise AccessError(AccessError.RECIPIENT_CARD_CLOSED)
        # 7. Нельзя переводить себе
        if self.card_id == to_card.card_id:
            raise BusinessRuleError(BusinessRuleError.TRANSFER_TO_SELF)
        # 8. Проверка лимита
        if amount > TRANSFER_LIMIT:
            self.status = CardStatus.BLOCKED
            raise BusinessRuleError(BusinessRuleError.TRANSFER_LIMIT_EXCEEDED)
        # 9. Достаточно ли денег
        if self.account.balance < amount:
            raise InsufficientFundsError(
                InsufficientFundsError.INSUFFICIENT_FUNDS_FOR_TRANSFER
            )

        # Сам перевод
        self.account.balance -= amount
        to_card.account.balance += amount
        latest_issue = max(self.issue_date, to_card.issue_date)

        self.bank.transaction_log.append(
            Transaction(
                from_card=self.card_id,
                to_card=to_card.card_id,
                amount=amount,
                type=TransactionType.TRANSFER,
                mcc=None,
                cashback=DEFAULT_CASHBACK_TRANSACTION,
                description=TRANSFER_DESCRIPTION.format(
                    amount=amount, from_card=self.card_id, to_card=to_card.card_id
                ),
                timestamp=next_timestamp_after(latest_issue),
            )
        )

    def pay(self, amount: float, mcc: str):
        # 1. Сумма покупки не положительная
        if amount <= 0:
            raise ValidationError(ValidationError.PAY_AMOUNT_NEGATIVE)
        # 2. Неверный формат MCC
        if not (isinstance(mcc, str) and mcc.isdigit() and len(mcc) == 4):
            raise ValidationError(ValidationError.INVALID_MCC)
        # 3. Карта не привязана к счёту
        if not self.account:
            raise AccessError(AccessError.ACCOUNT_NOT_LINKED)
        # 4. Карта закрыта или заблокирована
        if self.status in [CardStatus.CLOSED, CardStatus.BLOCKED]:
            raise AccessError(AccessError.CARD_CLOSED)
        # 5. Запрещённый MCC
        if mcc in FORBIDDEN_MCC:
            raise BusinessRuleError(BusinessRuleError.MCC_FORBIDDEN.format(mcc=mcc))
        # 6. Превышен лимит оплаты
        if amount > PAY_LIMIT:
            self.status = CardStatus.BLOCKED
            raise BusinessRuleError(BusinessRuleError.PURCHASE_LIMIT_EXCEEDED)
        # 7. Недостаточно денег
        if self.account.balance < amount:
            raise InsufficientFundsError(
                InsufficientFundsError.INSUFFICIENT_FUNDS_FOR_PAYMENT
            )

        self.account.balance -= amount

        self.bank.transaction_log.append(
            Transaction(
                from_card=self.card_id,
                to_card=None,
                amount=amount,
                type=TransactionType.PAY,
                mcc=mcc,
                cashback=DEFAULT_CASHBACK_TRANSACTION,
                description=PAY_DESCRIPTION.format(
                    amount=amount, mcc=mcc, card_id=self.card_id
                ),
                timestamp=next_timestamp_after(self.issue_date),
            )
        )

    def get_transaction_history(self):
        # 1. Карта не привязана к счёту
        if not self.account:
            raise AccessError(AccessError.ACCOUNT_NOT_LINKED)
        # 2. Карта не привязана к банку
        if not self.bank:
            raise AccessError(AccessError.BANK_NOT_LINKED)
        # 3. Карта закрыта или заблокирована
        if self.status in [CardStatus.CLOSED, CardStatus.BLOCKED]:
            raise AccessError(AccessError.CARD_CLOSED)

        rows = list(TRANSACTION_HISTORY_HEADER)
        for tx in self.bank.transaction_log:
            if tx.from_card == self.card_id or tx.to_card == self.card_id:
                dt_str = tx.timestamp.strftime("%Y-%m-%d %H:%M:%S")
                from_card = tx.from_card if tx.from_card is not None else ""
                to_card = tx.to_card if tx.to_card is not None else ""
                # Знак для пользователя:
                if tx.from_card == self.card_id:
                    sign = "-"
                elif tx.to_card == self.card_id:
                    sign = "+"
                else:
                    sign = ""
                amount_str = f"{sign}{tx.amount:.2f}₽"
                mcc_str = tx.mcc if tx.mcc else ""
                tx_type = tx.type.value
                cashback_str = f"{getattr(tx, 'cashback', 0):.2f}₽"
                rows.append(
                    f"{dt_str},{tx_type},{from_card},{to_card},{amount_str},{mcc_str},{cashback_str},{tx.description}"
                )
        return rows

    def close(self):
        self.status = CardStatus.CLOSED


class SimpleDebitCard(Card):
    pass


class CashbackDebitCard(Card):
    def __init__(self, *, cashback_rate: float = DEBIT_DEFAULT_CASHBACK_RATE, **kwargs):
        super().__init__(**kwargs)
        self.cashback_rate = cashback_rate

    def pay(self, amount: float, mcc: str):
        # 1. Сумма оплаты не положительная
        if amount <= 0:
            raise ValidationError(ValidationError.PAY_AMOUNT_NEGATIVE)
        # 2. Неверный формат MCC
        if not (isinstance(mcc, str) and mcc.isdigit() and len(mcc) == 4):
            raise ValidationError(ValidationError.INVALID_MCC)
        # 3. Процент кешбэка отрицательный
        if self.cashback_rate < 0:
            raise ValidationError(ValidationError.CASHBACK_NEGATIVE)
        # 4. Карта не привязана к счёту
        if not self.account:
            raise AccessError(AccessError.ACCOUNT_NOT_LINKED)
        # 5. Карта закрыта или заблокирована
        if self.status in [CardStatus.CLOSED, CardStatus.BLOCKED]:
            raise AccessError(AccessError.CARD_CLOSED)
        # 6. Процент кешбэка больше или равен лимиту (блокировка карты)
        if self.cashback_rate >= MAX_CASHBACK_RATE:
            self.status = CardStatus.BLOCKED
            raise BusinessRuleError(BusinessRuleError.CASHBACK_LIMIT)
        # 7. Запрещённый MCC
        if mcc in FORBIDDEN_MCC:
            raise BusinessRuleError(BusinessRuleError.MCC_FORBIDDEN.format(mcc=mcc))
        # 8. Сумма оплаты превышает лимит (блокировка карты)
        if amount > PAY_LIMIT:
            self.status = CardStatus.BLOCKED
            raise BusinessRuleError(BusinessRuleError.PURCHASE_LIMIT_EXCEEDED)
        # 9. Недостаточно денег
        if self.account.balance < amount:
            raise InsufficientFundsError(
                InsufficientFundsError.INSUFFICIENT_FUNDS_FOR_PAYMENT
            )

        # Основное действие — списание и кешбэк
        self.account.balance -= amount
        cashback_amount = round(float(amount * self.cashback_rate), 2)
        self.account.cashback_balance += cashback_amount

        self.bank.transaction_log.append(
            Transaction(
                from_card=self.card_id,
                to_card=None,
                amount=amount,
                type=TransactionType.PAY,
                mcc=mcc,
                cashback=cashback_amount,
                description=CB_DEBIT_PAY_DESCRIPTION.format(
                    amount=amount,
                    mcc=mcc,
                    card_id=self.card_id,
                    cashback_amount=cashback_amount,
                ),
                timestamp=next_timestamp_after(self.issue_date),
            )
        )


class SavingCard(Card):
    def __init__(
        self, *, interest_rate: float = SAVING_CARD_DEFAULT_INTEREST, **kwargs
    ):
        super().__init__(**kwargs)
        self.interest_rate = interest_rate

    def pay(self, amount: float, mcc: str):
        # 1. Всегда выбрасываем ошибку (оплаты запрещены)
        raise BusinessRuleError(BusinessRuleError.PAYMENT_NOT_ALLOWED_FOR_SAVING)

    # Копим проценты на положительный остаток
    def accrue_interest(self):
        # 1. Ставка накопления отрицательная
        if self.interest_rate < 0:
            raise ValidationError(ValidationError.INTEREST_NEGATIVE)
        # 2. Карта не привязана к счёту
        if not self.account:
            raise AccessError(AccessError.ACCOUNT_NOT_LINKED)
        # 3. Карта закрыта или заблокирована
        if self.status in [CardStatus.CLOSED, CardStatus.BLOCKED]:
            raise AccessError(AccessError.CARD_CLOSED)
        # 4. Ставка накопления превышает лимит (блокировка)
        if self.interest_rate >= MAX_SAVING_INTEREST_RATE:
            self.status = CardStatus.BLOCKED
            raise BusinessRuleError(BusinessRuleError.SAVING_RATE_TOO_HIGH)

        if self.account.balance >= 0:
            interest = round(self.account.balance * self.interest_rate, 2)
            self.account.balance += interest

            self.bank.transaction_log.append(
                Transaction(
                    from_card=None,
                    to_card=self.card_id,
                    amount=interest,
                    cashback=DEFAULT_CASHBACK_TRANSACTION,
                    type=TransactionType.INTEREST,
                    mcc=None,
                    description=SAVING_INTEREST_DESCRIPTION.format(
                        interest=interest, card_id=self.card_id
                    ),
                    timestamp=next_timestamp_after(self.issue_date),
                )
            )


@dataclass
class Bank:
    name: str
    bic: str

    _user_seq: int = field(default_factory=lambda: _it.count(1), init=False)
    _account_seq: int = field(default_factory=lambda: _it.count(1), init=False)
    _card_seq: int = field(default_factory=lambda: _it.count(1), init=False)
    _pan_seq: int = field(default_factory=lambda: _it.count(1), init=False)

    customers: dict = field(default_factory=dict)
    accounts: dict = field(default_factory=dict)
    cards: dict = field(default_factory=dict)
    transaction_log: list = field(default_factory=list)

    def _next_account_number(self):
        prefix_left = ACCOUNT_TYPE_CODE + ACCOUNT_CURRENCY
        prefix_right = ACCOUNT_BRANCH
        bic_tail = self.bic[-3:]
        serial = f"{next(self._account_seq):07d}"

        for control_digit in range(10):
            candidate_account_number = (
                prefix_left + str(control_digit) + prefix_right + serial
            )
            digits = [int(d) for d in bic_tail + candidate_account_number]
            weights = [7, 1, 3] * 8
            weighted = [a * b for a, b in zip(digits, weights[:23])]
            control_sum = sum(x % 10 for x in weighted)
            if control_sum % 10 == 0:
                return candidate_account_number

    def _generate_pan(self, system):
        bin_code = BIN_BY_SYSTEM.get(
            system.upper(), BIN_BY_SYSTEM[DEFAULT_PAYMENT_SYSTEM]
        )
        seq = f"{next(self._pan_seq):09d}"
        partial = bin_code + seq
        check = self._luhn(partial)
        return partial + str(check)

    def _luhn(self, number15):
        digits = [int(d) for d in number15[::-1]]
        for i in range(1, len(digits), 2):
            doubled = digits[i] * 2
            digits[i] = doubled - 9 if doubled > 9 else doubled
        return (10 - sum(digits) % 10) % 10

    def apply_for_card(
        self,
        last_name,
        first_name,
        pin,
        phone,
        payment_system=DEFAULT_PAYMENT_SYSTEM,
        card_class: type = Card,
        **kwargs,
    ):
        # 1, 2. ПИН-код — строка, состоящая из 4 цифр
        if not (isinstance(pin, str) and pin.isdigit() and len(pin) == 4):
            if not (isinstance(pin, str) and pin.isdigit()):
                raise ValidationError(ValidationError.PIN_FORMAT_INVALID)
            else:
                raise ValidationError(ValidationError.PIN_INVALID)

        # 3. Валидные имя и фамилия (на русском и без спецсимволов)

        name_pattern = r"^[А-ЯЁа-яё]+(?:[ -][А-ЯЁа-яё]+)*$"
        if not re.fullmatch(name_pattern, last_name) or not re.fullmatch(
            name_pattern, first_name
        ):
            raise ValidationError(ValidationError.NAME_INVALID)

        # 4. Корректная платёжная система
        allowed_systems = set(BIN_BY_SYSTEM.keys())
        if payment_system.upper() not in allowed_systems:
            raise ValidationError(
                ValidationError.PAYMENT_SYSTEM_NOT_SUPPORTED.format(
                    payment_system=payment_system
                )
            )

        # 5. Отсутствие другого пользователя с таким же телефоном
        for existing_user in self.customers.values():
            if existing_user.phone == phone:
                if (
                    existing_user.last_name != last_name
                    or existing_user.first_name != first_name
                ):
                    raise BusinessRuleError(BusinessRuleError.USER_CONFLICT)

        # 5. Максимальное число дебетовых/кешбэк карт — 5
        user = next(
            (
                user
                for user in self.customers.values()
                if user.last_name == last_name
                and user.first_name == first_name
                and user.phone == phone
            ),
            None,
        )
        if user and issubclass(card_class, (SimpleDebitCard, CashbackDebitCard)):
            debit_cards_count = sum(
                1
                for c in user.cards
                if isinstance(c, (SimpleDebitCard, CashbackDebitCard))
                and c.status not in [CardStatus.CLOSED, CardStatus.BLOCKED]
            )
            if debit_cards_count >= 5:
                raise BusinessRuleError(BusinessRuleError.TOO_MANY_DEBIT_CARDS)

        # Поиск существующего пользователя по телефону
        user = next(
            (
                user
                for user in self.customers.values()
                if user.last_name == last_name
                and user.first_name == first_name
                and user.phone == phone
            ),
            None,
        )
        if not user:
            user_id = next(self._user_seq)
            user = User(last_name, first_name, pin, phone, user_id)
            self.customers[user_id] = user

        acc_id = self._next_account_number()
        acc = Account(owner=user, acc_id=acc_id)
        self.accounts[acc_id] = acc
        user.accounts.append(acc)

        card_id = next(self._card_seq)
        pan = self._generate_pan(payment_system)
        issue_date = next(ISSUE_DATE_GENERATOR)

        card = card_class(
            account=acc,
            card_id=card_id,
            payment_system=payment_system,
            pan=pan,
            issue_date=issue_date,
            currency=CARD_CURRENCY,
            status=CARD_STATUS,
            bank=self,
            **kwargs,
        )

        self.cards[card_id] = card
        user.cards.append(card)
        return card

    def issue_simple_debit_card(
        self, last_name, first_name, pin, phone, payment_system, **kwargs
    ):
        return self.apply_for_card(
            last_name,
            first_name,
            pin,
            phone,
            payment_system,
            card_class=SimpleDebitCard,
            **kwargs,
        )

    def issue_cashback_debit_card(
        self, last_name, first_name, pin, phone, payment_system, **kwargs
    ):
        return self.apply_for_card(
            last_name,
            first_name,
            pin,
            phone,
            payment_system,
            card_class=CashbackDebitCard,
            **kwargs,
        )

    def issue_saving_card(
            self, last_name, first_name, pin, phone, payment_system, **kwargs
    ):
        return self.apply_for_card(
            last_name,
            first_name,
            pin,
            phone,
            payment_system,
            card_class=SavingCard,
            **kwargs,
        )

    def get_global_history(self) -> list:
        rows = list(TRANSACTION_HISTORY_HEADER)
        for tx in self.transaction_log:
            dt_str = tx.timestamp.strftime("%Y-%m-%d %H:%M:%S")
            tx_type = tx.type.value
            from_card = tx.from_card if tx.from_card is not None else ""
            to_card = tx.to_card if tx.to_card is not None else ""
            amount_str = f"{tx.amount:.2f}₽"
            cashback_str = f"{getattr(tx, 'cashback', 0):.2f}₽"
            mcc_str = tx.mcc if tx.mcc else ""
            rows.append(
                f"{dt_str},{tx_type},{from_card},{to_card},{amount_str},{mcc_str},{cashback_str},{tx.description}"
            )
        return rows
Посмотреть решение задачи 5.5. Сбор всех ошибок и поддержка работы программы

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

Скачать Solution_55.py

import datetime as _dt
import itertools as _it
import re
from dataclasses import dataclass, field
from enum import Enum

# =============================== КОНСТАНТЫ ===============================
DEFAULT_CARD_INFO_FIELDS = [
    "card_id",
    "user_id",
    "phone",
    "bank_name",
    "bank_bic",
    "acc_id",
    "pan",
    "payment_system",
    "currency",
    "status",
    "issue_date",
    "expiry_date",
    "balance",
    "cashback_balance",
    "user_cards",
]
DEFAULT_ACCOUNT_BALANCE = 0.00
DEFAULT_CASHBACK_BALANCE = 0.00
DEFAULT_CASHBACK_TRANSACTION = 0.00
CARD_CURRENCY = "RUB"
DEFAULT_PAYMENT_SYSTEM = "MIR"

ACCOUNT_TYPE_CODE = "40817"  # тип счета для физлиц
ACCOUNT_BRANCH = "0000"  # отсутствие филиалов у банка
ACCOUNT_CURRENCY = "810"  # идентификатор для рублёвых операций


EMPTY_PAN = "0000000000000000"

BIN_BY_SYSTEM = {
    "MIR": "220400",
    "VISA": "400000",
    "MASTERCARD": "510000",
}

TRANSACTION_HISTORY_HEADER = [
    "timestamp,type,from_card,to_card,amount,mcc,cashback,description"
]
DEPOSIT_DESCRIPTION = "{amount:.2f}₽ → карта #{card_id}"
TRANSFER_DESCRIPTION = "{amount:.2f}₽: карта #{from_card} → карта #{to_card}"
PAY_DESCRIPTION = "{amount:.2f}₽ (MCC: {mcc}) с карты #{card_id}"
BALANCE_DESCRIPTION = "Баланс: {balance:.2f}₽"
CB_DEBIT_PAY_DESCRIPTION = "{amount:.2f}₽ (MCC: {mcc}) с карты #{card_id} (кешбэк {cashback_amount:.2f}₽)"
SAVING_INTEREST_DESCRIPTION = "Начислены проценты {interest:.2f}₽ по накопительной карте #{card_id}"

FORBIDDEN_MCC = {"7995", "4829", "6051"}  # Пример: азартные игры, переводы, квази-кеш

DEBIT_DEFAULT_CASHBACK_RATE = 0.03
DEPOSIT_LIMIT = 1_000_000.00
TRANSFER_LIMIT = 500_000.00
PAY_LIMIT = 500_000.00
MAX_CASHBACK_RATE = 0.10  # 10%
MAX_SAVING_INTEREST_RATE = 0.3

SAVING_CARD_DEFAULT_INTEREST = 0.015

# =============================== ГЕНЕРАТОРЫ ДАННЫХ ===============================
ISSUE_DATE_START = _dt.date(2022, 1, 1)
ISSUE_DATE_GENERATOR = (ISSUE_DATE_START + _dt.timedelta(days=i) for i in _it.count())
EXPIRY_YEARS = 4

TIMESTAMP_START = _dt.datetime(2022, 1, 1, 9, 0, 0)


def timestamp_generator():
    for i in _it.count():
        base_date = TIMESTAMP_START + _dt.timedelta(days=i)
        hour = 9 + (i * 3) % 10  # цикличное смещение часа
        minute = (i * 7) % 60  # цикличное смещение минут
        second = (i * 11) % 60  # цикличное смещение секунд
        yield base_date.replace(hour=hour % 24, minute=minute, second=second)


TIMESTAMP_GENERATOR = timestamp_generator()


def next_timestamp_after(issue_date: _dt.date) -> _dt.datetime:
    """
    Возвращает ближайший timestamp из генератора, который позже issue_date.
    """
    while True:
        ts = next(TIMESTAMP_GENERATOR)
        if ts.date() > issue_date:
            return ts


# =============================== ENUM'Ы ===============================
class CardStatus(Enum):
    ACTIVE = "Active"
    CLOSED = "Closed"
    BLOCKED = "Blocked"


CARD_STATUS = CardStatus.ACTIVE


class TransactionType(Enum):
    DEPOSIT = "deposit"
    TRANSFER = "transfer"
    PAY = "pay"
    INTEREST = "interest"


# ======================= КАТАЛОГ СООБЩЕНИЙ ОБ ОШИБКАХ =======================
class BankError(Exception):
    """Базовый класс для всех ошибок банковского приложения."""


class ValidationError(BankError):
    """Ошибка валидации пользовательских данных (форматы, обязательные поля, допустимые значения)."""

    PIN_MISMATCH = "Введенный ПИН-код не соответствует текущему"
    PIN_INVALID = "ПИН-код должен быть строкой из 4 символов"
    PIN_FORMAT_INVALID = "ПИН-код должен состоять только из цифр"
    NAME_INVALID = "Имя и фамилия должны быть на русском, без использования специальных символов"
    AMOUNT_NEGATIVE = "Сумма должна быть положительной"
    DEPOSIT_AMOUNT_NEGATIVE = "Сумма пополнений должна быть положительной"
    CASHBACK_NEGATIVE = "Процент кешбэка на покупки должен быть положительным"
    INTEREST_NEGATIVE = "Ставка по счету должна быть положительной"
    PAY_AMOUNT_NEGATIVE = "Сумма покупки должна быть положительной"
    INVALID_MCC = "Неверный код категории продавца (MCC)"
    PAYMENT_SYSTEM_NOT_SUPPORTED = "Платежная система {payment_system} не поддерживается банком"


class NotFoundError(BankError):
    """Ошибка отсутствия объекта: карта, счёт, пользователь не найдены."""

    RECIPIENT_NOT_FOUND = "Ошибка номером карты. Такой карты не существует."


class AccessError(BankError):
    """Ошибка доступа к объекту или операции: карта/счёт не активны, недоступны или не привязаны."""

    CARD_CLOSED = "Карта закрыта или заблокирована. Невозможно провести операцию."
    ACCOUNT_NOT_LINKED = "Карта не привязана к счёту"
    BANK_NOT_LINKED = "Карта не привязана к банку"
    RECIPIENT_ACCOUNT_NOT_LINKED = "Карта получателя не привязана к счёту"
    RECIPIENT_CARD_CLOSED = "Карта получателя закрыта или заблокирована. Невозможно провести операцию."


class BusinessRuleError(BankError):
    """Ошибка бизнес-логики: нарушено ограничение по правилам банка
    (лимиты, количество, уникальность, запрещённые операции)."""

    DEPOSIT_LIMIT_EXCEEDED = "Превышен лимит депозита. Карта заблокирована до выяснения причин."
    TRANSFER_LIMIT_EXCEEDED = "Подозрение на мошенническую операцию. Карта заблокирована до выяснения причин."
    CASHBACK_LIMIT = "Процент кешбэка завышен, возможна техническая ошибка. Карта заблокирована до выяснения причин."
    TRANSFER_TO_SELF = "Нельзя пересылать деньги самому себе"
    USER_CONFLICT = "Пользователь с таким телефоном уже зарегистрирован"
    TOO_MANY_DEBIT_CARDS = "У пользователя уже есть пять дебетовых карт"
    MCC_FORBIDDEN = "Оплата отклонена. Покупки по {mcc} запрещены банком"
    PURCHASE_LIMIT_EXCEEDED = "Сумма оплаты превышает лимит. Операция заблокирована до выяснения причин."
    PAYMENT_NOT_ALLOWED_FOR_SAVING = "С накопительного счёта нельзя списывать покупки"
    SAVING_RATE_TOO_HIGH = (
        "Ставка накопления завышена, возможна техническая ошибка. "
        "Карта заблокирована до выяснения причины."
    )


class InsufficientFundsError(BankError):
    """Ошибка недостатка денег."""

    INSUFFICIENT_FUNDS_FOR_PAYMENT = "Недостаточно денег для оплаты."
    INSUFFICIENT_FUNDS_FOR_TRANSFER = "Недостаточно денег для осуществления перевода."


# =============================== ДЕКОРАТОР ===============================

error_log = []  # глобальный лог ошибок


def catch_errors_and_log_method(func):
    def wrapper(self, *args, **kwargs):
        try:
            return func(self, *args, **kwargs)
        except BankError as e:
            error_text = str(e)
            error_log.append(error_text)
            print(f"{error_text}")
    return wrapper


# ============================== ОСНОВНЫЕ КЛАССЫ ===============================


@dataclass
class Transaction:
    from_card: int | None
    to_card: int | None
    amount: float
    type: TransactionType
    mcc: str | None
    description: str
    timestamp: _dt.datetime
    cashback: float = DEFAULT_CASHBACK_TRANSACTION


@dataclass
class User:
    last_name: str
    first_name: str
    pin: str
    phone: str
    user_id: int

    accounts: list = field(default_factory=list)
    cards: list = field(default_factory=list)

    @catch_errors_and_log_method
    def change_pin(self, old_pin: str, new_pin: str):
        # 1,2: Оба ПИН-кода должны быть строками из 4 цифр
        for pin_value in [old_pin, new_pin]:
            if not (isinstance(pin_value, str) and len(pin_value) == 4):
                raise ValidationError(ValidationError.PIN_INVALID)
            if not pin_value.isdigit():
                raise ValidationError(ValidationError.PIN_FORMAT_INVALID)
        # 3: Старый ПИН-код совпадает с введённым
        if self.pin != old_pin:
            raise ValidationError(ValidationError.PIN_MISMATCH)
        # Всё хорошо — меняем пин
        self.pin = new_pin


@dataclass
class Account:
    owner: "User"
    acc_id: str
    balance: float = DEFAULT_ACCOUNT_BALANCE
    cashback_balance: float = DEFAULT_CASHBACK_BALANCE  # Баланс кешбэка, по умолчанию


class Card:
    def __init__(
        self,
        account,
        card_id,
        payment_system=DEFAULT_PAYMENT_SYSTEM,
        pan=EMPTY_PAN,
        issue_date=None,
        expiry_date=None,
        currency=CARD_CURRENCY,
        status=CARD_STATUS,
        bank=None,
    ):
        self.account = account
        self.card_id = card_id
        self.payment_system = payment_system
        self.pan = pan
        self.issue_date = issue_date
        self.expiry_date = expiry_date
        self.currency = currency
        self.status = status
        self.bank = bank

        # Обновляем дату заведения карты и срок окончания карты
        if self.issue_date is None:
            self.issue_date = next(ISSUE_DATE_GENERATOR)
        if self.expiry_date is None and self.issue_date is not None:
            self.expiry_date = _dt.date(
                self.issue_date.year + EXPIRY_YEARS,
                self.issue_date.month,
                self.issue_date.day,
            )

    @catch_errors_and_log_method
    def get_card_info(self, fields: list = None):
        # 1. Сначала проверка: есть ли счёт
        if not self.account:
            raise AccessError(AccessError.ACCOUNT_NOT_LINKED)
        # 2. Потом статус (CLOSED или BLOCKED)
        if self.status in [CardStatus.CLOSED, CardStatus.BLOCKED]:
            raise AccessError(AccessError.CARD_CLOSED)

        user = self.account.owner
        data = {
            "bank_name": f"Банк:          {self.bank.name}",
            "bank_bic": f"БИК банка:     {self.bank.bic}",
            "card_id": f"Карта #{self.card_id}",
            "user_id": f"Пользователь:  {user.user_id} — {user.last_name} {user.first_name}",
            "phone": f"Телефон:       {user.phone}",
            "pan": f"PAN:           {self.pan}",
            "acc_id": f"Счёт:          {self.account.acc_id}",
            "payment_system": f"Плат. система: {self.payment_system}",
            "currency": f"Валюта:        {self.currency}",
            "status": f"Статус:        {self.status.value}",
            "issue_date": f"Выпуск:        {self.issue_date}",
            "expiry_date": f"Срок:          {self.expiry_date}",
            "user_cards": f"Карты пользователя: {[c.pan for c in user.cards]}",
            "cashback_balance": f"Кешбэк:        {self.account.cashback_balance:.2f}₽",
            "balance": f"Баланс:        {self.account.balance:.2f}₽",
        }
        if fields is None:
            fields = DEFAULT_CARD_INFO_FIELDS
        return (
            "\n".join([data[field] for field in fields if field in data])
            + "\n"
            + "-" * 50
        )

    @catch_errors_and_log_method
    def get_balance(self):
        # 1. Карта должна быть привязана к счёту
        if not self.account:
            raise AccessError(AccessError.ACCOUNT_NOT_LINKED)
        # 2. Карта не должна быть закрыта/заблокирована
        if self.status in [CardStatus.CLOSED, CardStatus.BLOCKED]:
            raise AccessError(AccessError.CARD_CLOSED)
        balance = self.account.balance
        return BALANCE_DESCRIPTION.format(balance=balance)

    @catch_errors_and_log_method
    def deposit(self, amount: float):
        # 1. Сумма пополнения положительная
        if amount <= 0:
            raise ValidationError(ValidationError.DEPOSIT_AMOUNT_NEGATIVE)
        # 2. Карта привязана к счёту
        if not self.account:
            raise AccessError(AccessError.ACCOUNT_NOT_LINKED)
        # 3. Карта не закрыта/не заблокирована
        if self.status in [CardStatus.CLOSED, CardStatus.BLOCKED]:
            raise AccessError(AccessError.CARD_CLOSED)
        # 4. Не превышен лимит пополнения
        if amount > DEPOSIT_LIMIT:
            self.status = CardStatus.BLOCKED
            raise BusinessRuleError(BusinessRuleError.DEPOSIT_LIMIT_EXCEEDED)

        # Основное действие
        self.account.balance += amount

        self.bank.transaction_log.append(
            Transaction(
                from_card=None,
                to_card=self.card_id,
                amount=amount,
                type=TransactionType.DEPOSIT,
                mcc=None,
                cashback=DEFAULT_CASHBACK_TRANSACTION,
                description=DEPOSIT_DESCRIPTION.format(
                    amount=amount, card_id=self.card_id
                ),
                timestamp=next_timestamp_after(self.issue_date),
            )
        )

    @catch_errors_and_log_method
    def transfer(self, to_card, amount: float):
        # 1. Сумма перевода положительная
        if amount <= 0:
            raise ValidationError(ValidationError.AMOUNT_NEGATIVE)
        # 2. Карта получателя должна существовать
        if to_card is None:
            raise NotFoundError(NotFoundError.RECIPIENT_NOT_FOUND)
        # 3. Карта отправителя привязана к счёту
        if not self.account:
            raise AccessError(AccessError.ACCOUNT_NOT_LINKED)
        # 4. Карта получателя привязана к счёту
        if not to_card.account:
            raise AccessError(AccessError.RECIPIENT_ACCOUNT_NOT_LINKED)
        # 5. Карта отправителя не заблокирована/не закрыта
        if self.status in [CardStatus.CLOSED, CardStatus.BLOCKED]:
            raise AccessError(AccessError.CARD_CLOSED)
        # 6. Карта получателя не заблокирована/не закрыта
        if to_card.status in [CardStatus.CLOSED, CardStatus.BLOCKED]:
            raise AccessError(AccessError.RECIPIENT_CARD_CLOSED)
        # 7. Нельзя переводить себе
        if self.card_id == to_card.card_id:
            raise BusinessRuleError(BusinessRuleError.TRANSFER_TO_SELF)
        # 8. Проверка лимита
        if amount > TRANSFER_LIMIT:
            self.status = CardStatus.BLOCKED
            raise BusinessRuleError(BusinessRuleError.TRANSFER_LIMIT_EXCEEDED)
        # 9. Достаточно ли денег
        if self.account.balance < amount:
            raise InsufficientFundsError(
                InsufficientFundsError.INSUFFICIENT_FUNDS_FOR_TRANSFER
            )

        # Сам перевод
        self.account.balance -= amount
        to_card.account.balance += amount
        latest_issue = max(self.issue_date, to_card.issue_date)

        self.bank.transaction_log.append(
            Transaction(
                from_card=self.card_id,
                to_card=to_card.card_id,
                amount=amount,
                type=TransactionType.TRANSFER,
                mcc=None,
                cashback=DEFAULT_CASHBACK_TRANSACTION,
                description=TRANSFER_DESCRIPTION.format(
                    amount=amount, from_card=self.card_id, to_card=to_card.card_id
                ),
                timestamp=next_timestamp_after(latest_issue),
            )
        )

    @catch_errors_and_log_method
    def pay(self, amount: float, mcc: str):
        # 1. Сумма покупки не положительная
        if amount <= 0:
            raise ValidationError(ValidationError.PAY_AMOUNT_NEGATIVE)
        # 2. Неверный формат MCC
        if not (isinstance(mcc, str) and mcc.isdigit() and len(mcc) == 4):
            raise ValidationError(ValidationError.INVALID_MCC)
        # 3. Карта не привязана к счёту
        if not self.account:
            raise AccessError(AccessError.ACCOUNT_NOT_LINKED)
        # 4. Карта закрыта или заблокирована
        if self.status in [CardStatus.CLOSED, CardStatus.BLOCKED]:
            raise AccessError(AccessError.CARD_CLOSED)
        # 5. Запрещённый MCC
        if mcc in FORBIDDEN_MCC:
            raise BusinessRuleError(BusinessRuleError.MCC_FORBIDDEN.format(mcc=mcc))
        # 6. Превышен лимит оплаты
        if amount > PAY_LIMIT:
            self.status = CardStatus.BLOCKED
            raise BusinessRuleError(BusinessRuleError.PURCHASE_LIMIT_EXCEEDED)
        # 7. Недостаточно денег
        if self.account.balance < amount:
            raise InsufficientFundsError(
                InsufficientFundsError.INSUFFICIENT_FUNDS_FOR_PAYMENT
            )

        self.account.balance -= amount

        self.bank.transaction_log.append(
            Transaction(
                from_card=self.card_id,
                to_card=None,
                amount=amount,
                type=TransactionType.PAY,
                mcc=mcc,
                cashback=DEFAULT_CASHBACK_TRANSACTION,
                description=PAY_DESCRIPTION.format(
                    amount=amount, mcc=mcc, card_id=self.card_id
                ),
                timestamp=next_timestamp_after(self.issue_date),
            )
        )

    @catch_errors_and_log_method
    def get_transaction_history(self):
        # 1. Карта не привязана к счёту
        if not self.account:
            raise AccessError(AccessError.ACCOUNT_NOT_LINKED)
        # 2. Карта не привязана к банку
        if not self.bank:
            raise AccessError(AccessError.BANK_NOT_LINKED)
        # 3. Карта закрыта или заблокирована
        if self.status in [CardStatus.CLOSED, CardStatus.BLOCKED]:
            raise AccessError(AccessError.CARD_CLOSED)

        rows = list(TRANSACTION_HISTORY_HEADER)
        for tx in self.bank.transaction_log:
            if tx.from_card == self.card_id or tx.to_card == self.card_id:
                dt_str = tx.timestamp.strftime("%Y-%m-%d %H:%M:%S")
                from_card = tx.from_card if tx.from_card is not None else ""
                to_card = tx.to_card if tx.to_card is not None else ""
                # Знак для пользователя:
                if tx.from_card == self.card_id:
                    sign = "-"
                elif tx.to_card == self.card_id:
                    sign = "+"
                else:
                    sign = ""
                amount_str = f"{sign}{tx.amount:.2f}₽"
                mcc_str = tx.mcc if tx.mcc else ""
                tx_type = tx.type.value
                cashback_str = f"{getattr(tx, 'cashback', 0):.2f}₽"
                rows.append(
                    f"{dt_str},{tx_type},{from_card},{to_card},{amount_str},{mcc_str},{cashback_str},{tx.description}"
                )
        return rows

    def close(self):
        self.status = CardStatus.CLOSED


class SimpleDebitCard(Card):
    pass


class CashbackDebitCard(Card):
    def __init__(self, *, cashback_rate: float = DEBIT_DEFAULT_CASHBACK_RATE, **kwargs):
        super().__init__(**kwargs)
        self.cashback_rate = cashback_rate

    @catch_errors_and_log_method
    def pay(self, amount: float, mcc: str):
        # 1. Сумма оплаты не положительная
        if amount <= 0:
            raise ValidationError(ValidationError.PAY_AMOUNT_NEGATIVE)
        # 2. Неверный формат MCC
        if not (isinstance(mcc, str) and mcc.isdigit() and len(mcc) == 4):
            raise ValidationError(ValidationError.INVALID_MCC)
        # 3. Процент кешбэка отрицательный
        if self.cashback_rate < 0:
            raise ValidationError(ValidationError.CASHBACK_NEGATIVE)
        # 4. Карта не привязана к счёту
        if not self.account:
            raise AccessError(AccessError.ACCOUNT_NOT_LINKED)
        # 5. Карта закрыта или заблокирована
        if self.status in [CardStatus.CLOSED, CardStatus.BLOCKED]:
            raise AccessError(AccessError.CARD_CLOSED)
        # 6. Процент кешбэка больше или равен лимиту (блокировка карты)
        if self.cashback_rate >= MAX_CASHBACK_RATE:
            self.status = CardStatus.BLOCKED
            raise BusinessRuleError(BusinessRuleError.CASHBACK_LIMIT)
        # 7. Запрещённый MCC
        if mcc in FORBIDDEN_MCC:
            raise BusinessRuleError(BusinessRuleError.MCC_FORBIDDEN.format(mcc=mcc))
        # 8. Сумма оплаты превышает лимит (блокировка карты)
        if amount > PAY_LIMIT:
            self.status = CardStatus.BLOCKED
            raise BusinessRuleError(BusinessRuleError.PURCHASE_LIMIT_EXCEEDED)
        # 9. Недостаточно денег
        if self.account.balance < amount:
            raise InsufficientFundsError(
                InsufficientFundsError.INSUFFICIENT_FUNDS_FOR_PAYMENT
            )

        # Основное действие — списание и кешбэк
        self.account.balance -= amount
        cashback_amount = round(float(amount * self.cashback_rate), 2)
        self.account.cashback_balance += cashback_amount

        self.bank.transaction_log.append(
            Transaction(
                from_card=self.card_id,
                to_card=None,
                amount=amount,
                type=TransactionType.PAY,
                mcc=mcc,
                cashback=cashback_amount,
                description=CB_DEBIT_PAY_DESCRIPTION.format(
                    amount=amount,
                    mcc=mcc,
                    card_id=self.card_id,
                    cashback_amount=cashback_amount,
                ),
                timestamp=next_timestamp_after(self.issue_date),
            )
        )


class SavingCard(Card):
    def __init__(
        self, *, interest_rate: float = SAVING_CARD_DEFAULT_INTEREST, **kwargs
    ):
        super().__init__(**kwargs)
        self.interest_rate = interest_rate

    @catch_errors_and_log_method
    def pay(self, amount: float, mcc: str):
        # 1. Всегда выбрасываем ошибку (оплаты запрещены)
        raise BusinessRuleError(BusinessRuleError.PAYMENT_NOT_ALLOWED_FOR_SAVING)

    # Копим проценты на положительный остаток
    @catch_errors_and_log_method
    def accrue_interest(self):
        # 1. Ставка накопления отрицательная
        if self.interest_rate < 0:
            raise ValidationError(ValidationError.INTEREST_NEGATIVE)
        # 2. Карта не привязана к счёту
        if not self.account:
            raise AccessError(AccessError.ACCOUNT_NOT_LINKED)
        # 3. Карта закрыта или заблокирована
        if self.status in [CardStatus.CLOSED, CardStatus.BLOCKED]:
            raise AccessError(AccessError.CARD_CLOSED)
        # 4. Ставка накопления превышает лимит (блокировка)
        if self.interest_rate >= MAX_SAVING_INTEREST_RATE:
            self.status = CardStatus.BLOCKED
            raise BusinessRuleError(BusinessRuleError.SAVING_RATE_TOO_HIGH)

        if self.account.balance >= 0:
            interest = round(self.account.balance * self.interest_rate, 2)
            self.account.balance += interest

            self.bank.transaction_log.append(
                Transaction(
                    from_card=None,
                    to_card=self.card_id,
                    amount=interest,
                    cashback=DEFAULT_CASHBACK_TRANSACTION,
                    type=TransactionType.INTEREST,
                    mcc=None,
                    description=SAVING_INTEREST_DESCRIPTION.format(
                        interest=interest, card_id=self.card_id
                    ),
                    timestamp=next_timestamp_after(self.issue_date),
                )
            )


@dataclass
class Bank:
    name: str
    bic: str

    _user_seq: int = field(default_factory=lambda: _it.count(1), init=False)
    _account_seq: int = field(default_factory=lambda: _it.count(1), init=False)
    _card_seq: int = field(default_factory=lambda: _it.count(1), init=False)
    _pan_seq: int = field(default_factory=lambda: _it.count(1), init=False)

    customers: dict = field(default_factory=dict)
    accounts: dict = field(default_factory=dict)
    cards: dict = field(default_factory=dict)
    transaction_log: list = field(default_factory=list)

    def _next_account_number(self):
        prefix_left = ACCOUNT_TYPE_CODE + ACCOUNT_CURRENCY
        prefix_right = ACCOUNT_BRANCH
        bic_tail = self.bic[-3:]
        serial = f"{next(self._account_seq):07d}"

        for control_digit in range(10):
            candidate_account_number = (
                prefix_left + str(control_digit) + prefix_right + serial
            )
            digits = [int(d) for d in bic_tail + candidate_account_number]
            weights = [7, 1, 3] * 8
            weighted = [a * b for a, b in zip(digits, weights[:23])]
            control_sum = sum(x % 10 for x in weighted)
            if control_sum % 10 == 0:
                return candidate_account_number

    def _generate_pan(self, system):
        bin_code = BIN_BY_SYSTEM.get(
            system.upper(), BIN_BY_SYSTEM[DEFAULT_PAYMENT_SYSTEM]
        )
        seq = f"{next(self._pan_seq):09d}"
        partial = bin_code + seq
        check = self._luhn(partial)
        return partial + str(check)

    def _luhn(self, number15):
        digits = [int(d) for d in number15[::-1]]
        for i in range(1, len(digits), 2):
            doubled = digits[i] * 2
            digits[i] = doubled - 9 if doubled > 9 else doubled
        return (10 - sum(digits) % 10) % 10

    @catch_errors_and_log_method
    def apply_for_card(
        self,
        last_name,
        first_name,
        pin,
        phone,
        payment_system=DEFAULT_PAYMENT_SYSTEM,
        card_class: type = Card,
        **kwargs,
    ):
        # 1, 2. ПИН-код — строка, состоящая из 4 цифр
        if not (isinstance(pin, str) and pin.isdigit() and len(pin) == 4):
            if not (isinstance(pin, str) and pin.isdigit()):
                raise ValidationError(ValidationError.PIN_FORMAT_INVALID)
            else:
                raise ValidationError(ValidationError.PIN_INVALID)

        # 3. Валидные имя и фамилия (на русском и без спецсимволов)

        name_pattern = r"^[А-ЯЁа-яё]+(?:[ -][А-ЯЁа-яё]+)*$"
        if not re.fullmatch(name_pattern, last_name) or not re.fullmatch(
            name_pattern, first_name
        ):
            raise ValidationError(ValidationError.NAME_INVALID)

        # 4. Корректная платёжная система
        allowed_systems = set(BIN_BY_SYSTEM.keys())
        if payment_system.upper() not in allowed_systems:
            raise ValidationError(
                ValidationError.PAYMENT_SYSTEM_NOT_SUPPORTED.format(
                    payment_system=payment_system
                )
            )

        # 5. Отсутствие другого пользователя с таким же телефоном
        for existing_user in self.customers.values():
            if existing_user.phone == phone:
                if (
                    existing_user.last_name != last_name
                    or existing_user.first_name != first_name
                ):
                    raise BusinessRuleError(BusinessRuleError.USER_CONFLICT)

        # 5. Максимальное число дебетовых/кешбэк карт — 5
        user = next(
            (
                user
                for user in self.customers.values()
                if user.last_name == last_name
                and user.first_name == first_name
                and user.phone == phone
            ),
            None,
        )
        if user and issubclass(card_class, (SimpleDebitCard, CashbackDebitCard)):
            debit_cards_count = sum(
                1
                for c in user.cards
                if isinstance(c, (SimpleDebitCard, CashbackDebitCard))
                and c.status not in [CardStatus.CLOSED, CardStatus.BLOCKED]
            )
            if debit_cards_count >= 5:
                raise BusinessRuleError(BusinessRuleError.TOO_MANY_DEBIT_CARDS)

        # Поиск существующего пользователя по телефону
        user = next(
            (
                user
                for user in self.customers.values()
                if user.last_name == last_name
                and user.first_name == first_name
                and user.phone == phone
            ),
            None,
        )
        if not user:
            user_id = next(self._user_seq)
            user = User(last_name, first_name, pin, phone, user_id)
            self.customers[user_id] = user

        acc_id = self._next_account_number()
        acc = Account(owner=user, acc_id=acc_id)
        self.accounts[acc_id] = acc
        user.accounts.append(acc)

        card_id = next(self._card_seq)
        pan = self._generate_pan(payment_system)
        issue_date = next(ISSUE_DATE_GENERATOR)

        card = card_class(
            account=acc,
            card_id=card_id,
            payment_system=payment_system,
            pan=pan,
            issue_date=issue_date,
            currency=CARD_CURRENCY,
            status=CARD_STATUS,
            bank=self,
            **kwargs,
        )

        self.cards[card_id] = card
        user.cards.append(card)
        return card

    def issue_simple_debit_card(
        self, last_name, first_name, pin, phone, payment_system, **kwargs
    ):
        return self.apply_for_card(
            last_name,
            first_name,
            pin,
            phone,
            payment_system,
            card_class=SimpleDebitCard,
            **kwargs,
        )

    def issue_cashback_debit_card(
        self, last_name, first_name, pin, phone, payment_system, **kwargs
    ):
        return self.apply_for_card(
            last_name,
            first_name,
            pin,
            phone,
            payment_system,
            card_class=CashbackDebitCard,
            **kwargs,
        )

    def issue_saving_card(
            self, last_name, first_name, pin, phone, payment_system, **kwargs
    ):
        return self.apply_for_card(
            last_name,
            first_name,
            pin,
            phone,
            payment_system,
            card_class=SavingCard,
            **kwargs,
        )

    def get_global_history(self) -> list:
        rows = list(TRANSACTION_HISTORY_HEADER)
        for tx in self.transaction_log:
            dt_str = tx.timestamp.strftime("%Y-%m-%d %H:%M:%S")
            tx_type = tx.type.value
            from_card = tx.from_card if tx.from_card is not None else ""
            to_card = tx.to_card if tx.to_card is not None else ""
            amount_str = f"{tx.amount:.2f}₽"
            cashback_str = f"{getattr(tx, 'cashback', 0):.2f}₽"
            mcc_str = tx.mcc if tx.mcc else ""
            rows.append(
                f"{dt_str},{tx_type},{from_card},{to_card},{amount_str},{mcc_str},{cashback_str},{tx.description}"
            )
        return rows

Задача № 1. Оформление заявки на выпуск карты

Ключевая тема: устройство классов и их взаимодействие друг с другом.

Подход к задаче

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

В нашем случае все заводимые объекты будут связаны друг с другом, что упрощает взаимодействие.

Примечание

А дальше смотрим на схему и атрибуты и реализуем по кусочку функциональность каждого метода.

Для реализации метода get_card_info можно рядом открыть ещё пример вывода, чтобы удобнее было сопоставлять данные.

Список всех выпущенных карт:

Карта #1
Пользователь:  1 — Иванов Иван
Телефон:       +79161234501
Банк:          Demo Bank
БИК банка:     044452345
Счёт:          40817810000060000001
PAN:           2204000000000015
Плат. система: MIR
Валюта:        RUB
Статус:        Active
Выпуск:        2022-01-01
Срок:          2026-01-01
Баланс:        0.00₽
Кешбэк:        0.00₽
Карты пользователя: ['2204000000000015']
--------------------------------------------------

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

Таким образом, разработанный метод выглядит так:

   def get_card_info(self, fields: list = None):
        user = self.account.owner
        data = {
            "bank_name": f"Банк:          {self.bank.name}",
            "bank_bic": f"БИК банка:     {self.bank.bic}",
            "card_id": f"Карта #{self.card_id}",
            "user_id": f"Пользователь:  {user.user_id} — {user.last_name} {user.first_name}",
            "phone": f"Телефон:       {user.phone}",
            "pan": f"PAN:           {self.pan}",
            "acc_id": f"Счёт:          {self.account.acc_id}",
            "payment_system": f"Плат. система: {self.payment_system}",
            "currency": f"Валюта:        {self.currency}",
            "status": f"Статус:        {self.status.value}",
            "issue_date": f"Выпуск:        {self.issue_date}",
            "expiry_date": f"Срок:          {self.expiry_date}",
            "user_cards": f"Карты пользователя: {[c.pan for c in user.cards]}",
            "cashback_balance": f"Кешбэк:        {self.account.cashback_balance:.2f}₽",
            "balance": f"Баланс:        {self.account.balance:.2f}₽",
        }
        if fields is None:
            fields = DEFAULT_CARD_INFO_FIELDS
        return (
            "\n".join([data[field] for field in fields if field in data])
            + "\n"
            + "-" * 50
        )

Обратите внимание на строчку user = self.account.owner. Мы сокращаем путь обращения к атрибуту, чтобы не писать self.account.owner.user_id.

Для реализации метода apply_for_card() давайте тоже нарисуем примерную схему его работы и взаимодействия с классами на основе ТЗ в задаче.

Он должен:

  1. Создать нового клиента User с данными, поданными при подаче заявки.
  2. Создать расчётный счёт Account. Номер счёта должен проходить ГОСТ-контроль и быть привязанным к клиенту. Начальный баланс — 0. Для реализации используем _next_account_number из шаблона.
  3. Выпустить карту Card, привязав её к счёту и указав:
    — внутренний идентификационный номер (card_id) — выдаётся по очереди начиная с 1;
    — платёжную систему (payment_system);
    — номер карты (pan) — 16 цифр, с контрольной цифрой по алгоритму Луна. Для реализации используем _generate_pan и _luhn из шаблона;
    — дату выпуска — последовательно берётся из генератора ISSUE_DATE_GENERATOR;
    — срок действия — 4 года;
    — статус карты — Active;
    — валюту карты — RUB;
    — банк карты — берётся из созданного банка-объекта.
  4. У пользователя сохраняется выпущенная карта в user.cards и новый счёт в user.accounts.
  5. В банк добавляется новый пользователь в bank.customers, счёт в bank.accounts и карта в bank.cards.
  6. Если заявку подаёт пользователь, который уже есть в системе (совпадает телефон, имя и фамилия), то нужно завести карту уже имеющемуся пользователю, а не создавать нового.

Вот как это можно проиллюстрировать:

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

    def apply_for_card(
        self,
        last_name,
        first_name,
        pin,
        phone,
        payment_system=DEFAULT_PAYMENT_SYSTEM,
        card_class: type = Card,
        **kwargs,
    ):
        # Поиск существующего пользователя по телефону
        user = next(
            (
                user
                for user in self.customers.values()
                if user.last_name == last_name
                and user.first_name == first_name
                and user.phone == phone
            ),
            None,
        )
        if not user:
            #Реализация шага 1
            user_id = next(self._user_seq)
            user = User(last_name, first_name, pin, phone, user_id)
            self.customers[user_id] = user

        #Реализация шага 2
        acc_id = self._next_account_number()
        acc = Account(owner=user, acc_id=acc_id)
        self.accounts[acc_id] = acc
        user.accounts.append(acc)

        #Реализация шага 3
        card_id = next(self._card_seq)
        pan = self._generate_pan(payment_system)
        issue_date = next(ISSUE_DATE_GENERATOR)

        card = card_class(
            account=acc,
            card_id=card_id,
            payment_system=payment_system,
            pan=pan,
            issue_date=issue_date,
            currency=CARD_CURRENCY,
            status=CARD_STATUS,
            bank=self,
            **kwargs,
        )

        self.cards[card_id] = card
        user.cards.append(card)
        return card

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

Какие тесты можно использовать для проверки

Хороший пример теста, который покрывает почти все случаи, представлен в примере ввода и вывода.

bank = Bank("Demo Bank", "044452345")

card_data = [
  ("Иванов", "Иван", "1234", "+79161234501", "MIR"),
  ("Петров", "Пётр", "5678", "+79161234502", "VISA"),
  ("Сидоров", "Сидор", "0000", "+79161234503", "MASTERCARD"),
  ("Кузнецов", "Кузьма", "9999", "+79161234504", "VISA"),
  ("Смирнов", "Сергей", "1111", "+79161234505", "MIR"),
  ("Смирнов", "Сергей", "1111", "+79161234505", "VISA"),
  ("Захаров", "Кирилл", "1212", "+79161234507", "MASTERCARD"),
  ("Захаров", "Кирилл", "1212", "+79161234518", "MASTERCARD")
]

cards = []
print("Список всех выпущенных карт:\n")
for last_name, first_name, pin, phone, system in card_data:
  card = bank.apply_for_card(last_name, first_name, pin, phone, system)
  print(card.get_card_info())
  cards.append(card)

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

Что в итоге

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

Задача № 2. Использование банковской карты и ведение журнала операций

Ключевая тема: реализация методов, каждый из которых закрывает конкретный сценарий работы для клиента или для банка.

Подход к задаче

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

На схеме можно заметить, что класс Transaction хранит всю информацию об одной конкретной транзакции. Эту же информацию можно получить через класс Bank. Аналогично и объекты других классов смогут дотянуться до информации из транзакций.

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

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

change_pin
   def change_pin(self, old_pin: str, new_pin: str):
        if self.pin == old_pin:
            self.pin = new_pin

С точки зрения реализации просто делаем проверку значений старого и нового ПИН-кода. И на её основе принимаем решение: менять или не менять.

Важно

Обратите внимание, как передаются аргументы для метода. Они передаются не числами, а строками. Потому что «0070» как число равно 70. Плюс пользователь может опечататься, а проверить валидность строки проще.

get_balance
   def get_balance(self):
        balance = self.account.balance
        return BALANCE_DESCRIPTION.format(balance=balance)

Так как метод расположен у класса Card, а balance лежит в классе Account, то необходимо к нему туда сходить.

BALANCE_DESCRIPTION — это заготовленная текстовая константа, чтобы вам не надо было подгонять пробелы и переписывать текст.

BALANCE_DESCRIPTION ="Баланс: {balance:.2f}₽"

Чтобы положить переменную balance в этот шаблон, используется метод для строк .format(). Можно задать несколько переменных через запятую.

deposit
   def deposit(self, amount: float):
        self.account.balance += amount

        self.bank.transaction_log.append(Transaction(
            from_card=None,
            to_card=self.card_id,
            amount=amount,
            type=TransactionType.DEPOSIT,
            mcc=None,
            cashback=DEFAULT_CASHBACK_TRANSACTION,
            description=DEPOSIT_DESCRIPTION.format(amount=amount, card_id=self.card_id),
            timestamp=next_timestamp_after(self.issue_date)
        ))

Аналогично с get_balance вам нужно видоизменить баланс. Значит, необходимо сходить за ним к объекту account.

А транзакцию необходимо поместить в bank, поэтому тоже идём за списком к объекту.

В параметрах обратите внимание, что from_card = None. Это значит, что деньги не снимаются ни с какой карты. Если поле to_card не пустое — значит, пополняем деньгами конкретно эту карту.

Параметр amount передаётся в формате float без знака +/-. Если необходимо при выводе знак проставить — реализуем это в методе, который выводит транзакции.

В type=TransactionType.DEPOSIT используется заготовленный Enum из шаблона, чтобы вид типа транзакций был везде одинаков.

class TransactionType(Enum):
    DEPOSIT = "deposit"
    TRANSFER = "transfer"
    PAY = "pay"
    INTEREST = "interest"

В description как раз пример реализации метода .format() для двух переменных, потому что шаблон выглядит так:

DEPOSIT_DESCRIPTION = "{amount:.2f}₽ → карта #{card_id}"

В шаблоне в конце каждого метода прописано значение для timestamp, чтобы вы не задавались вопросом, как подтянуть его из генератора. Если кратко, то в каждом методе значение времени подтягивается таким образом, чтобы транзакция имела смысл.

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

timestamp=next_timestamp_after(self.issue_date)
transfer
   def transfer(self, to_card, amount: float):
        self.account.balance -= amount
        to_card.account.balance += amount
        latest_issue = max(self.issue_date, to_card.issue_date)

        self.bank.transaction_log.append(Transaction(
            from_card=self.card_id,
            to_card=to_card.card_id,
            amount=amount,
            type=TransactionType.TRANSFER,
            mcc=None,
            cashback=DEFAULT_CASHBACK_TRANSACTION,
            description=TRANSFER_DESCRIPTION.format(amount=amount, from_card=self.card_id, to_card=to_card.card_id),
            timestamp=next_timestamp_after(latest_issue),
        ))

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

Для корректного получения timestamp транзакции в шаблоне ввели latest_issue. Это самая поздняя дата выпуска среди карт. Чтобы транзакция имела реальный смысл и по времени была позже даты выпуска карт.

Также есть параметры from_card и to_card: с какой карты списались деньги и на какую добавились.

В type=TransactionType.TRANSFER используется заготовленный в шаблоне Enum.

class TransactionType(Enum):
    DEPOSIT = "deposit"
    TRANSFER = "transfer"
    PAY = "pay"
    INTEREST = "interest"

В description для метода .format() подтягиваем аж три переменные:

TRANSFER_DESCRIPTION = "{amount:.2f}₽: карта #{from_card} → карта #{to_card}"

А next_timestamp_after применяем для самой поздней даты, latest issue.

close
   def close(self):
        self.status = CardStatus.CLOSED

Тут всё просто: меняем статус карты на заготовленный в шаблоне Enum:

class CardStatus(Enum):
    ACTIVE = "Active"
    CLOSED = "Closed"
    BLOCKED = "Blocked"

CARD_STATUS = CardStatus.ACTIVE
get_global_history (для банка)
   def get_global_history(self) -> list:
        rows = list(TRANSACTION_HISTORY_HEADER)
        # Сортировка по времени (timestamp) по возрастанию:
        for tx in sorted(self.transaction_log, key=lambda x: x.timestamp):
            dt_str = tx.timestamp.strftime("%Y-%m-%d %H:%M:%S")
            tx_type = tx.type.value
            from_card = tx.from_card if tx.from_card is not None else ""
            to_card = tx.to_card if tx.to_card is not None else ""
            amount_str = f"{tx.amount:.2f}₽"
            cashback_str = f"{getattr(tx, 'cashback', 0):.2f}₽"
            mcc_str = tx.mcc if tx.mcc else ""
            rows.append(
f"{dt_str},{tx_type},{from_card},{to_card},{amount_str},{mcc_str},{cashback_str},{tx.description}"
            )
        return rows

Тут самое главное — не забыть отсортировать все транзакции по возрастанию времени и вывести их. В остальном метод — это приведение данных каждой строки к нужному виду.

Вот вид для примера:

timestamp,type,from_card,to_card,amount,mcc,cashback,description
2022-01-02 12:07:11,deposit,,1,100.00₽,,0.00₽,100.00₽ → карта #1
2022-01-03 15:14:22,deposit,,2,200.00₽,,0.00₽,200.00₽ → карта #2
2022-01-04 18:21:33,deposit,,3,300.00₽,,0.00₽,300.00₽ → карта #3
2022-01-05 11:28:44,deposit,,4,400.00₽,,0.00₽,400.00₽ → карта #4
2022-01-06 14:35:55,deposit,,5,500.00₽,,0.00₽,500.00₽ → карта #5
2022-01-07 17:42:06,deposit,,6,600.00₽,,0.00₽,600.00₽ → карта #6
2022-01-08 10:49:17,deposit,,7,700.00₽,,0.00₽,700.00₽ → карта #7
2022-01-09 13:56:28,pay,1,,45.00₽,5814,0.00₽,45.00₽ (MCC: 5814) с карты #1
2022-01-10 16:03:39,transfer,1,2,50.00₽,,0.00₽,50.00₽: карта #1 → карта #2

В TRANSACTION_HISTORY_HEADER лежат все столбцы, которые используются в транзакции:

TRANSACTION_HISTORY_HEADER = [
    "timestamp,type,from_card,to_card,amount,mcc,cashback,description"
]

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

# Enum, из которого возьмётся значение
class TransactionType(Enum):
    DEPOSIT = "deposit"
    TRANSFER = "transfer"
    PAY = "pay"
    INTEREST = "interest"

tx_type = tx.type.value
get_global_history
   def get_transaction_history(self):
        rows = list(TRANSACTION_HISTORY_HEADER)
        # Сортируем по времени!
        sorted_transactions = sorted(self.bank.transaction_log, key=lambda x: x.timestamp)
        for tx in sorted_transactions:
            if tx.from_card == self.card_id or tx.to_card == self.card_id:
                dt_str = tx.timestamp.strftime("%Y-%m-%d %H:%M:%S")
                from_card = tx.from_card if tx.from_card is not None else ""
                to_card = tx.to_card if tx.to_card is not None else ""
                # Знак для пользователя:
                if tx.from_card == self.card_id:
                    sign = "-"
                elif tx.to_card == self.card_id:
                    sign = "+"
                else:
                    sign = ""
                amount_str = f"{sign}{tx.amount:.2f}₽"
                mcc_str = tx.mcc if tx.mcc else ""
                tx_type = tx.type.value
                cashback_str = f"{getattr(tx, 'cashback', 0):.2f}₽"
                rows.append(
f"{dt_str},{tx_type},{from_card},{to_card},{amount_str},{mcc_str},{cashback_str},{tx.description}"
                )
        return rows

Реализация этого метода почти такая же, как для метода get_global_history класса Bank. Только нужно добавить условия фильтрации по карте и знак у amount.

Алгоритм:

  1. Сортируем транзакции банка по времени.
  2. Проверяем, подходит ли транзакция. Для этого смотрим на tx.from_card и tx.to_card. Если хотя бы одно значение совпадает с self.card_id, то эту транзакцию обрабатываем.
  3. Если tx.from_card=self.card_id, значит, деньги с карты снимались, тогда к amount добавляем знак минус.
  4. Если tx.to_card=self.card_id, значит, деньги приходили на карту, тогда к amount добавляем знак плюс.

В остальном всё как в get_global_history. Просто настраиваем вид вывода данных.

Важно. Как мы уже говорили ранее, в задаче мы делаем все расчёты с помощью типа данных float — чтобы чуть облегчить вам жизнь. Но в реальном проекте для финансовой сферы нужно обязательно использовать decimal: он позволяет точнее проводить операции с десятичными дробями.

Если пренебрегать этим правилом, то можно потерять деньги: свои, компании или клиентов. Одному разработчику этот опыт обошёлся в 10 тысяч долларов. Вам — достаётся бесплатно!

Какие тесты можно использовать для проверки

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

    # Заводим банк
    bank = Bank("Demo Bank", "044452345")
    # Заводим карты
    cards = []
    card_data = [
        ("Иванов", "Иван", "1234", "+79161234501", "MIR"),
        ("Петров", "Пётр", "5678", "+79161234502", "VISA"),
        ("Сидоров", "Сидор", "0000", "+79161234503", "MASTERCARD"),
        ("Кузнецов", "Кузьма", "9999", "+79161234504", "VISA"),
        ("Смирнов", "Сергей", "1111", "+79161234505", "MIR"),
        ("Смирнов", "Сергей", "1111", "+79161234505", "VISA"),
        ("Захаров", "Кирилл", "1212", "+79161234507", "MASTERCARD")
        ("Захаров", "Кирилл", "1212", "+79161234518", "MASTERCARD")
    ]

    for last_name, first_name, pin, phone, system in card_data:
        card = bank.apply_for_card(last_name, first_name, pin, phone, system)
        cards.append(card)

    #Проверяем смену ПИН-кода
    print("Проверка ПИН-кода")
    user=cards[0].account.owner

    print("Старый ПИН-код:", user.pin)
    user.change_pin("1234", "5678")
    print("Новый ПИН-код (после правильной смены):", user.pin)

    user.change_pin("0000", "9999")
    print("PIN после попытки с неверным старым:", user.pin)

    #Проверяем работу метода get_balance
    print("\nБаланс выпущенных карт")
    for i, card in enumerate(cards):
        print(card.get_balance())

    # Проверяем работу метода deposit
    print("\nПополняем депозиты карт")
    for i, card in enumerate(cards):
        amount = 100 * (i + 1)
        card.deposit(amount)
        print(card.get_balance())

    #Проверяем работу метода pay
    print("\nПокупаем продукты в магазине")
    print(cards[0].get_balance())
    cards[0].pay(45, "5814")
    print(cards[0].get_balance())

    #Проверяем работу метода transfer
    print("\nПереводим деньги с одной карты на другую")
    print(cards[0].get_balance())
    print(cards[1].get_balance())
    cards[0].transfer(cards[1], 50)
    print(cards[0].get_balance())
    print(cards[1].get_balance())
    for row in cards[1].get_transaction_history():
        print(row)

    #Проверяем работу метода close
    print("\nЗакрываем карту")
    print(cards[0].status.value)
    cards[0].close()
    print(cards[0].status.value)

    #Проверяем работу метода get_transaction_history
    print("\nИстория транзакций банковской карты")
    for row in cards[0].get_transaction_history():
        print(row)
    print(cards[0].get_balance())

    #Проверяем работу метода get_global_history
    print("\nИстория транзакций банка")
    for row in bank.get_global_history():
        print(row)

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

Подробнее о юнит-тестах можете почитать в статье на Хабре.

Что в итоге

  1. Если нужно положить значение переменных в шаблон — используем .format().
  2. Если хотим обратиться к значению, лежащему в enum, — используем .value().
  3. Использование констант для текстовых и повторяющихся конструкций облегчает код и обновление. Если захотите обновить текст description во всех методах, то сможете это сделать в блоке «Константы», не бегая по всему коду.

Задача № 3. Выпуск новых типов банковских карт

Ключевая тема: работа с наследниками классов и их методами.

Подход к задаче

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

То есть задача сводится к тому, чтобы:

  1. Завести пустой класс-наследник для дебетовой карты.
  2. Для дебетовой карты с кешбэком добавить новый атрибут cashback_rate и переопределить метод pay, чтобы учитывался кешбэк.
  3. Для накопительного счёта добавить новый атрибут interest_rate и новый метод для накопления процентов accure_interest.

Ниже мы собрали блоки кода с реализацией — и прокомментировали их, чтобы подсветить нюансы. Советуем пройтись по ним последовательно.

Завести пустой класс-наследник для дебетовой карты
class SimpleDebitCard(Card):
    pass

Готово! Вы восхитительны!

Завести класс для дебетовой карты с кешбэком
class CashbackDebitCard(Card):
    def __init__(self, *, cashback_rate: float = DEBIT_DEFAULT_CASHBACK_RATE, **kwargs):
        super().__init__(**kwargs)
        self.cashback_rate = cashback_rate

    def pay(self, amount: float, mcc: str):
        self.account.balance -= amount
        cashback_amount = round(float(amount * self.cashback_rate), 2)
        self.account.cashback_balance += cashback_amount

        self.bank.transaction_log.append(Transaction(
            from_card=self.card_id,
            to_card=None,
            amount=amount,
            type=TransactionType.PAY,
            mcc=mcc,
            cashback=cashback_amount,
            description=CB_DEBIT_PAY_DESCRIPTION.format(
                amount=amount, mcc=mcc, card_id=self.card_id, cashback_amount=cashback_amount
            ),
            timestamp=next_timestamp_after(self.issue_date)
        ))

Про класс

Если параметр cashback_rate не передавался явно, то задаётся с дефолтным значением константы DEBIT_DEFAULT_CASHBACK_RATE.

Если параметр передавался явно, то в строке self.cashback_rate = cashback_rate его присвоим.

Через super().__init__(**kwargs) все другие параметры отправятся на инициализацию к родителю Card.

Про метод

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

        self.account.balance -= amount
        cashback_amount = round(float(amount * self.cashback_rate), 2)
        self.account.cashback_balance += cashback_amount

В транзакции теперь дополнительно передаём параметр cashback с получившимся значением. А также используем новые шаблонные константы:

CB_DEBIT_PAY_DESCRIPTION = "{amount:.2f}₽ (MCC: {mcc}) с карты #{card_id} (кешбэк {cashback_amount:.2f}₽)"
Завести класс для накопительного счёта
class SavingCard(Card):
    def __init__(self, *, interest_rate: float = SAVING_CARD_DEFAULT_INTEREST, **kwargs):
        super().__init__(**kwargs)
        self.interest_rate = interest_rate

    # Копим проценты на положительный остаток
    def accrue_interest(self):
        interest = round(self.account.balance * self.interest_rate, 2)
        self.account.balance += interest

        self.bank.transaction_log.append(Transaction(
            from_card=None,
            to_card=self.card_id,
            amount=interest,
            cashback=DEFAULT_CASHBACK_TRANSACTION,
            type=TransactionType.INTEREST,
            mcc=None,
            description=SAVING_INTEREST_DESCRIPTION.format(interest=interest, card_id=self.card_id),
            timestamp=next_timestamp_after(self.issue_date)
        ))

Про класс

Подход такой же, как и для дебетовой карты с кешбэком. Новый параметр для карты interest_rate, который передаётся или с дефолтным значением, если не указан, или с указанным значением.

Через super().__init__(**kwargs) все другие параметры отправятся на инициализацию к родителю Card.

Про метод

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

        interest = round(self.account.balance * self.interest_rate, 2)
        self.account.balance += interest

В транзакции используем новую шаблонную константу для передачи description.

SAVING_INTEREST_DESCRIPTION ="Начислены проценты {interest:.2f}₽ по накопительной карте #{card_id}"
Какие тесты можно использовать для проверки

Нужно проверить следующие моменты:

  1. Новые заведённые банковские карты наследуют функциональность своего родителя. Для этого стоит протестировать работу всех методов родителя, которые не переопределялись.
  2. Для каждой новой карты работают добавленные атрибуты и методы.
  3. Для каждой новой карты работают переопределённые методы.
  4. Классы карт между собой не конфликтуют.

В первых пяти примерах в задаче как раз проверяется каждый блок.

В примере 1 проверяется работоспособность SimpleDebitCard. Можно ещё добавить незатронутый метод close.

bank = Bank("Demo Bank", "044452345")
c1 = bank.issue_simple_debit_card("Иванов", "Иван", "1234", "+70000000001", "MIR")
c2 = bank.issue_simple_debit_card("Петров", "Пётр", "1111", "+70000000002", "MIR")

c1.deposit(200)
c1.pay(50, '5411')
c1.transfer(c2, 30)

print(c1.get_balance())
print(c2.get_balance())

for row in c1.get_transaction_history(): print(row)
for row in c2.get_transaction_history(): print(row)

В примере 2 проверяется работоспособность CashbackDebitCard. Дополнительно заводятся карты с кастомным параметром cashback_rate и используется переопределённый метод pay.

bank = Bank("Demo Bank", "044452345")
c_base = bank.issue_cashback_debit_card("Смирнов", "Андрей", "2222", "+70000000003", "MIR")
c_custom = bank.issue_cashback_debit_card("Смирнов", "Андрей", "2222", "+70000000004", "MIR", cashback_rate=0.07)
c_recv = bank.issue_cashback_debit_card("Петров", "Пётр", "3333", "+70000000005", "MIR")

c_base.deposit(300)
c_custom.deposit(300)
c_recv.deposit(100)

c_base.pay(120, '5814')
c_custom.pay(120, '5814')

c_base.transfer(c_recv, 50)
c_custom.transfer(c_recv, 70)

print(c_base.get_balance())
print(c_custom.get_balance())
print(c_recv.get_balance())

print(c_base.account.cashback_balance)
print(c_custom.account.cashback_balance)

for row in c_base.get_transaction_history(): print(row)
for row in c_custom.get_transaction_history(): print(row)
for row in c_recv.get_transaction_history(): print(row)

В примере 3 проверяется работоспособность SavingDebitCard. Дополнительно заводятся карты с кастомным параметром interest_rate и используется новый метод accure_interest.

bank = Bank("Demo Bank", "044452345")
c_base = bank.issue_saving_card("Кузнецов", "Кирилл", "3333", "+70000000004", "MIR")
c_custom = bank.issue_saving_card("Кузнецов", "Кирилл", "3333", "+70000000005", "MIR", interest_rate=0.03)
c_recv = bank.issue_saving_card("Сидоров", "Сергей", "4444", "+70000000006", "MIR")

c_base.deposit(1000)
c_custom.deposit(1000)
c_recv.deposit(500)

c_base.pay(200, '5411')
c_custom.pay(200, '5814')

c_base.accrue_interest()
c_custom.accrue_interest()

c_base.transfer(c_recv, 150)
c_custom.transfer(c_recv, 120)

print(c_base.get_balance())
print(c_custom.get_balance())
print(c_recv.get_balance())

for row in c_base.get_transaction_history(): print(row)
for row in c_custom.get_transaction_history(): print(row)
for row in c_recv.get_transaction_history(): print(row)

Примеры 4 и 5 направлены на проверку кросс-взаимодействий разных классов друг с другом. Можете составить самостоятельно примеры пользовательских сценариев, в которых задействованы разные типы карт. Подробно их рассматривать не будем.

Что в итоге

  1. Если происходит переопределение методов и добавление новых классов — постарайтесь визуализировать изменения хотя бы в формате таблицы. Это сильно поможет структурированно подходить к дальнейшей разработке.
  2. Если в дочернем классе вы используете такие же атрибуты, как и у родителя, то лучше использовать super().__init__(**kwargs) для их передачи: это позволит не прописывать повторно несколько полей для инициализации.

Задача № 4. Обработка исключений

Ключевая тема: реализация обработки ошибок для каждого метода.

Подход к задаче

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

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

change_pin
   def change_pin(self, old_pin: str, new_pin: str):
        # 1,2: Оба ПИН-кода должны быть строками из 4 цифр
        for pin_value in [old_pin, new_pin]:
            if not (isinstance(pin_value, str) and len(pin_value) == 4):
                raise ValidationError(ValidationError.PIN_INVALID)
            if not pin_value.isdigit():
                raise ValidationError(ValidationError.PIN_FORMAT_INVALID)
        # 3: Старый ПИН-кода совпадает с введённым
        if self.pin != old_pin:
            raise ValidationError(ValidationError.PIN_MISMATCH)
        # Всё хорошо — меняем пин
        self.pin = new_pin

isinstance() — встроенная функция Python, которая проверяет, относится ли объект к определённому классу.

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

get_card_info

Проверяем последовательно необходимые условия. Сначала проверяется, есть ли вообще счёт (account) у карты. А затем, является ли она заблокированной или закрытой.

   def get_card_info(self, fields: list = None):
        # 1. Сначала проверка: есть ли счёт
        if not self.account:
            raise AccessError(AccessError.ACCOUNT_NOT_LINKED)
        # 2. Потом статус (CLOSED или BLOCKED)
        if self.status in [CardStatus.CLOSED, CardStatus.BLOCKED]:
            raise AccessError(AccessError.CARD_CLOSED)

        user = self.account.owner
        data = {
            "bank_name": f"Банк:          {self.bank.name}",
            "bank_bic": f"БИК банка:     {self.bank.bic}",
            "card_id": f"Карта #{self.card_id}",
            "user_id": f"Пользователь:  {user.user_id} — {user.last_name} {user.first_name}",
            "phone": f"Телефон:       {user.phone}",
            "pan": f"PAN:           {self.pan}",
            "acc_id": f"Счёт:          {self.account.acc_id}",
            "payment_system": f"Плат. система: {self.payment_system}",
            "currency": f"Валюта:        {self.currency}",
            "status": f"Статус:        {self.status.value}",
            "issue_date": f"Выпуск:        {self.issue_date}",
            "expiry_date": f"Срок:          {self.expiry_date}",
            "user_cards": f"Карты пользователя: {[c.pan for c in user.cards]}",
            "cashback_balance": f"Кешбэк:        {self.account.cashback_balance:.2f}₽",
            "balance": f"Баланс:        {self.account.balance:.2f}₽",
        }
        if fields is None:
            fields = DEFAULT_CARD_INFO_FIELDS
        return "\n".join([data[field] for field in fields if field in data]) + "\n" + "-" * 50
get_balance

Аналогичные проверки, как и для метода get_card_info. Сначала проверяется, есть ли вообще account у карты. А затем — является ли она заблокированной или закрытой.

   def get_balance(self):
        # 1. Карта должна быть привязана к счёту
        if not self.account:
            raise AccessError(AccessError.ACCOUNT_NOT_LINKED)
        # 2. Карта не должна быть закрыта/заблокирована
        if self.status in [CardStatus.CLOSED, CardStatus.BLOCKED]:
            raise AccessError(AccessError.CARD_CLOSED)
        balance = self.account.balance
        return BALANCE_DESCRIPTION.format(balance=balance)
deposit

Сначала проверяются входные данные. В данном случае смотрим, является ли сумма пополнений положительной. Далее идут проверки на наличие счёта и статус карты, такие же, как и в методах get_card_info и get_balance.

И в заключение проверка бизнес-условий — не превышен ли депозитный лимит.

   def deposit(self, amount: float):
        # 1. Сумма пополнения положительная
        if amount <= 0:
            raise ValidationError(ValidationError.DEPOSIT_AMOUNT_NEGATIVE)
        # 2. Карта привязана к счёту
        if not self.account:
            raise AccessError(AccessError.ACCOUNT_NOT_LINKED)
        # 3. Карта не закрыта / не заблокирована
        if self.status in [CardStatus.CLOSED, CardStatus.BLOCKED]:
            raise AccessError(AccessError.CARD_CLOSED)
        # 4. Не превышен лимит пополнения
        if amount > DEPOSIT_LIMIT:
            self.status = CardStatus.BLOCKED
            raise BusinessRuleError(BusinessRuleError.DEPOSIT_LIMIT_EXCEEDED)

        # Основное действие
        self.account.balance += amount

        self.bank.transaction_log.append(
            Transaction(
                from_card=None,
                to_card=self.card_id,
                amount=amount,
                type=TransactionType.DEPOSIT,
                mcc=None,
                cashback=DEFAULT_CASHBACK_TRANSACTION,
                description=DEPOSIT_DESCRIPTION.format(
                    amount=amount, card_id=self.card_id
                ),
                timestamp=next_timestamp_after(self.issue_date),
            )
        )
transfer
   def transfer(self, to_card, amount: float):
        # 1. Сумма перевода положительная
        if amount <= 0:
            raise ValidationError(ValidationError.AMOUNT_NEGATIVE)
        # 2. Карта получателя должна существовать
        if to_card is None:
            raise NotFoundError(NotFoundError.RECIPIENT_NOT_FOUND)
        # 3. Карта отправителя привязана к счёту
        if not self.account:
            raise AccessError(AccessError.ACCOUNT_NOT_LINKED)
        # 4. Карта получателя привязана к счёту
        if not to_card.account:
            raise AccessError(AccessError.RECIPIENT_ACCOUNT_NOT_LINKED)
        # 5. Карта отправителя не заблокирована / не закрыта
        if self.status in [CardStatus.CLOSED, CardStatus.BLOCKED]:
            raise AccessError(AccessError.CARD_CLOSED)
        # 6. Карта получателя не заблокирована / не закрыта
        if to_card.status in [CardStatus.CLOSED, CardStatus.BLOCKED]:
            raise AccessError(AccessError.RECIPIENT_CARD_CLOSED)
        # 7. Нельзя переводить себе
        if self.card_id == to_card.card_id:
            raise BusinessRuleError(BusinessRuleError.TRANSFER_TO_SELF)
        # 8. Проверка лимита
        if amount > TRANSFER_LIMIT:
            self.status = CardStatus.BLOCKED
            raise BusinessRuleError(BusinessRuleError.TRANSFER_LIMIT_EXCEEDED)
        # 9. Достаточно ли денег
        if self.account.balance < amount:
            raise InsufficientFundsError(
                InsufficientFundsError.INSUFFICIENT_FUNDS_FOR_TRANSFER
            )

        # Сам перевод
        self.account.balance -= amount
        to_card.account.balance += amount
        latest_issue = max(self.issue_date, to_card.issue_date)

        self.bank.transaction_log.append(
            Transaction(
                from_card=self.card_id,
                to_card=to_card.card_id,
                amount=amount,
                type=TransactionType.TRANSFER,
                mcc=None,
                cashback=DEFAULT_CASHBACK_TRANSACTION,
                description=TRANSFER_DESCRIPTION.format(
                    amount=amount, from_card=self.card_id, to_card=to_card.card_id
                ),
                timestamp=next_timestamp_after(latest_issue),
            )
        )

Когда вы сталкиваетесь с большим количеством проверок, важно следить за двумя вещами:

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

В методе transfer все проверки похожи на проверки методов ранее, используют оператор сравнения с одним условием. Важно подтянуть правильную ошибку.

При некоторых сценариях, например если сумма перевода превышает лимит перевода (проверка #8), происходит блокировка карты. Тоже об этом не забывайте.

       # 8. Проверка лимита
        if amount > TRANSFER_LIMIT:
            self.status = CardStatus.BLOCKED
            raise BusinessRuleError(BusinessRuleError.TRANSFER_LIMIT_EXCEEDED)
pay
   def pay(self, amount: float, mcc: str):
        # 1. Сумма покупки не положительная
        if amount <= 0:
            raise ValidationError(ValidationError.PAY_AMOUNT_NEGATIVE)
        # 2. Неверный формат MCC
        if not (isinstance(mcc, str) and mcc.isdigit() and len(mcc) == 4):
            raise ValidationError(ValidationError.INVALID_MCC)
        # 3. Карта не привязана к счёту
        if not self.account:
            raise AccessError(AccessError.ACCOUNT_NOT_LINKED)
        # 4. Карта закрыта или заблокирована
        if self.status in [CardStatus.CLOSED, CardStatus.BLOCKED]:
            raise AccessError(AccessError.CARD_CLOSED)
        # 5. Запрещённый MCC
        if mcc in FORBIDDEN_MCC:
            raise BusinessRuleError(BusinessRuleError.MCC_FORBIDDEN.format(mcc=mcc))
        # 6. Превышен лимит оплаты
        if amount > PAY_LIMIT:
            self.status = CardStatus.BLOCKED
            raise BusinessRuleError(BusinessRuleError.PURCHASE_LIMIT_EXCEEDED)
        # 7. Недостаточно денег
        if self.account.balance < amount:
            raise InsufficientFundsError(
                InsufficientFundsError.INSUFFICIENT_FUNDS_FOR_PAYMENT
            )

        self.account.balance -= amount

        self.bank.transaction_log.append(
            Transaction(
                from_card=self.card_id,
                to_card=None,
                amount=amount,
                type=TransactionType.PAY,
                mcc=mcc,
                cashback=DEFAULT_CASHBACK_TRANSACTION,
                description=PAY_DESCRIPTION.format(
                    amount=amount, mcc=mcc, card_id=self.card_id
                ),
                timestamp=next_timestamp_after(self.issue_date),
            )
        )

Большинство проверок вы уже реализовали в рамках методов выше. Отдельного внимания здесь заслуживают сценарии, связанные с MCC: в случае неправильно указанного номера и запрещённого MCC.

Проверка неверно указанного номера состоит из трёх условий: номер подан в формате строки (str), является числом и состоит ровно из 4 цифр.

       # 2. Неверный формат MCC
        if not (isinstance(mcc, str) and mcc.isdigit() and len(mcc) == 4):
            raise ValidationError(ValidationError.INVALID_MCC)

Для проверки принадлежности к классу str используем функцию isinstance(), которая возвращает TRUE или FALSE в зависимости от принадлежности к заданному классу. Подробнее её рассмотрели в подсказке.

Для проверки, является ли код MCC числом, используется метод для str.isdigit(), который возвращает TRUE, если число, и FALSE, если не число.

Для проверки длины числа используется знакомая вам функция len().

Для проверки запрещённых MCC (условие #5) не забывайте, что у вас есть уже готовая константа, которая содержит необходимые номера. Остаётся только проверить принадлежность поданного номера к ним.

FORBIDDEN_MCC = {"7995", "4829", "6051"}  # Пример: азартные игры, переводы, квази-кеш

…
       # 5. Запрещённый MCC
        if mcc in FORBIDDEN_MCC:
            raise BusinessRuleError(BusinessRuleError.MCC_FORBIDDEN.format(mcc=mcc))
…
get_transaction_history

В данном методе повторяются все проверки, которые были ранее в get_balance или get_card_info. Единственное: добавляется проверка на привязку к банку, в которой изучаем соответствующий атрибут по аналогии с привязкой к счёту.

   def get_transaction_history(self):
        # 1. Карта не привязана к счёту
        if not self.account:
            raise AccessError(AccessError.ACCOUNT_NOT_LINKED)
        # 2. Карта не привязана к банку
        if not self.bank:
            raise AccessError(AccessError.BANK_NOT_LINKED)
        # 3. Карта закрыта или заблокирована
        if self.status in [CardStatus.CLOSED, CardStatus.BLOCKED]:
            raise AccessError(AccessError.CARD_CLOSED)

        rows = list(TRANSACTION_HISTORY_HEADER)
        for tx in self.bank.transaction_log:
            if tx.from_card == self.card_id or tx.to_card == self.card_id:
                dt_str = tx.timestamp.strftime("%Y-%m-%d %H:%M:%S")
                from_card = tx.from_card if tx.from_card is not None else ""
                to_card = tx.to_card if tx.to_card is not None else ""
                # Знак для пользователя:
                if tx.from_card == self.card_id:
                    sign = "-"
                elif tx.to_card == self.card_id:
                    sign = "+"
                else:
                    sign = ""
                amount_str = f"{sign}{tx.amount:.2f}₽"
                mcc_str = tx.mcc if tx.mcc else ""
                tx_type = tx.type.value
                cashback_str = f"{getattr(tx, 'cashback', 0):.2f}₽"
                rows.append(
                    f"{dt_str},{tx_type},{from_card},{to_card},{amount_str},{mcc_str},{cashback_str},{tx.description}"
                )
        return rows
pay для CashbackDebitCard
   def pay(self, amount: float, mcc: str):
        # 1. Сумма оплаты не положительная
        if amount <= 0:
            raise ValidationError(ValidationError.PAY_AMOUNT_NEGATIVE)
        # 2. Неверный формат MCC
        if not (isinstance(mcc, str) and mcc.isdigit() and len(mcc) == 4):
            raise ValidationError(ValidationError.INVALID_MCC)
        # 3. Процент кешбэка отрицательный
        if self.cashback_rate < 0:
            raise ValidationError(ValidationError.CASHBACK_NEGATIVE)
        # 4. Карта не привязана к счёту
        if not self.account:
            raise AccessError(AccessError.ACCOUNT_NOT_LINKED)
        # 5. Карта закрыта или заблокирована
        if self.status in [CardStatus.CLOSED, CardStatus.BLOCKED]:
            raise AccessError(AccessError.CARD_CLOSED)
        # 6. Процент кешбэка больше или равен лимиту (блокировка карты)
        if self.cashback_rate >= MAX_CASHBACK_RATE:
            self.status = CardStatus.BLOCKED
            raise BusinessRuleError(BusinessRuleError.CASHBACK_LIMIT)
        # 7. Запрещённый MCC
        if mcc in FORBIDDEN_MCC:
            raise BusinessRuleError(BusinessRuleError.MCC_FORBIDDEN.format(mcc=mcc))
        # 8. Сумма оплаты превышает лимит (блокировка карты)
        if amount > PAY_LIMIT:
            self.status = CardStatus.BLOCKED
            raise BusinessRuleError(BusinessRuleError.PURCHASE_LIMIT_EXCEEDED)
        # 9. Недостаточно денег
        if self.account.balance < amount:
            raise InsufficientFundsError(
                InsufficientFundsError.INSUFFICIENT_FUNDS_FOR_PAYMENT
            )

        # Основное действие — списание и кешбэк
        self.account.balance -= amount
        cashback_amount = round(float(amount * self.cashback_rate), 2)
        self.account.cashback_balance += cashback_amount

        self.bank.transaction_log.append(
            Transaction(
                from_card=self.card_id,
                to_card=None,
                amount=amount,
                type=TransactionType.PAY,
                mcc=mcc,
                cashback=cashback_amount,
                description=CB_DEBIT_PAY_DESCRIPTION.format(
                    amount=amount,
                    mcc=mcc,
                    card_id=self.card_id,
                    cashback_amount=cashback_amount,
                ),
                timestamp=next_timestamp_after(self.issue_date),
            )
        )

Большинство проверок идентично проверкам метода pay у родительского класса Card. Но дополнительно добавляются простые проверки, связанные с уникальным для класса CashbackDebitCard атрибутом cashback_rate: условия #3 и #6.

       # 3. Процент кешбэка отрицательный
        if self.cashback_rate < 0:
            raise ValidationError(ValidationError.CASHBACK_NEGATIVE)

       # 6. Процент кешбэка больше лимита или равен лимиту (блокировка карты)
        if self.cashback_rate >= MAX_CASHBACK_RATE:
            self.status = CardStatus.BLOCKED
            raise BusinessRuleError(BusinessRuleError.CASHBACK_LIMIT)

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

pay для SavingCard
   def pay(self, amount: float, mcc: str):
        # 1. Всегда выбрасываем ошибку (оплаты запрещены)
        raise BusinessRuleError(BusinessRuleError.PAYMENT_NOT_ALLOWED_FOR_SAVING)

Это новый переопределённый метод для SavingCard, который всегда выбрасывает ошибку, если его используют по отношению к SavingCard.

accure_interest для SavingCard
   def accrue_interest(self):
        # 1. Ставка накопления отрицательная
        if self.interest_rate < 0:
            raise ValidationError(ValidationError.INTEREST_NEGATIVE)
        # 2. Карта не привязана к счёту
        if not self.account:
            raise AccessError(AccessError.ACCOUNT_NOT_LINKED)
        # 3. Карта закрыта или заблокирована
        if self.status in [CardStatus.CLOSED, CardStatus.BLOCKED]:
            raise AccessError(AccessError.CARD_CLOSED)
        # 4. Ставка накопления превышает лимит (блокировка)
        if self.interest_rate >= MAX_SAVING_INTEREST_RATE:
            self.status = CardStatus.BLOCKED
            raise BusinessRuleError(BusinessRuleError.SAVING_RATE_TOO_HIGH)

        if self.account.balance >= 0:
            interest = round(self.account.balance * self.interest_rate, 2)
            self.account.balance += interest

            self.bank.transaction_log.append(
                Transaction(
                    from_card=None,
                    to_card=self.card_id,
                    amount=interest,
                    cashback=DEFAULT_CASHBACK_TRANSACTION,
                    type=TransactionType.INTEREST,
                    mcc=None,
                    description=SAVING_INTEREST_DESCRIPTION.format(
                        interest=interest, card_id=self.card_id
                    ),
                    timestamp=next_timestamp_after(self.issue_date),
                )
            )

Проверки на привязку к счёту и на то, является ли карточка активной, такие же, как и в методах выше, например в get_balance или get_card_info.

Также добавляются проверки для уникального для SavingCard атрибута interest_rate (по аналогии с cashback_rate для CashbackDebitCard логика идентичная) — условия #1 и #4.

       # 1. Ставка накопления отрицательная
        if self.interest_rate < 0:
            raise ValidationError(ValidationError.INTEREST_NEGATIVE)

        # 4. Ставка накопления превышает лимит (блокировка)
        if self.interest_rate >= MAX_SAVING_INTEREST_RATE:
            self.status = CardStatus.BLOCKED
            raise BusinessRuleError(BusinessRuleError.SAVING_RATE_TOO_HIGH)
apply_for_card для Bank
   def apply_for_card(
        self,
        last_name,
        first_name,
        pin,
        phone,
        payment_system=DEFAULT_PAYMENT_SYSTEM,
        card_class: type = Card,
        **kwargs,
    ):
        # 1, 2. ПИН-код — строка, состоящая из 4 цифр
        if not (isinstance(pin, str) and pin.isdigit() and len(pin) == 4):
            if not (isinstance(pin, str) and pin.isdigit()):
                raise ValidationError(ValidationError.PIN_FORMAT_INVALID)
            else:
                raise ValidationError(ValidationError.PIN_INVALID)

        # 3. Валидные имя и фамилия (на русском и без спецсимволов)
        name_pattern = r"^[А-ЯЁа-яё]+(?:[ -][А-ЯЁа-яё]+)*$"
        if not re.fullmatch(name_pattern, last_name) or not re.fullmatch(
            name_pattern, first_name
        ):
            raise ValidationError(ValidationError.NAME_INVALID)

        # 4. Корректная платёжная система
        allowed_systems = set(BIN_BY_SYSTEM.keys())
        if payment_system.upper() not in allowed_systems:
            raise ValidationError(
                ValidationError.PAYMENT_SYSTEM_NOT_SUPPORTED.format(
                    payment_system=payment_system
                )
            )

        # 5. Отсутствие другого пользователя с таким же телефоном
        for existing_user in self.customers.values():
            if existing_user.phone == phone:
                if (
                    existing_user.last_name != last_name
                    or existing_user.first_name != first_name
                ):
                    raise BusinessRuleError(BusinessRuleError.USER_CONFLICT)

        # 5. Максимальное число дебетовых/кешбэк-карт — 5
        user = next(
            (
                user
                for user in self.customers.values()
                if user.last_name == last_name
                and user.first_name == first_name
                and user.phone == phone
            ),
            None,
        )
        if user and issubclass(card_class, (SimpleDebitCard, CashbackDebitCard)):
            debit_cards_count = sum(
                1
                for c in user.cards
                if isinstance(c, (SimpleDebitCard, CashbackDebitCard))
                and c.status not in [CardStatus.CLOSED, CardStatus.BLOCKED]
            )
            if debit_cards_count >= 5:
                raise BusinessRuleError(BusinessRuleError.TOO_MANY_DEBIT_CARDS)

        # Поиск существующего пользователя по телефону
        user = next(
            (
                user
                for user in self.customers.values()
                if user.last_name == last_name
                and user.first_name == first_name
                and user.phone == phone
            ),
            None,
        )
        if not user:
            user_id = next(self._user_seq)
            user = User(last_name, first_name, pin, phone, user_id)
            self.customers[user_id] = user

        acc_id = self._next_account_number()
        acc = Account(owner=user, acc_id=acc_id)
        self.accounts[acc_id] = acc
        user.accounts.append(acc)

        card_id = next(self._card_seq)
        pan = self._generate_pan(payment_system)
        issue_date = next(ISSUE_DATE_GENERATOR)

        card = card_class(
            account=acc,
            card_id=card_id,
            payment_system=payment_system,
            pan=pan,
            issue_date=issue_date,
            currency=CARD_CURRENCY,
            status=CARD_STATUS,
            bank=self,
            **kwargs,
        )

        self.cards[card_id] = card
        user.cards.append(card)
        return card

Несколько проверок уже проводились в методах выше в change_pin и get_balance. Остановимся детальнее на проверках условий #2 и #5.

Иногда нужно проверять соответствие объекта определённой маске, как в условии #2. Например, то, что имя и фамилия должны быть на русском. Или телефон должен быть в формате «+7(999)999-99-99». Можно это делать способом перебора всех символов.

text = "Привет"

result = all(
    ch in "Ёё" or 'А' <= ch <= 'Я' or 'а' <= ch <= 'я'
    for ch in text
)

print(result)  # True

Если в тексте могут встречаться ещё спецсимволы и знаки пунктуации, то можно усложнить условие.

text = "Привет, мир!"

allowed = " .,!?-"

result = all(
    ch in allowed or ch in "Ёё" or 'А' <= ch <= 'Я' or 'а' <= ch <= 'я'
    for ch in text
)

print(result)  # True

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

import re
       # 3. Валидные имя и фамилия (на русском и без спецсимволов)
        name_pattern = r"^[А-ЯЁа-яё]+(?:[ -][А-ЯЁа-яё]+)*$"
        if not re.fullmatch(name_pattern, last_name) or not re.fullmatch(
            name_pattern, first_name
        ):
            raise ValidationError(ValidationError.NAME_INVALID)

Здесь name_pattern описывает шаблон:

  • ^[А-ЯЁа-яё]+ — в начале обязательно одна русская буква;
  • (?:…)* — необязательная повторяющаяся группа:
    — [ -] — пробел или дефис;
    — [А-ЯЁа-яё]+ — за пробелом и дефисом обязательно идут буквы;
  • $ — конец строки.

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

re.fullmatch() — проверяет, соответствует ли вся строка шаблону. Отдельно имя, отдельно фамилия.

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

Ещё одна нетипичная проверка в рамках метода — проверка на принадлежность к определённому подклассу, чтобы посчитать количество определённого типа карт в условии #5.

Ранее в методе change_pin мы использовали функцию isinstance(), которая проверяла принадлежность объекта к определённому классу. Похожая функция, которая работает по аналогии и проверяет принадлежность класса к определённому подклассу, — issubclass().

В рамках решения вам необходимо проверять, сколько дебетовых карт пользователь уже завёл. С помощью issubclass() определяем принадлежность новой карты к SimpleDebitCard и CashbackDebitCard, а дальше считаем уже количество.

       # 5. Максимальное число дебетовых/кешбэк-карт — 5
        user = next(
            (
                user
                for user in self.customers.values()
                if user.last_name == last_name
                and user.first_name == first_name
                and user.phone == phone
            ),
            None,
        )
        if user and issubclass(card_class, (SimpleDebitCard, CashbackDebitCard)):
            debit_cards_count = sum(
                1
                for c in user.cards
                if isinstance(c, (SimpleDebitCard, CashbackDebitCard))
                and c.status not in [CardStatus.CLOSED, CardStatus.BLOCKED]
            )
            if debit_cards_count >= 5:
                raise BusinessRuleError(BusinessRuleError.TOO_MANY_DEBIT_CARDS)

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

Какие тесты можно использовать для проверки

Самый простой способ — прорабатывать отдельно каждый модуль и собирать небольшие функции-тесты для проверки каждого условия.

Например, хотим проверить, что метод deposit правильно обрабатывает исключения. Для этого соберём вспомогательную функцию try_deposit(), в которую положим все ситуации.

def try_deposit():
    bank = Bank("ОплатаБанк", "044525501")

    # Карта без счёта
    card_no_account = Card(account=None, card_id=1, bank=bank)
    card_no_account.status = CardStatus.ACTIVE

    # Карта закрыта
    user_card = bank.issue_simple_debit_card("Гусева", "Олеся", "1111", "79990000206", "MIR")
    user_card.status = CardStatus.CLOSED

    # Обычная активная карта
    active_card = bank.issue_simple_debit_card("Иванов", "Иван", "1111", "79990000207", "MIR")

    test_cases = [
        ("OK", active_card, 100),
        ("Нет привязки к счёту", card_no_account, 100),
        ("Карта закрыта", user_card, 100),
        ("Сумма = 0", active_card, 0),
        ("Отрицательная сумма", active_card, -50),
        ("Превышен лимит", active_card, DEPOSIT_LIMIT + 1),
    ]

    for title, card, amount in test_cases:
        try:
            card.deposit(amount)
            print(f"[deposit] {title}: OK, баланс={card.account.balance if card.account else '—'}")
        except BankError as e:
            print(f"[deposit] {title}: {type(e).__name__} — {e}, статус={card.status}")

if __name__ == "__main__":
    try_deposit()

В константу test_cases положили следующие ситуации:

  • Пополнение счёта прошло без ошибок.
  • Нет привязки к счёту.
  • Карта закрыта, операции невозможны.
  • Сумма пополнения меньше нуля или равна нулю.
  • Лимит превышен.

Программа выведет:

[deposit] OK: OK, баланс=100.0
[deposit] Нет привязки к счёту: AccessError — Карта не привязана к счёту, статус=CardStatus.ACTIVE
[deposit] Карта закрыта: AccessError — Карта закрыта или заблокирована. Невозможно провести операцию., статус=CardStatus.CLOSED
[deposit] Сумма = 0: ValidationError — Сумма пополнений должна быть положительной, статус=CardStatus.ACTIVE
[deposit] Отрицательная сумма: ValidationError — Сумма пополнений должна быть положительной, статус=CardStatus.ACTIVE
[deposit] Превышен лимит: BusinessRuleError — Превышен лимит депозита. Карта заблокирована до выяснения причин., статус=CardStatus.BLOCKED

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

Глобально, конечно, можно не делать тесты, а просто сделать несколько небольших проверок прямым кодом. Но если начнёте задумываться о задаче с конца и проверять себя с помощью продуманных тестов, то это точно уменьшит количество ошибок и поднимет качество кода. Такой подход называется Test-Driven Development.

Что в итоге
  1. Для проверки класса объекта используйте функцию isinstance(). Особенно если важно перед обработкой убедиться, что объект нужного типа.
  2. Для проверки принадлежности к определённому подклассу используйте функцию issubclass().
  3. Регулярные выражения — мощный инструмент для проверки соответствия строк определённым шаблонам.

Задача № 5. Сбор всех ошибок и поддержка работы программы

Ключевая тема: повышение отказоустойчивости сервиса.

Подход к задаче

Можно подойти двумя способами:

  • «В лоб»: прописать обработку ошибок в каждом методе.
  • Вынести обработку в декоратор.

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

Например, для метода deposit() код выглядел бы следующим образом:

def deposit(self, amount: float):
    try:
       # 1. Сумма пополнения положительная
        if amount <= 0:
            raise ValidationError(ValidationError.DEPOSIT_AMOUNT_NEGATIVE)
        # 2. Карта привязана к счёту
        if not self.account:
            raise AccessError(AccessError.ACCOUNT_NOT_LINKED)
        # 3. Карта не закрыта / не заблокирована
        if self.status in [CardStatus.CLOSED, CardStatus.BLOCKED]:
            raise AccessError(AccessError.CARD_CLOSED)
        # 4. Не превышен лимит пополнения
        if amount > DEPOSIT_LIMIT:
            self.status = CardStatus.BLOCKED
            raise BusinessRuleError(BusinessRuleError.DEPOSIT_LIMIT_EXCEEDED)

        # Основное действие
        self.account.balance += amount

        self.bank.transaction_log.append(Transaction(
            from_card=None,
            to_card=self.card_id,
            amount=amount,
            type=TransactionType.DEPOSIT,
            mcc=None,
            cashback=DEFAULT_CASHBACK_TRANSACTION,
            description=DEPOSIT_DESCRIPTION.format(amount=amount, card_id=self.card_id),
            timestamp=next_timestamp_after(self.issue_date)
        ))

    except BankError as e:
        print(f"{e}")
    finally:
        # Действия, которые выполняются всегда, даже при ошибке
        pass

Но лучше так не делать, потому что:

  1. Вам нужно будет для каждого метода прописать try/except/finally, а в будущем заниматься поддержкой этого кода.
  2. Сейчас внутри метода смешиваются две задачи: пополнение счёта и обработка исключений. Лучше держать в одном методе одну задачу, чтобы не перегружать логику работы и код.

Поэтому лучше доверим ловлю и обработку ошибок декоратору. Он может выглядеть следующим образом:

# =============================== ДЕКОРАТОР ===============================

error_log = []  # глобальный лог ошибок

def catch_errors_and_log_method(func):
    def wrapper(self, *args, **kwargs):
        try:
            return func(self, *args, **kwargs)
        except BankError as e:
            error_text = str(e)
            error_log.append((error_type, error_text))
            print(f"{error_text}")
    return wrapper

Декоратор пробует реализовать метод. Если возникает ошибка — собирает для вас в одном формате код ошибки (error_type, error_text). А пользователю просто выводим текст ошибки без лишних усложнений.

Декоратор цепляется к методам так же, как и к функциям:

   @catch_errors_and_log_method
    def deposit(self, amount: float):
       # 1. Сумма пополнения положительная
        if amount <= 0:
            raise ValidationError(ValidationError.DEPOSIT_AMOUNT_NEGATIVE)
        # 2. Карта привязана к счёту
        if not self.account:
            raise AccessError(AccessError.ACCOUNT_NOT_LINKED)
        # 3. Карта не закрыта / не заблокирована
        if self.status in [CardStatus.CLOSED, CardStatus.BLOCKED]:
            raise AccessError(AccessError.CARD_CLOSED)
        # 4. Не превышен лимит пополнения
        if amount > DEPOSIT_LIMIT:
            self.status = CardStatus.BLOCKED
            raise BusinessRuleError(BusinessRuleError.DEPOSIT_LIMIT_EXCEEDED)

        # Основное действие
        self.account.balance += amount

        self.bank.transaction_log.append(Transaction(
            from_card=None,
            to_card=self.card_id,
            amount=amount,
            type=TransactionType.DEPOSIT,
            mcc=None,
            cashback=DEFAULT_CASHBACK_TRANSACTION,
            description=DEPOSIT_DESCRIPTION.format(amount=amount, card_id=self.card_id),
            timestamp=next_timestamp_after(self.issue_date)
        ))
Какие тесты можно использовать для проверки

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

# Позитивный сценарий. Без ошибок
def user_positive_path():
    bank = Bank("Demo Bank", "044452345")

    # 1. Выпуск карт
    card1 = bank.issue_simple_debit_card("Иванов", "Иван", "1234", "+79000010001", "MIR")
    card2 = bank.issue_simple_debit_card("Петров", "Пётр", "5678", "+79000010002", "VISA")

    # 2. Смена ПИН-кода
    card1.account.owner.change_pin("1234", "4321")

    # 3. Пополнение
    card1.deposit(10_000)

    # 4. Оплата
    card1.pay(500, "5411")

    # 5. Перевод
    card1.transfer(card2, 2000)

    # 6. Кешбэк-карта
    cb_card = bank.issue_cashback_debit_card("Сидоров", "Максим", "1111", "+79000010003", "MIR")
    cb_card.deposit(5_000)
    cb_card.pay(1000, "5411")

    # 7. Накопительная карта
    sv_card = bank.issue_saving_card("Копылов", "Артём", "2222", "+79000010004", "MIR", interest_rate=0.02)
    sv_card.deposit(3_000)
    sv_card.accrue_interest()

    # 8. История операций
    print("История по карте 1:")
    for row in card1.get_transaction_history():
        print(row)


if __name__ == "__main__":
    user_positive_path()

На выводе получим только историю операций транзакций.

История по карте 1:
timestamp,type,from_card,to_card,amount,mcc,cashback,description
2022-01-02 12:07:11,deposit,,1,+10000.00₽,,0.00₽,10000.00₽ → карта #1
2022-01-03 15:14:22,pay,1,,-500.00₽,5411,0.00₽,500.00₽ (MCC: 5411) с карты #1
2022-01-04 18:21:33,transfer,1,2,-2000.00₽,,0.00₽,2000.00₽: карта #1 → карта #2

А это уже негативный сценарий. Готовьтесь ловить ошибки.

def user_negative_path():
    bank = Bank("Demo Bank", "044452345")

    # 1. Выпуск карты — неверный формат PIN
    bank.apply_for_card("Иванов", "Иван", "12a4", "+79000010001")

    # 2. Смена ПИН-кода — неправильный старый ПИН-код
    card1 = bank.issue_simple_debit_card("Иванов", "Иван", "1234", "+79000010002", "MIR")
    card1.account.owner.change_pin("0000", "5678")

    # 3. Пополнение — отрицательная сумма
    card1.deposit(-100)

    # 4. Оплата — запрещённый MCC
    card1.deposit(5000)
    card1.pay(100, "7995")

    # 5. Перевод — перевод самому себе
    card1.transfer(card1, 100)

    # 6. История — карта без банка
    card_no_bank = bank.issue_simple_debit_card("Петров", "Пётр", "5678", "+79000010003", "VISA")
    card_no_bank.bank = None
    card_no_bank.get_transaction_history()

    # 7. Кешбэк — слишком высокий процент
    cb_card = bank.issue_cashback_debit_card("Сидоров", "Максим", "1111", "+79000010004", "MIR", cashback_rate=MAX_CASHBACK_RATE)
    cb_card.deposit(500)
    cb_card.pay(100, "5411")

    # 8. Накопительная — оплата запрещена
    sv_card = bank.issue_saving_card("Копылов", "Артём", "2222", "+79000010005", "MIR")
    sv_card.pay(100, "5411")

На выводе получим сообщения об ошибках. Но при этом программа не упадёт и с каждой неправильной операцией будет продолжать работу.

ПИН-код должен состоять только из цифр
Введённый ПИН-код не соответствует текущему
Сумма пополнений должна быть положительной
Оплата отклонена. Покупки по 7995 запрещены банком
Нельзя пересылать деньги самому себе
Карта не привязана к банку
Процент кешбэка завышен, возможна техническая ошибка. Карта заблокирована до выяснения причин
С накопительного счёта нельзя списывать покупки
Что в итоге
  1. Не перегружайте методы. Старайтесь делать так, чтобы каждый метод решал одну конкретную задачу, а не несколько сразу.
  2. Декораторы применяются к методам так же, как и к функциям.
  3. У вас получилась программа, которая обрабатывает сценарий пользователя; как бы он ни пытался её уронить, она всё равно продолжает работать. Класс!

Итог

Если у вас получилось разобраться с решениями всех пяти задач и проработать их — вы молодец!

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

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

Максим Ломакин
Тимлид в Яндекс Учебнике

Хочу напоследок подвести итоги и поделиться несколькими пожеланиями на будущее. Начнём с пожеланий.

Если не понимаете, как реализовать конкретную функцию, — поищите примеры. Возможно, вы заметили, что в этой задаче часто мелькали ссылки на документацию по определённым функциям и методам.

Python — активно развивающийся язык с большим сообществом. Скорее всего, любую задачу, с которой вы сталкиваетесь, кто-то уже сделал. Уделяйте время, чтобы разобраться в чём-то новом с помощью поисковиков и документации, развивайте насмотренность.

Также можно использовать разные GPT, но — хотим предупредить — с осторожностью. Они могут галлюцинировать и убедительно выдавать неоптимизированный или не работающий код.

Упрощайте работу с помощью дата-классов и Enum. Основная задача разработчика — делать код, который эффективно решает задачу, надёжен в работе и легко поддерживается.

Использование дата-классов и Enum помогает:

  • сократить количество шаблонного кода;
  • повысить читаемость и структурированность программы;
  • снизить риск ошибок, связанных с ручным управлением состоянием объектов и «магическими» строками или числами;
  • экономить время на рутинных задачах;
  • упрощать дальнейшую поддержку и расширение функциональности.

В будущем вы будете находить всё больше таких вспомогательных фишечек. Запоминайте их и используйте!

Если сталкиваетесь с большим количеством сущностей — изучите, как они взаимодействуют. Лучше уделить этому достаточно времени: зато в дальнейшем будет проще. Отлично помогают диаграммы, которые описывают взаимосвязи, как мы делали в разборе выше.

Также давайте подведём итоги: что изучили и что можно взять с собой дальше.

В рамках этих задач вы:

  • Поработали со всеми принципами ООП:
    — Абстракция — описали основные сущности (Card, Bank, User, Account, Transaction) через классы, выделив только необходимые атрибуты и действия.
    — Инкапсуляция — настроили изменение внутренних параметров только через методы, которые выполняют проверки (change_pin, deposit, transfer, pay).
    — Наследование — создали специализированные классы (SimpleDebitCard, CashbackDebitCard, SavingCard) на основе базового Card.
    — Полиморфизм — переопределили pay() в классе CashbackDebitCard и SavingCard, сохранив единый интерфейс для всех карт.
  • Вспомнили, как работать с генераторами и декораторами.
  • Проработали модель исключений.
  • Попробовали использование Enum и dataclass.

Что дальше

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

В общем, пробуйте, экспериментируйте — и у вас всё получится.

На этом освоение раздела завершено. А вы — супер!