Платежи

Провайдерские клиенты (YooKassa, Tinkoff) и построение чеков 54-ФЗ живут в отдельном, секретонезависимом пакете @launchkit/payments — стартер не дублирует эту логику, а только оркестрирует вызовы (src/lib/payments/).

Выбор провайдера

# PAYMENT_PROVIDER: mock | yookassa | tinkoff
PAYMENT_PROVIDER="mock"
YOOKASSA_SHOP_ID=""
YOOKASSA_SECRET_KEY=""
TINKOFF_TERMINAL_KEY=""
TINKOFF_PASSWORD=""

По умолчанию — mock: полный цикл checkout работает без каких-либо ключей, что удобно для локальной разработки и демо. src/lib/payments/config.ts резолвит провайдера из env и падает с понятной ошибкой, если выбран yookassa/tinkoff, а ключи не заданы:

export function resolvePaymentProvider(env: Env): ResolvedPaymentProvider {
  switch (env.PAYMENT_PROVIDER) {
    case 'yookassa':
      if (!env.YOOKASSA_SHOP_ID || !env.YOOKASSA_SECRET_KEY) {
        throw new Error('PAYMENT_PROVIDER=yookassa requires YOOKASSA_SHOP_ID and YOOKASSA_SECRET_KEY');
      }
      // ...

Как получить ключи

YooKassayookassa.ru/joinups: после подключения магазина в личном кабинете доступны shopId и secretKey (Настройки → API).

Tinkoff Кассаtinkoff.ru/kassa: после подписания договора в личном кабинете эквайринга выдаются TerminalKey и Password.

Оба провайдера имеют тестовый (sandbox) режим — используйте его, пока не готовы принимать реальные платежи.

Чек 54-ФЗ

Продукт работает по налоговому режиму ИП на УСН, 54-ФЗ (онлайн-касса). Основной канал фискализации — PSP-embedded: и YooKassa, и Tinkoff выпускают фискальный чек как часть самого вызова оплаты/возврата, отдельная касса/ОФД не нужны.

// src/lib/payments/receipt.ts
export function buildLicenseReceipt(order: LicenseOrder): ReceiptRequest {
  return buildReceipt(
    {
      items: [{
        description: `Лицензия LaunchKit — тариф «${order.plan}»`,
        quantity: 1,
        amountKopecks: order.amountKopecks,
        vatCode: 'none',          // без НДС — ИП на УСН
        subject: 'service',
      }],
      totalAmountKopecks: order.amountKopecks,
      customer: { email: order.customerEmail },
      taxMode: 'ip_usn_54fz',
      fiscalRail: 'psp_embedded',
    },
    'full_payment',
    'income',
  );
}

vatCode и subject (признак предмета расчёта) в этом примере — значения, подтверждённые бухгалтером для лицензии на код (услуга/результат интеллектуальной деятельности). Если у вас другой вид товара/услуги — сверьте эти поля со своим бухгалтером, это не техническое решение.

Все денежные суммы передаются в копейках (amountKopecks) — так исключается класс ошибок округления с плавающей точкой на денежных суммах.

Создание платежа (checkout)

// src/lib/payments/checkout.ts
export async function createLicenseCheckout(params: CreateCheckoutParams) {
  const receipt = buildLicenseReceipt(params.order);
  return params.provider.create(params.creds, {
    orderId: params.orderId,
    amountKopecks: params.order.amountKopecks,
    currency: 'RUB',
    description: `Оплата тарифа «${params.order.plan}»`,
    returnUrl: params.returnUrl,
    idempotenceKey: params.idempotenceKey,
    receipt,
    notificationUrl: params.notificationUrl,  // нужен только Tinkoff — YooKassa настраивает webhook в кабинете
  });
}

Роут POST /api/checkout резолвит провайдера, вызывает createLicenseCheckout и возвращает confirmationUrl — на неё редиректится покупатель для оплаты. idempotenceKey обязателен для обоих провайдеров: он не даёт создать два платежа при повторном сабмите формы или при ретрае после сетевой ошибки.

Возврат (возврат прихода)

По 54-ФЗ возврат денег требует выпуска отдельного фискального документа «возврат прихода», а не просто отмены оплаты у провайдера. buildLicenseRefundReceipt строит такой чек, и он передаётся вместе с вызовом refund:

export async function refundLicense(params: RefundParams) {
  const receipt = buildLicenseRefundReceipt(params.order, params.refundAmountKopecks);
  return params.provider.refund(params.creds, {
    providerPaymentId: params.providerPaymentId,
    amountKopecks: params.refundAmountKopecks ?? params.order.amountKopecks,
    idempotenceKey: params.idempotenceKey,
    receipt,
  });
}

На основном (psp_embedded) рельсе провайдер выпускает возврат-прихода атомарно вместе с самим возвратом денег в одном вызове API — отдельного похода в кассу/ОФД не требуется. Поддерживается частичный возврат: refundAmountKopecks можно указать меньше полной суммы заказа.

Тестовый провайдер (mock)

createMockPaymentProvider() реализует тот же интерфейс PaymentProvider, что и боевые клиенты, но без сетевых вызовов — платёж мгновенно переходит в succeeded. Используйте его в тестах и для локальной разработки без реальных ключей; переключение на боевой провайдер — это только смена PAYMENT_PROVIDER в .env, код checkout/refund не меняется.