balkhaev.com Блог · AI-агенты и автоматизации

Коммерческие автоматизациичасть 5 из 8

Заменяем бухгалтера: оплаты, доступы и напоминания о долгах

Счёт одной командой в Telegram, оплата через ЮKassa, ссылка на оплату, которая не протухает, доступ ровно один раз, сверка платежей и вежливые напоминания должникам, которые останавливаются сами.

обновлено 12 мин чтенияautomationpaymentsyookassaidempotency

Коммерческие автоматизациичасть 5 из 8
  1. 01Концепция: какие автоматизации окупаются
  2. 02Фундамент: сервер, база и деплой по пушу
  3. 03Сердце системы: события, очередь и повторы
  4. 04Заменяем продажника: заявка, квалификация и менеджер за минуту
  5. 05Заменяем бухгалтера: оплаты, доступы и напоминания о долгах
  6. 06Заменяем поддержку: разбор входящих и черновики ответов
  7. 07Заменяем аналитика: утренняя сводка и тревоги
  8. 08Эксплуатация: мониторинг, бэкапы, секреты и стоимость

Это пятая часть цикла про коммерческие автоматизации и вторая из серии «Заменяем …». В прошлой части мы забрали у продажника разбор заявок. Теперь сделка состоялась, и начинается работа с деньгами. В статье только ключевые фрагменты кода, полный код серии с тестами лежит в репозитории.

Что именно мы заменяем

Речь не о бухгалтерии в смысле налогов и отчётности, а о рутине вокруг оплат, которую в небольшой компании обычно делает бухгалтер, офис-менеджер или сам продажник:

Раньше делал человек Теперь делает система
выставить счёт и отправить клиенту одна команда в чате, письмо уходит само
проверить в личном кабинете, пришли ли деньги уведомление от ЮKassa и сверка раз в час
выдать доступ, отправить письмо «оплата получена» сразу после оплаты, ночью и в выходные тоже
сообщить команде «💰 Оплачено 15 000 ₽» в чате
вспомнить про неоплаченные счета и написать клиенту напоминания на 3, 7 и 14 день
понять, кому пора позвонить сообщение в чат после последнего напоминания

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

Почему с деньгами нужно иначе

В части про продажи мы смирились с редким дублем: если CRM создала лид, а воркер упал, появится второй лид, и менеджер его объединит. С деньгами так нельзя. Двойная выдача доступа это потеря, двойное письмо «оплата получена» без оплаты это скандал, а неучтённая оплата это клиент, который заплатил и ничего не получил.

Поэтому здесь три правила:

  1. Источник правды о платеже это платёжная система, а не уведомление. Уведомление только повод спросить её.
  2. Заказ становится оплаченным в одном месте и одним условным SQL-запросом. Сколько бы раз и откуда ни пришло подтверждение, переход из «ждёт» в «оплачен» случится один раз.
  3. Каждое внешнее действие это отдельная задача с ключом. Выдать доступ, написать клиенту, сообщить команде. Если одно упало, остальные не повторяются.

Как это устроено

клиент нажал ссылку только первый раз /invoice в чате Заказ (pending)письмо со ссылкой /pay/<id> GET /pay/<id>живой платёж ЮKassa есть — редирект на негонет или отменён — создать новый (Idempotence-Key) Клиент платит в ЮKassa Уведомление ЮKassa Сверка раз в час Возврат клиента на /pay payment.checkGET /payments/<id> markPaidupdate … where status = 'pending' order.fulfill Письмо с доступом «💰» в чат

Шаг 1. Таблицы

migrations/004_orders.sqlsql
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. Запуск

  1. Допишите на сервере в .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>
  1. Запушьте код: миграция 004_orders.sql применится при деплое.
  2. В ЮKassa укажите адрес уведомлений https://auto.example.com/hooks/yookassa.
  3. Выставьте себе счёт на 1 рубль, оплатите тестовой картой и проверьте, что пришло письмо с доступом и сообщение в чат.
  4. Переключите ключи на боевой магазин.

Проверка

Прогон через 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.
  • Потерянное уведомление подхватит часовая сверка.
  • Должникам уходят напоминания, а когда пора звонить, в чат приходит сообщение.

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