5.5. Проект «Разработка бэкенда банковского приложения»
Вы уже изучили все темы раздела и сейчас подходите к заключительному заданию. Эта задача похожа на те, что решают Python-разработчики на практике. Она поможет вам повторить пройденный материал и применить полученные знания.
Задача состоит из нескольких блоков:
- Контекст. В реальной жизни задача не существует сама по себе, а возникает из определённого бизнес-контекста. Поэтому мы добавили небольшое описание трансформации задачи, от первого запроса до её финального вида.
- Подсказки. Помощь в решении, если зайдёте в тупик. Комментарии к ключевым моментам решения задачи в формате FAQ.
- Разбор. Подробный разбор задачи от автора, специалиста из Яндекса: от составления алгоритма решения до реализации и пояснения каждого блока кода.
Важно
Эта задача «со звёздочкой», она посложнее предыдущих. Если не справитесь — ничего страшного: в программировании редко что-то получается с первого раза. Сделайте паузу, перечитайте теорию и возвращайтесь с новыми силами и хорошим настроением.
Перед решением задачи
Повторите:
- Объектную модель Python. Классы, поля и методы.
- Волшебные методы. Переопределение методов. Наследование.
- Модель исключений в Python.
- Генераторы, декораторы.
Изучите дополнительно:
- Класс Enum для работы с ограниченными множествами.
- Декоратор dataclass для классов-хранилищ.
- Организацию класса ошибок.
И ещё один совет напоследок: рекомендуем работать с задачей последовательно.
- Изучите контекст.
- Ознакомьтесь с условием задачи.
- Попробуйте решить её самостоятельно.
- Если понадобится помощь — обратитесь к подсказкам. Если совсем тяжко — изучите разбор и попробуйте после самостоятельно решить.
- Когда решите задачу, сравните свой вариант с решением от специалиста.
Проект

Максим Ломакин
Тимлид в Яндекс Учебнике
Привет! В этой задаче вы создадите симуляцию части функциональности банковского приложения. Это позволит пощупать взаимодействие нескольких классов и их методов. А ещё выстроите обработку исключений таким образом, чтобы приложение продолжало работать даже после ошибок. Удачи!
Контекст
Вы разработчик бэкенд-части приложения для нового банка. Вас попросили собрать базовую функциональность, которая позволит выполнять банковские операции, заводить карты разного типа и поддерживать обработку ошибок.
В основе бизнес-логики приложения выделены следующие пять классов, из которых будут заводиться реальные объекты:
- Bank.
- Account.
- Card.
- Transaction.
- User.
Вот подробнее табличка о задаче каждого класса и функциях:
|
Название класса |
За что отвечает |
Сценарии пользователей/банка |
|
Bank |
Выпускает банковские продукты и хранит информацию обо всём, что было когда-либо выпущено |
|
|
Account |
Хранит номер счёта, баланс, кто владелец |
|
|
Сard |
Отвечает за проведение операций по карте и хранит информацию о карте |
|
|
Transaction |
Хранит информацию о транзакции |
|
|
User |
Данные пользователя, карты, счёта, ПИН-код приложения |
|
Задача кажется сложной, поэтому лучше всего работать последовательно, шаг за шагом добавляя новые функции:
- Сначала наладить выпуск карт в целом.
- Затем добавить базовые функции для использования карт (например, оплаты, переводы).
- Под предпочтения пользователей выпустить несколько банковских продуктов в виде новых типов карт.
- Организовать поддержку работы приложения, настроить обработку исключений.
В итоге у вас получится продуманная симуляция банковского приложения, реализованная с помощью принципов ООП. При желании можете добавить дополнительные функции и положить результат задачи к себе на 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.
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.
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.
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.
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.
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() давайте тоже нарисуем примерную схему его работы и взаимодействия с классами на основе ТЗ в задаче.
Он должен:
- Создать нового клиента
Userс данными, поданными при подаче заявки. - Создать расчётный счёт
Account. Номер счёта должен проходить ГОСТ-контроль и быть привязанным к клиенту. Начальный баланс — 0. Для реализации используем_next_account_numberиз шаблона. - Выпустить карту
Card, привязав её к счёту и указав:
— внутренний идентификационный номер (card_id) — выдаётся по очереди начиная с 1;
— платёжную систему (payment_system);
— номер карты (pan) — 16 цифр, с контрольной цифрой по алгоритму Луна. Для реализации используем_generate_panи_luhnиз шаблона;
— дату выпуска — последовательно берётся из генератора ISSUE_DATE_GENERATOR;
— срок действия — 4 года;
— статус карты —Active;
— валюту карты —RUB;
— банк карты — берётся из созданного банка-объекта. - У пользователя сохраняется выпущенная карта в
user.cardsи новый счёт вuser.accounts. - В банк добавляется новый пользователь в
bank.customers, счёт вbank.accountsи карта вbank.cards. - Если заявку подаёт пользователь, который уже есть в системе (совпадает телефон, имя и фамилия), то нужно завести карту уже имеющемуся пользователю, а не создавать нового.
Вот как это можно проиллюстрировать:
Переносим эту схему в код и не забываем добавить проверку, если пользователь повторно отправит заявку на карту.
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)
В нём есть генерация карт с разными платёжными системами, выпуск двух карт для одного пользователя и выпуск карт для однофамильцев с разными телефонами. Этого хватает, чтобы проверить работу всей запланированной функциональности.
Что в итоге
- В этой задаче вы разбирались с шаблоном и примерным устройством классов и объектов, с которыми будем работать и далее.
- Мы постарались продемонстрировать, что любая визуализация, которая помогает лучше понять взаимодействие данных, упрощает дальнейшее написание кода. Так вы поймёте, что и в какие корзинки можно класть, а откуда и что можно достать.
Задача № 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.
Алгоритм:
- Сортируем транзакции банка по времени.
- Проверяем, подходит ли транзакция. Для этого смотрим на
tx.from_cardиtx.to_card. Если хотя бы одно значение совпадает сself.card_id, то эту транзакцию обрабатываем. - Если
tx.from_card=self.card_id, значит, деньги с карты снимались, тогда кamountдобавляем знак минус. - Если
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)
Напомним, что в этом блоке мы только рассуждаем, как проверить работоспособность программы: тему подходов к тестированию мы подробно рассматривать не будем. Скажем лишь, что в реальной жизни под каждый метод и каждый кейс его работы пишутся юнит-тесты.
Подробнее о юнит-тестах можете почитать в статье на Хабре.
Что в итоге
- Если нужно положить значение переменных в шаблон — используем
.format(). - Если хотим обратиться к значению, лежащему в enum, — используем
.value(). - Использование констант для текстовых и повторяющихся конструкций облегчает код и обновление. Если захотите обновить текст
descriptionво всех методах, то сможете это сделать в блоке «Константы», не бегая по всему коду.
Задача № 3. Выпуск новых типов банковских карт
Ключевая тема: работа с наследниками классов и их методами.
Подход к задаче
Давайте сперва проанализируем, а чем новые карты и их методы будут отличаться от уже реализованных.
То есть задача сводится к тому, чтобы:
- Завести пустой класс-наследник для дебетовой карты.
- Для дебетовой карты с кешбэком добавить новый атрибут
cashback_rateи переопределить методpay, чтобы учитывался кешбэк. - Для накопительного счёта добавить новый атрибут
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 проверяется работоспособность 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 направлены на проверку кросс-взаимодействий разных классов друг с другом. Можете составить самостоятельно примеры пользовательских сценариев, в которых задействованы разные типы карт. Подробно их рассматривать не будем.
Что в итоге
- Если происходит переопределение методов и добавление новых классов — постарайтесь визуализировать изменения хотя бы в формате таблицы. Это сильно поможет структурированно подходить к дальнейшей разработке.
- Если в дочернем классе вы используете такие же атрибуты, как и у родителя, то лучше использовать
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),
)
)
Когда вы сталкиваетесь с большим количеством проверок, важно следить за двумя вещами:
- За последовательностью проверки ошибок, чтобы она соответствовала заложенной иерархии.
- Не изменять данные при проверке ошибок, чтобы не откатывать потом изменения. Поэтому конструкция метода выглядит как набор обособленных элементов — сначала проверки, а потом уже изменение данных.
В методе 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.
Что в итоге
- Для проверки класса объекта используйте функцию isinstance(). Особенно если важно перед обработкой убедиться, что объект нужного типа.
- Для проверки принадлежности к определённому подклассу используйте функцию issubclass().
- Регулярные выражения — мощный инструмент для проверки соответствия строк определённым шаблонам.
Задача № 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
Но лучше так не делать, потому что:
- Вам нужно будет для каждого метода прописать try/except/finally, а в будущем заниматься поддержкой этого кода.
- Сейчас внутри метода смешиваются две задачи: пополнение счёта и обработка исключений. Лучше держать в одном методе одну задачу, чтобы не перегружать логику работы и код.
Поэтому лучше доверим ловлю и обработку ошибок декоратору. Он может выглядеть следующим образом:
# =============================== ДЕКОРАТОР ===============================
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 запрещены банком
Нельзя пересылать деньги самому себе
Карта не привязана к банку
Процент кешбэка завышен, возможна техническая ошибка. Карта заблокирована до выяснения причин
С накопительного счёта нельзя списывать покупки
Что в итоге
- Не перегружайте методы. Старайтесь делать так, чтобы каждый метод решал одну конкретную задачу, а не несколько сразу.
- Декораторы применяются к методам так же, как и к функциям.
- У вас получилась программа, которая обрабатывает сценарий пользователя; как бы он ни пытался её уронить, она всё равно продолжает работать. Класс!
Итог
Если у вас получилось разобраться с решениями всех пяти задач и проработать их — вы молодец!
Надеемся, вы хорошо провели время и очень собой довольны. Ведь вы написали сложную программу: воссоздали кусочек сервиса, которым пользуетесь каждый день. И — что самое главное — она работает! Так что смело можете похвастаться кодом в своём GitHub.

Максим Ломакин
Тимлид в Яндекс Учебнике
Хочу напоследок подвести итоги и поделиться несколькими пожеланиями на будущее. Начнём с пожеланий.
Если не понимаете, как реализовать конкретную функцию, — поищите примеры. Возможно, вы заметили, что в этой задаче часто мелькали ссылки на документацию по определённым функциям и методам.
Python — активно развивающийся язык с большим сообществом. Скорее всего, любую задачу, с которой вы сталкиваетесь, кто-то уже сделал. Уделяйте время, чтобы разобраться в чём-то новом с помощью поисковиков и документации, развивайте насмотренность.
Также можно использовать разные GPT, но — хотим предупредить — с осторожностью. Они могут галлюцинировать и убедительно выдавать неоптимизированный или не работающий код.
Упрощайте работу с помощью дата-классов и Enum. Основная задача разработчика — делать код, который эффективно решает задачу, надёжен в работе и легко поддерживается.
Использование дата-классов и Enum помогает:
- сократить количество шаблонного кода;
- повысить читаемость и структурированность программы;
- снизить риск ошибок, связанных с ручным управлением состоянием объектов и «магическими» строками или числами;
- экономить время на рутинных задачах;
- упрощать дальнейшую поддержку и расширение функциональности.
В будущем вы будете находить всё больше таких вспомогательных фишечек. Запоминайте их и используйте!
Если сталкиваетесь с большим количеством сущностей — изучите, как они взаимодействуют. Лучше уделить этому достаточно времени: зато в дальнейшем будет проще. Отлично помогают диаграммы, которые описывают взаимосвязи, как мы делали в разборе выше.
Также давайте подведём итоги: что изучили и что можно взять с собой дальше.
В рамках этих задач вы:
- Поработали со всеми принципами ООП:
— Абстракция — описали основные сущности (Card,Bank,User,Account,Transaction) через классы, выделив только необходимые атрибуты и действия.
— Инкапсуляция — настроили изменение внутренних параметров только через методы, которые выполняют проверки (change_pin,deposit,transfer,pay).
— Наследование — создали специализированные классы (SimpleDebitCard,CashbackDebitCard,SavingCard) на основе базовогоCard.
— Полиморфизм — переопределилиpay()в классеCashbackDebitCardиSavingCard, сохранив единый интерфейс для всех карт. - Вспомнили, как работать с генераторами и декораторами.
- Проработали модель исключений.
- Попробовали использование
Enumиdataclass.
Что дальше
Вы уже можете создавать небольшие бэкенд-приложения. Вы можете попробовать написать бота для мессенджера или автоматизировать какую-нибудь повседневную задачу.
В общем, пробуйте, экспериментируйте — и у вас всё получится.
На этом освоение раздела завершено. А вы — супер!