tutorial 3 хв читання

Чому ми викинули каталог продуктів з інвойсів

Як next10 CRM перейшла з обовʼязкового product_config до ad-hoc інвойсів — і чому це різко спростило життя менеджерам. Архітектурний розбір з реальним кодом.

Кирило Пирожок
Founder · next10

Перші місяці нашої CRM працювали так: щоб виставити рахунок клієнту — менеджер мав спершу зайти в Settings → Products → створити продукт із slug, ціною, описом, FOP-реквізитами. Потім — відкрити карточку клієнта → Інвойси → обрати щойно створений продукт → надіслати.

Для разової консультації на 500 ₴ це сім кліків і дві сторінки. Команда саботувала — просто скидали реквізити в Telegram руками.

Розбираємо, як ми це переписали.

Стара модель

class Payment(Base):
    __tablename__ = "payment"
    id: Mapped[int] = mapped_column(primary_key=True)
    client_id: Mapped[int] = mapped_column(ForeignKey("client.id"))
    product_config_id: Mapped[int] = mapped_column(  # NOT NULL
        ForeignKey("product_config.id", ondelete="RESTRICT")
    )
    amount: Mapped[Decimal]
    # ...

Кожен платіж жорстко прив’язаний до product_config. Назва, ціна, картинка, реквізити — все там. Це чисто, поки катáлог стабільний. Як тільки бізнес виходить за межі типових послуг — починаються «продукти-сирітки» в Settings: «Консультація для Олега», «Доплата за хостинг», «Чорновий етап лендингу».

Що зробили

Один маленький рефакторинг рятує день:

class Payment(Base):
    product_config_id: Mapped[int | None] = mapped_column(
        ForeignKey("product_config.id"), nullable=True   # ← було NOT NULL
    )
    title: Mapped[str | None]                             # ← нове
    description: Mapped[str | None]                       # ← нове
    # ...

    @property
    def display_title(self) -> str:
        if self.title:
            return self.title
        if self.product_config:
            return self.product_config.name
        return f"Інвойс #{self.id}"

Тепер є дві стратегії:

  1. Готовий продукт — старий шлях, для типових послуг (MVP-пакети, тарифи).
  2. Ad-hoc — менеджер вводить назву + суму вручну. Ніяких Settings.

Snapshot-міграція

Найболючіший момент — старі платежі. Раніше їхня назва бралась з JOIN payment.product_config_id = product_config.id. Якщо завтра вимкнути продукт — історія для бухгалтерії «зникне».

Один разовий backfill знімає ризик:

UPDATE payment
SET title = pc.name
FROM product_config pc
WHERE payment.product_config_id = pc.id
  AND payment.title IS NULL;

Після цього payment.title — самодостатнє поле. Незалежно від того, що відбувається з каталогом продуктів далі.

Що це дало в UI

Форма створення інвойсу зараз має toggle:

[ Готовий продукт ] [ Свій інвойс ]
   назва [...]
   сума [500]  валюта [UAH ▾]
   опис (опціонально) [...]
   метод оплати: [Mono] [ФОП]
   ☑ Надіслати клієнту в Telegram

Менеджер створює інвойс не виходячи з карточки клієнта. Один екран. Чотири кліки.

Lessons learned

  1. Жорсткі FK у фінансових таблицях — це борг. Snapshot-копія важливіша за нормалізацію.
  2. Product catalog добре працює для product-led бізнесу. Service-led — потребує гнучкості.
  3. Toggle «product / ad-hoc» — м’який міграційний шлях. Стара логіка не зникає, нова просто паралельна.

Якщо ваша CRM змушує менеджерів робити кліків більше ніж 4 на типову операцію — десь у моделі є помилка. Подивіться, де саме.

Зібрати проєкт