Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Partial refunds #2230

Merged
merged 19 commits into from
Apr 19, 2024
Merged

Partial refunds #2230

merged 19 commits into from
Apr 19, 2024

Conversation

paulinenik
Copy link
Contributor

OrderRefunder теперь обрабатывает и полный, и частичный возвраты(если указан amount), так, например, можно будет оставить возможность полного возврата в списке

В админку пока не выводила, хочу окнуть изменения сервиса: сейчас я просто храню сумму прошлых возвратов в поле refund_amount заказа, а историю возвратов записываю в LogEntry.

Вижу два варианта:

  1. Реализовать на деталке заказа внизу кнопку "Оформить возврат", где нужно будет ввести сумму возврата, история будет храниться в LogEntry
  2. Создать отдельную модель Refund, привязанную к заказу и выводить её на детальной в виде инлайна, тогда историю возвратов можно будет видеть прямо на странице в инлайне и в LogEntry, а refund_amount в заказе в таком случае можно сделать @Property

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

@paulinenik paulinenik requested a review from f213 March 20, 2024 12:21
@paulinenik paulinenik requested a review from f213 March 21, 2024 13:14
Copy link
Contributor

@f213 f213 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

src/apps/orders/services/order_refunder.py Outdated Show resolved Hide resolved
src/apps/orders/models/order.py Outdated Show resolved Hide resolved
@@ -42,7 +42,7 @@ def get_initial_payment_url(self) -> str:

return result["link"]

def refund(self) -> None:
def refund(self, amount: Decimal | None = None) -> None:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

А зачем здесь меняем сигнатуру?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Потому что у родительского Bank сигнатура изменилась и линтер ругается, что сигнатуры не совпадают , подумала, что так правильнее, чем повесить type: ignore

image

src/apps/orders/migrations/0036_Refund.py Show resolved Hide resolved
src/apps/orders/services/order_refunder.py Show resolved Hide resolved
@paulinenik
Copy link
Contributor Author

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

@paulinenik
Copy link
Contributor Author

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

Следующие шаги вижу так:

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

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

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

src/apps/orders/migrations/0036_Refund.py Outdated Show resolved Hide resolved
tests/apps/amocrm/services/order/tests_order_pusher.py Outdated Show resolved Hide resolved
src/apps/orders/services/order_refunder.py Outdated Show resolved Hide resolved
src/apps/orders/services/order_refunder.py Outdated Show resolved Hide resolved
src/apps/orders/services/order_refunder.py Outdated Show resolved Hide resolved
@f213
Copy link
Contributor

f213 commented Mar 25, 2024

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

Я бы весь код кастомизации спрятал бы в отдельную модельку для админки. Сделал бы прокси-модель к рефандам, типа AdminRefund, выводил бы её в админке заказа как Inline, а при добавлении переопределил бы метод AdminRefundManager.create() или форму в админке (забыл, что лучше), чтобы они вместо создания записи в БД вызывали бы OrderRefunder.

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

@paulinenik
Copy link
Contributor Author

@f213 комментарии поправлю чуть позже, а пока вопрос: в сервисе возврата происходит много операций, кажется, что стоит обернуть это всё в transaction.atomic, чтобы при ошибке на любом этапе данные в базе не расходились с реальным положением дел

или это не сделано по какой-то причине?

@f213
Copy link
Contributor

f213 commented Mar 25, 2024

или это не сделано по какой-то причине?

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

@paulinenik paulinenik requested a review from f213 March 26, 2024 09:12
Copy link
Contributor

@f213 f213 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Я тут чё-то принёс важных замечаний в последний момент :-)

src/apps/orders/services/order_refunder.py Outdated Show resolved Hide resolved
src/apps/orders/services/order_refunder.py Outdated Show resolved Hide resolved
bank_id=self.order.bank_id,
)

def mark_order_as_not_paid_if_needed(self) -> None:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Я думаю от всей этой логики можно избавиться, как и от поля unpaid. Мы пытались на его основе построить дешборды для возвратов, то так и не сделали. В новой версии приложения записи Refund полностью его заменят.

Давай удалим метод и поле unpaid.

Copy link
Contributor Author

@paulinenik paulinenik Mar 27, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Тогда paid мы не сбрасываем, когда заказ полностью вернули? Сейчас дата становится None и к этому привязаны различные штуки вроде обновление тегов для даши

Ещё такой вопрос, если мы делаем возврат для ещё не оплаченного заказа (paid=None), то к возврату доступно 0 и в записи о возврате - 0?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UPD: дропнула unpaid, но метод пока оставила, чтобы чистить paid при полном возврате: не уверена, что ничего не сломается, если это убрать (как минимум ломаются тесты с amo)

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

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Так, я тут нашёл ещё одну важную проблему. У нас сейчас написана куча дешбордов, в которых мы считаем деньги. К примеру, чтобы посчитать деньги за период, мы берём все заказы, у которых paid is not null and between period_start and period_end и делаем SUM(price * acquiring_percent).

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

Я предлагаю добавить денормализацию:

  1. При создании refund уменьшать order.price на его сумму
  2. paid при полном рефанде не сбрасывать, чтобы было видно, что заказ когда-то был оплачен. order.price при этом делать нулевым.

Что думаешь?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Супер, это позволит упростить код сервиса ещё немного и полностью отказаться от available_to_refund_amount: по price уже будет понятно, сколько можно вернуть

И ещё раз четче сформулирую вопрос: если заказ не был оплачен (paid=None), а мы пытаемся сделать возврат - что происходит? В моем понимании, нам ещё не поступили деньги, а значит мы не можем их вернуть, но должны закрыть доступ к продукту.

Для таких заказов возврат делается на сумму 0 и всё равно записывается в логах?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

И ещё раз четче сформулирую вопрос: если заказ не был оплачен (paid=None), а мы пытаемся сделать возврат - что происходит? В моем понимании, нам ещё не поступили деньги, а значит мы не можем их вернуть, но должны закрыть доступ к продукту.

Да, всё так.

На самом деле это довольно редкий кейс — заказ может быть paid=None и shipped is not None только в случае больших b2b-заказов, когда мы допускаем учеников до занятий до поступления оплаты. Не помню ни одного раза, чтобы при этом нужно было оформлять возврат. Но да, лучше сделать.

На всякий случай не забывай, чт paid может быть not None, а price=0 — это ученики, бесплатно допущенные до курсов. Туда попадают ранние чтецы или, к примеру, все кого мы отправляем учиться из fands. Вряд ли конечно там будут возвраты, но лучше бы тоже учесть.

@paulinenik
Copy link
Contributor Author

Я учла все комментарии, а ещё решила админку сделать здесь(через инлайн там действительно добавилось не очень много нового кода, а троттлинг я перенесла прямо в сервис возвратов, и кажется, это наиболее логично)

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

@paulinenik paulinenik requested a review from f213 March 28, 2024 13:51
@f213
Copy link
Contributor

f213 commented Mar 28, 2024

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

Давай может и её заблокируем? Можно в тротлинг к примеру добавить логику, что мы можем делать только один возврат в течение 10 секунд.

Copy link
Contributor

@f213 f213 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Так, я тут пришёл ещё с одной ВНЕЗАПНОЙ вводной. А по коду мне уже всё нравки, да.

src/apps/orders/admin/orders/admin.py Outdated Show resolved Hide resolved
class OrderRefundActionThrottle(ConfigurableThrottlingMixin, UserRateThrottle): # type: ignore
"""Throttle for order's admin action `refund`."""

scope = "order-refund"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Кажется, скоуп стоит выпилить ещё и отсюда.

bank_id=self.order.bank_id,
)

def mark_order_as_not_paid_if_needed(self) -> None:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Так, я тут нашёл ещё одну важную проблему. У нас сейчас написана куча дешбордов, в которых мы считаем деньги. К примеру, чтобы посчитать деньги за период, мы берём все заказы, у которых paid is not null and between period_start and period_end и делаем SUM(price * acquiring_percent).

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

Я предлагаю добавить денормализацию:

  1. При создании refund уменьшать order.price на его сумму
  2. paid при полном рефанде не сбрасывать, чтобы было видно, что заказ когда-то был оплачен. order.price при этом делать нулевым.

Что думаешь?

@paulinenik paulinenik requested a review from f213 April 3, 2024 09:18
src/apps/orders/models/order.py Outdated Show resolved Hide resolved
src/apps/orders/services/order_refunder.py Outdated Show resolved Hide resolved
src/apps/orders/services/order_refunder.py Outdated Show resolved Hide resolved
src/apps/orders/services/order_refunder.py Outdated Show resolved Hide resolved
@paulinenik paulinenik requested a review from f213 April 4, 2024 16:56
return self.filter(paid__isnull=False, price__gt=0)

def not_paid(self) -> "OrderQuerySet":
return self.filter(Q(paid__isnull=False, price=0) | Q(paid__isnull=True))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Подскажи, какой смысл этого изменения?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Но ещё меня смутил вот этот тест, потому что сейчас новым заказам в амо привязывается лид из возвращенных заказов (или не закрытых)

Сейчас если заказы с paid is not None для этой же сделки существуют - новый заказ не отправится в амо, раньше в это условие не попадали заказы с возвратами, потому что мы сбрасывали дату платежа, теперь нужно другое условие

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

if Order.objects.paid().same_deal(order=self.order).filter(amocrm_lead__isnull=False, price__gt=0).exists(): # we have other paid orders for the same deal

Что думаешь?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Согласен!

@paulinenik paulinenik requested a review from f213 April 10, 2024 14:59
Copy link
Contributor

@f213 f213 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Мне всё нравки. Готов это смёрджить на проде с живыми деньгами с моей карточки.

Ты же не видишь никаких препятствий?

@paulinenik
Copy link
Contributor Author

@f213 по коду всё, можем мерджить. Из важного: в админке в списке по-прежнему можно сделать полный возрат, я поправила название действия на "Сделать полный возврат", чтобы не возникло путаницы

Осталось поправить шаблон в постмарке и всё готово:
image
предлагаю убрать предложение о том, что доступ закрыт (мы его закрываем, только когда price == 0)

и заменить

Сумма: {{ price }}

на

Сумма возврата: {{ amount }}
Для возврата доступно: {{ price }}

так будет окей?

@f213
Copy link
Contributor

f213 commented Apr 15, 2024

Из важного: в админке в списке по-прежнему можно сделать полный возрат, я поправила название действия на "Сделать полный возврат", чтобы не возникло путаницы

Супер!

так будет окей?

Да, вполне.

Думаю, сегодня я уже не успею, а завтра будем тестить

@f213 f213 merged commit 7f2fb99 into master Apr 19, 2024
5 checks passed
@f213 f213 deleted the partial-refunds branch April 19, 2024 15:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants