Коммерческие автоматизациичасть 5 из 8
- 01Концепция: какие автоматизации окупаются
- 02Фундамент: сервер, база и деплой по пушу
- 03Сердце системы: события, очередь и повторы
- 04Заменяем продажника: заявка, квалификация и менеджер за минуту
- 05Заменяем бухгалтера: оплаты, доступы и напоминания о долгах
- 06Заменяем поддержку: разбор входящих и черновики ответов
- 07Заменяем аналитика: утренняя сводка и тревоги
- 08Эксплуатация: мониторинг, бэкапы, секреты и стоимость
Это пятая часть цикла про коммерческие автоматизации и вторая из серии «Заменяем …». В прошлой части мы забрали у продажника разбор заявок. Теперь сделка состоялась, и начинается работа с деньгами. В статье только ключевые фрагменты кода, полный код серии с тестами лежит в репозитории.
Что именно мы заменяем
Речь не о бухгалтерии в смысле налогов и отчётности, а о рутине вокруг оплат, которую в небольшой компании обычно делает бухгалтер, офис-менеджер или сам продажник:
| Раньше делал человек | Теперь делает система |
|---|---|
| выставить счёт и отправить клиенту | одна команда в чате, письмо уходит само |
| проверить в личном кабинете, пришли ли деньги | уведомление от ЮKassa и сверка раз в час |
| выдать доступ, отправить письмо «оплата получена» | сразу после оплаты, ночью и в выходные тоже |
| сообщить команде | «💰 Оплачено 15 000 ₽» в чате |
| вспомнить про неоплаченные счета и написать клиенту | напоминания на 3, 7 и 14 день |
| понять, кому пора позвонить | сообщение в чат после последнего напоминания |
Возвраты, споры и всё, где нужно решение, остаются человеку. Система о них только сообщает.
Почему с деньгами нужно иначе
В части про продажи мы смирились с редким дублем: если CRM создала лид, а воркер упал, появится второй лид, и менеджер его объединит. С деньгами так нельзя. Двойная выдача доступа это потеря, двойное письмо «оплата получена» без оплаты это скандал, а неучтённая оплата это клиент, который заплатил и ничего не получил.
Поэтому здесь три правила:
- Источник правды о платеже это платёжная система, а не уведомление. Уведомление только повод спросить её.
- Заказ становится оплаченным в одном месте и одним условным SQL-запросом. Сколько бы раз и откуда ни пришло подтверждение, переход из «ждёт» в «оплачен» случится один раз.
- Каждое внешнее действие это отдельная задача с ключом. Выдать доступ, написать клиенту, сообщить команде. Если одно упало, остальные не повторяются.
Как это устроено
Шаг 1. Таблицы
create table orders (
id text primary key,
email text not null,
description text not null,
amount integer not null check (amount > 0), -- в копейках: никаких дробных чисел для денег
status text not null default 'pending'
check (status in ('pending', 'paid')),
payment_id text,
paid_payment_id text,
created_by text,
reminders int not null default 0,
created_at timestamptz not null default now(),
paid_at timestamptz
);
create index orders_pending on orders (created_at) where status = 'pending';
create table accesses (
order_id text primary key references orders (id),
token text not null unique,
created_at timestamptz not null default now()
);Сумма хранится в копейках целым числом. Деньги никогда не хранят во float: 0.1 + 0.2 в JavaScript это 0.30000000000000004, и рано или поздно такая ошибка всплывёт в сверке.
payment_id это текущий платёж в ЮKassa, по которому клиент может заплатить. paid_payment_id это платёж, который действительно оплатил заказ. Они различаются, если клиент открыл ссылку, не заплатил, а через неделю пришёл по напоминанию и получил новый платёж.
Доступы вынесены в отдельную таблицу с первичным ключом по заказу: второй доступ на тот же заказ база просто не даст создать.
Шаг 2. ЮKassa
Регистрируем магазин в ЮKassa, в настройках берём shopId и секретный ключ. Для тестов есть тестовый магазин с тестовыми картами, начинайте с него.
Клиент ЮKassa это две функции. getPayment(id) спрашивает GET /payments/<id> и возвращает платёж или undefined, если его нет. createPayment создаёт платёж с редиректом на страницу оплаты и с id заказа в metadata:
const response = await fetch(`${config.yookassaApi}/payments`, {
method: "POST",
headers: {
authorization: auth, // Basic shopId:secretKey
"content-type": "application/json",
"idempotence-key": params.idempotenceKey,
},
body: JSON.stringify({
amount: { value: rubles(params.amount), currency: "RUB" }, // "15000.00"
capture: true,
confirmation: { type: "redirect", return_url: params.returnUrl },
metadata: { order_id: params.orderId },
}),
});Две важные вещи.
Idempotence-Key. ЮKassa требует этот заголовок на каждом создающем запросе. Если запрос с тем же ключом пришёл повторно, она не создаёт второй платёж, а возвращает первый. Это ровно то, что нужно при повторах: сеть оборвалась после того, как ЮKassa создала платёж, мы повторяем запрос с тем же ключом и получаем тот же платёж.
404 это не ошибка. Если платежа с таким id нет, getPayment возвращает undefined. Ниже станет понятно, зачем: так мы спокойно отбрасываем поддельные уведомления.
Шаг 3. Заказы и ссылка, которая не протухает
Модуль orders.ts отвечает за жизнь заказа. parseInvoice разбирает команду /invoice email сумма описание и переводит сумму в копейки. createOrder вставляет заказ с on conflict (id) do nothing и ставит задачу отправить счёт. payRedirect(id) обслуживает ссылку из письма: если заказ оплачен, ведёт на страницу «Спасибо»; если у заказа есть живой платёж, ведёт на него; если платёж успел пройти, а уведомление ещё не дошло, засчитывает оплату; иначе создаёт новый платёж с ключом <заказ>:<id прошлого платежа или first>. А markPaid засчитывает оплату, сначала сверив сумму платежа с суммой заказа:
// markPaid: единственное место, где заказ становится оплаченным
const [won] = await sql`
update orders set status = 'paid', paid_at = now(), paid_payment_id = ${payment.id}
where id = ${orderId} and status = 'pending'
returning id
`;
if (won) {
await enqueue({ kind: "order.fulfill", payload: { id: orderId }, key: `order.fulfill:${orderId}` });
} else if (order.paid_payment_id !== payment.id) {
await alert(`Заказ ${orderId} оплачен дважды: ${order.paid_payment_id} и ${payment.id}`);
}Здесь три идеи.
Ссылка в письме ведёт к нам, а не в ЮKassa. Если вставить в письмо прямую ссылку на платёж, через несколько дней она может перестать работать: неоплаченные платежи отменяются. Наша ссылка /pay/<id> при каждом нажатии находит живой платёж или создаёт новый. Поэтому напоминание через две недели работает так же, как первое письмо.
Ключ нового платежа зависит от предыдущего. <заказ>:first для первого, <заказ>:<id отменённого> для следующего. Если клиент нажал ссылку дважды подряд, оба запроса вычислят одинаковый ключ, и ЮKassa вернёт один и тот же платёж. А после отмены ключ другой, и создаётся новый.
markPaid единственное место, где заказ становится оплаченным. Перед этим проверяем, что сумма платежа совпадает с суммой заказа. Затем update ... where status = 'pending' returning id: Postgres гарантирует, что строку вернёт только первый запрос. Он и ставит задачу выдачи. Все последующие подтверждения того же платежа ничего не делают. А если заказ оплачен двумя разными платежами (клиент открыл две вкладки и заплатил в обеих), система не возвращает деньги сама, а пишет в чат тревог: возврат это решение человека.
Ссылка обслуживается новым маршрутом GET /pay/:id, который отвечает редиректом 303 туда, куда указал payRedirect. Если ЮKassa недоступна, клиент видит «попробуйте через минуту», а заказ остаётся в базе. Ничего не теряется.
Шаг 4. Уведомления без подписи
В части про ядро мы обещали вернуться к платёжкам, которые не подписывают уведомления. ЮKassa как раз такая: в уведомлении нет HMAC. Можно проверять IP-адреса отправителя по списку из документации, но есть способ надёжнее и проще: не верить уведомлению вообще. Из него мы берём только id платежа и спрашиваем ЮKassa, что с этим платежом на самом деле.
// src/sources.ts: уведомления ЮKassa не подписаны,
// поэтому берём из них только id платежа
yookassa: {
verify: () => true,
dedupKey: (body) => {
const note = JSON.parse(body);
return `${note.event}:${note.object.id}`;
},
route: (body) => [
{ kind: "payment.check", payload: { paymentId: JSON.parse(body).object.id } },
],
},Поддельное уведомление «платёж pay-123 оплачен» приведёт к запросу в ЮKassa, которая ответит 404 или честным статусом. Подделать ответ ЮKassa злоумышленник не может: запрос к ней идёт с нашим ключом по HTTPS.
В личном кабинете ЮKassa в разделе HTTP-уведомлений указываем адрес https://auto.example.com/hooks/yookassa и события payment.succeeded и payment.canceled.
Шаг 5. Почта
Для писем клиентам нужен сервис транзакционной почты. Отправлять письма с собственного сервера плохая идея: они почти наверняка попадут в спам. В примере Resend, но подойдёт любой сервис с HTTP API: Postmark, Unisender, Amazon SES. Меняется одна функция: sendEmail(to, subject, text) делает POST на /emails с ключом в заголовке authorization и адресом отправителя из настроек.
Домен отправителя нужно подтвердить в сервисе (записи SPF и DKIM в DNS), иначе письма будут уходить в спам.
Шаг 6. Обработчики
Вся логика оплат в отдельном файле billing.ts, а общий список обработчиков просто подключает его и передаёт сообщения, начинающиеся с /invoice, в invoiceCommand. Задачи такие:
order.invoiceотправляет клиенту письмо со ссылкой/pay/<id>;payment.checkспрашивает ЮKassa о платеже и, если онsucceeded, вызываетmarkPaidдля заказа изmetadata;order.fulfillвыдаёт доступ и ставит две задачи: письмо клиенту и «💰» в чат;invoices.remindкаждое утро выбирает неоплаченные заказы, которым исполнилось 3, 7 или 14 дней, и ставитorder.remindс ключомorder.remind:<заказ>:<номер>;order.remindпроверяет, что заказ ещё не оплачен, шлёт письмо, а после последнего напоминания пишет в чат «📞 Стоит позвонить»;payments.reconcileраз в час ставитpayment.checkдля неоплаченных заказов за 30 дней.
Выдача доступа целиком:
// Оплачено: выдаём доступ один раз, потом сообщаем клиенту и команде
"order.fulfill": async ({ id }) => {
await sql`
insert into accesses (order_id, token) values (${id}, ${crypto.randomUUID()})
on conflict (order_id) do nothing
`;
await enqueue({ kind: "order.access_email", payload: { id }, key: `order.access_email:${id}` });
await enqueue({ kind: "order.paid_notice", payload: { id }, key: `order.paid_notice:${id}` });
},На что обратить внимание:
- Счета выставляет только чат менеджеров. Команду из любого другого чата бот молча игнорирует. Кто состоит в чате, тот и может выставлять счета, поэтому следите за составом группы.
- id заказа берётся из id сообщения. Если задача
telegram.messageупадёт после создания заказа и повторится, второй заказ не появится. order.fulfillтолько выдаёт доступ и ставит две задачи. Письмо клиенту и сообщение в чат выполняются отдельно. Если почтовый сервис лежит, письмо будет повторяться, а сообщение в чат уже ушло, и доступ никто не выдаст повторно.- Напоминания не нужно отменять. Каждое проверяет статус заказа в момент отправки, а утренняя задача выбирает только неоплаченные заказы. Заплатил клиент, и цепочка остановилась сама.
- Ключ напоминания содержит его номер. Если утренняя задача выполнится дважды за день, второе письмо не уйдёт.
Шаг 7. Сверка и расписание
Уведомление может не дойти: ЮKassa его отправила, а у нас в этот момент шёл деплой, или оно потерялось по дороге. ЮKassa повторяет уведомления, но полагаться только на это нельзя. Раз в час payments.reconcile проходит по неоплаченным заказам последних 30 дней с платежом и проверяет каждый через тот же payment.check. В худшем случае клиент получит доступ через час, а не никогда.
В расписание из части про ядро добавляем две задачи:
export const schedules: Schedule[] = [
{ kind: "heartbeat", everyMinutes: 60 },
{ kind: "invoices.remind", dailyAt: "10:00" },
{ kind: "payments.reconcile", everyMinutes: 60 },
];Шаг 8. Тесты
Для оплат тесты особенно важны: проверяем, что многократное подтверждение одного платежа выдаёт доступ один раз, что двойная оплата вызывает тревогу, что платёж с чужой суммой не засчитывается, что ссылка переиспользует живой платёж и что напоминания уходят по графику и прекращаются после оплаты.
Заглушки внешних API вынесены в общий файл test/stubs.ts: заглушка ЮKassa, как настоящая, запоминает платежи по Idempotence-Key. Тесты вызывают обработчики напрямую: трижды payment.check для одного платежа дают одну задачу order.fulfill, два разных платежа для одного заказа дают тревогу, а для напоминаний тест «состаривает» заказ, меняя created_at.
В CI добавляем в шаг Tests адреса заглушек: YOOKASSA_API: http://localhost:3996/v3, EMAIL_API: http://localhost:3995 и тестовые значения остальных новых переменных.
Шаг 9. Запуск
- Допишите на сервере в
.env:
YOOKASSA_SHOP_ID=123456
YOOKASSA_SECRET_KEY=test_... # сначала ключ тестового магазина
PUBLIC_URL=https://auto.example.com
ACCESS_URL=https://example.com/access/
EMAIL_API_KEY=re_...
EMAIL_FROM=Студия <hello@example.com>- Запушьте код: миграция
004_orders.sqlприменится при деплое. - В ЮKassa укажите адрес уведомлений
https://auto.example.com/hooks/yookassa. - Выставьте себе счёт на 1 рубль, оплатите тестовой картой и проверьте, что пришло письмо с доступом и сообщение в чат.
- Переключите ключи на боевой магазин.
Проверка
Прогон через HTTP с заглушками ЮKassa, почты и Telegram:
| Что сделали | Что произошло |
|---|---|
/invoice anna@example.com 15000 Лендинг для студии в чате менеджеров |
заказ создан, в чат «Счёт на 15 000 ₽ отправлен», клиенту письмо со ссылкой |
Открыли /pay/<id> |
создан платёж в ЮKassa с суммой «15000.00», редирект на страницу оплаты |
| Открыли ссылку ещё раз | редирект на тот же платёж, новый не создан |
Открыли /pay/nope |
404 |
Пришло уведомление payment.succeeded, а затем его повтор |
первое обработано, второе отсечено как дубль |
Пришло поддельное уведомление про платёж forged |
ЮKassa ответила 404, заказ не тронут |
| После оплаты | заказ paid, одно письмо с доступом, одно «💰 Оплачено 15 000 ₽» в чате |
| Открыли ссылку после оплаты | редирект на страницу «Спасибо» |
| Тесты: три подтверждения одного платежа | одна выдача доступа |
| Тесты: заказ оплачен двумя платежами | тревога «оплачен дважды, верните один» |
| Тесты: платёж на 1 рубль вместо 15 000 | заказ остаётся неоплаченным, тревога |
| Тесты: 1, 3, 3, 8 день, оплата, 15 день | напоминания на 3 и 8 день, после оплаты ни одного |
Что получилось
- Счёт выставляется одной командой, клиент получает письмо за секунды.
- Ссылка на оплату работает всегда, даже из напоминания через две недели.
- Оплата засчитывается ровно один раз, сколько бы подтверждений ни пришло, и только с правильной суммой.
- Подделать оплату уведомлением нельзя: мы проверяем каждую в ЮKassa.
- Потерянное уведомление подхватит часовая сверка.
- Должникам уходят напоминания, а когда пора звонить, в чат приходит сообщение.
Дальше «Заменяем поддержку»: разбираем входящие письма и сообщения, отвечаем на типовые вопросы черновиками по базе знаний и оставляем человеку последнее слово.