diff --git a/src/components/logistics/SmsProblemNotice.jsx b/src/components/logistics/SmsProblemNotice.jsx index 251f75c..0b67f77 100644 --- a/src/components/logistics/SmsProblemNotice.jsx +++ b/src/components/logistics/SmsProblemNotice.jsx @@ -1,24 +1,65 @@ /** * @file SmsProblemNotice.jsx - * @description Объясняет причину, по которой SMS не уходят. + * @description Объясняет, что происходит с отправкой SMS — над таблицей наборов. * - * В таблице наборов видно только «SMS не отправлено», а причина лежит в логе - * отправок цифрой (например, 201 — на счёте SMS-сервиса закончились средства). - * Блок читает последние неудачные отправки, складывает их по причинам - * (summarizeSmsFailures) и показывает причину словами над таблицей. + * В таблице видно только «SMS не отправлено», а причина лежит в логе отправок + * цифрой (например, 201 — на счёте SMS-сервиса закончились средства). Блок + * читает последние неудачные отправки, спрашивает баланс счёта через функцию + * sms-balance и показывает вывод словами: + * • средств нет — красное предупреждение с причиной; + * • счёт пополнен — короткая строка «SMS снова уходят» (гаснет сама через неделю); + * • денег мало — предупреждение заранее, до остановки отправки. * - * Если неудачных отправок нет — блок не показывается вообще. + * Крестик закрывает блок: решение запоминается в браузере по «отпечатку» + * состояния, поэтому та же самая проблема больше не всплывает, а новая — показывается. */ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { supabase } from "../../supabaseClient"; import { summarizeSmsFailures } from "../../services/smsProblemSummary"; import { SmsProblemNoticeView } from "./SmsProblemNoticeView"; const LOOKBACK_DAYS = 30; const FAILED_STATUSES = ["send_failed", "error", "limit_exceeded"]; +const BALANCE_LOW_RUB = 100; +const DISMISS_STORAGE_KEY = "supersam_sms_problem_dismissed"; + +const readDismissed = () => { + try { + return window.localStorage.getItem(DISMISS_STORAGE_KEY) || ""; + } catch { + return ""; + } +}; + +const writeDismissed = (signature) => { + try { + window.localStorage.setItem(DISMISS_STORAGE_KEY, signature); + } catch { + // Приватный режим — просто не запоминаем. + } +}; + +/** Баланс счёта SMS-сервиса: ключ живёт только на сервере, поэтому спрашиваем функцию. */ +const fetchBalance = async () => { + const url = import.meta.env.VITE_SUPABASE_URL || ""; + const anonKey = import.meta.env.VITE_SUPABASE_ANON_KEY || ""; + if (!url) return null; + try { + const resp = await fetch(`${url}/functions/v1/sms-balance`, { + method: "POST", + headers: { "Content-Type": "application/json", ...(anonKey ? { apikey: anonKey } : {}) }, + body: "{}", + }); + const payload = await resp.json(); + return payload?.ok ? { balance: payload.balance, lowBalance: payload.threshold ?? BALANCE_LOW_RUB } : null; + } catch { + return null; + } +}; export function SmsProblemNotice({ orderGroupIds = [], className = "" }) { const [summary, setSummary] = useState(null); + const [dismissed, setDismissed] = useState(readDismissed); const visibleKey = useMemo(() => orderGroupIds.join(","), [orderGroupIds]); useEffect(() => { @@ -26,15 +67,22 @@ export function SmsProblemNotice({ orderGroupIds = [], className = "" }) { (async () => { try { const since = new Date(Date.now() - LOOKBACK_DAYS * 86400000).toISOString(); - const { data, error } = await supabase - .from("sms_campaign_log") - .select("order_group_id, campaign_type, status, sms_code, error_message, created_at") - .in("status", FAILED_STATUSES) - .gte("created_at", since) - .order("created_at", { ascending: false }) - .limit(1000); + const [{ data, error }, balanceInfo] = await Promise.all([ + supabase + .from("sms_campaign_log") + .select("order_group_id, campaign_type, status, sms_code, error_message, created_at") + .in("status", FAILED_STATUSES) + .gte("created_at", since) + .order("created_at", { ascending: false }) + .limit(1000), + fetchBalance(), + ]); if (error) throw error; - if (alive) setSummary(summarizeSmsFailures(data || [], orderGroupIds)); + if (!alive) return; + setSummary(summarizeSmsFailures(data || [], orderGroupIds, { + balance: balanceInfo?.balance ?? null, + lowBalance: balanceInfo?.lowBalance ?? BALANCE_LOW_RUB, + })); } catch { // Лог может быть недоступен для роли — тогда просто ничего не показываем. if (alive) setSummary(null); @@ -45,5 +93,13 @@ export function SmsProblemNotice({ orderGroupIds = [], className = "" }) { }; }, [visibleKey]); // eslint-disable-line react-hooks/exhaustive-deps - return ; + const handleClose = useCallback(() => { + if (!summary) return; + writeDismissed(summary.signature); + setDismissed(summary.signature); + }, [summary]); + + const visible = summary && summary.signature !== dismissed ? summary : null; + + return ; } diff --git a/src/components/logistics/SmsProblemNotice.test.jsx b/src/components/logistics/SmsProblemNotice.test.jsx index 691bbf8..4fc5045 100644 --- a/src/components/logistics/SmsProblemNotice.test.jsx +++ b/src/components/logistics/SmsProblemNotice.test.jsx @@ -1,14 +1,17 @@ /** * @file SmsProblemNotice.test.jsx - * @description Блок над таблицей: причина «закончились средства» написана словами, - * видно период и число заказов; без причины блок не рисуется. + * @description Блок про SMS: причина словами, «всё наладилось», предупреждение о балансе + * и крестик, которым блок можно закрыть. */ import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { SmsProblemNoticeView } from "./SmsProblemNoticeView"; -const fundsSummary = { +/** В русской локали разделитель разрядов — неразрывный пробел. */ +const plain = (html) => String(html).replace(/[\u00a0\u202f]/g, " "); + +const base = { reason: "на счёте SMS-сервиса закончились средства", messages: 42, topMessages: 42, @@ -18,11 +21,14 @@ const fundsSummary = { hasFundsReason: true, firstAt: "2026-09-10T10:06:11Z", lastAt: "2026-09-13T06:03:03Z", + balance: 12717.45, + balanceKnown: true, + lowBalanceThreshold: 100, }; describe("SmsProblemNoticeView", () => { - it("пишет, что закончились средства, и что делать", () => { - const html = renderToStaticMarkup(); + it("при нехватке средств пишет причину и что делать", () => { + const html = renderToStaticMarkup(); expect(html).toContain("SMS не отправлено: на счёте SMS-сервиса закончились средства"); expect(html).toContain("8 заказов"); @@ -31,28 +37,41 @@ describe("SmsProblemNoticeView", () => { expect(html).toContain("Пополните счёт SMS-сервиса"); }); + it("после пополнения счёта красное предупреждение сменяется спокойным «снова уходят»", () => { + const html = renderToStaticMarkup(); + + expect(html).toContain("SMS снова уходят"); + expect(plain(html)).toContain("на счёте есть деньги (12 717,45 ₽)"); + expect(html).not.toContain("Пополните счёт"); + expect(html).toContain("Эти сообщения не ушли с 10.09 по 13.09 из-за нехватки средств"); + expect(html).not.toContain("В таблице ниже"); + }); + + it("предупреждает о низком балансе, когда ошибок ещё нет", () => { + const html = renderToStaticMarkup( + , + ); + + expect(plain(html)).toContain("На счёте SMS-сервиса мало денег: 55 ₽"); + expect(html).toContain("пополните счёт заранее"); + }); + it("для другой причины подсказка другая", () => { const html = renderToStaticMarkup( - , + , ); expect(html).toContain("SMS не отправлено: неверный номер получателя"); expect(html).toContain("1 заказ"); expect(html).toContain("разделе «SMS-рассылки»"); - expect(html).not.toContain("Пополните счёт"); }); - it("одно сообщение — «сообщение», одно число — «заказ»", () => { - const html = renderToStaticMarkup( - , - ); - expect(html).toContain("1 заказ"); - expect(html).toContain("1 сообщение"); - expect(html).toContain("за 10.09"); + it("крестик есть, когда блок можно закрыть", () => { + const withClose = renderToStaticMarkup(); + const withoutClose = renderToStaticMarkup(); + + expect(withClose).toContain("Скрыть сообщение"); + expect(withoutClose).not.toContain("Скрыть сообщение"); }); it("без сводки ничего не рисуется", () => { diff --git a/src/components/logistics/SmsProblemNoticeView.jsx b/src/components/logistics/SmsProblemNoticeView.jsx index a92d90b..e39679a 100644 --- a/src/components/logistics/SmsProblemNoticeView.jsx +++ b/src/components/logistics/SmsProblemNoticeView.jsx @@ -1,9 +1,10 @@ /** * @file SmsProblemNoticeView.jsx - * @description Разметка блока «почему SMS не уходят». + * @description Разметка блока про SMS: причина, «всё наладилось» и предупреждение о низком балансе. * - * Отдельно от загрузки данных: сам блок только рисует то, что посчитал - * summarizeSmsFailures. Так разметку можно проверить без сети и Supabase. + * Отдельно от загрузки данных: блок рисует то, что посчитал summarizeSmsFailures. + * Так разметку можно проверить без сети и Supabase. Крестик закрывает блок — + * состояние запоминает сам блок данных (SmsProblemNotice). */ import React from "react"; import { plural } from "../../services/smsProblemSummary"; @@ -17,15 +18,48 @@ const fmtDay = (iso) => { } }; -export const SmsProblemNoticeView = ({ summary, className = "" }) => { +const fmtMoney = (value) => { + const n = Number(value); + if (!Number.isFinite(n)) return ""; + return `${n.toLocaleString("ru-RU", { maximumFractionDigits: 2 })} ₽`; +}; + +const TONES = { + funds: { + icon: "⛔", + box: "border-[var(--color-danger)] bg-[rgba(239,68,68,0.08)]", + chip: "bg-[rgba(239,68,68,0.14)] text-[var(--color-danger)]", + text: "text-[var(--color-danger)]", + }, + low_balance: { + icon: "⚠️", + box: "border-[var(--color-warning)] bg-[var(--color-warning-soft)]", + chip: "bg-[var(--color-warning-soft)] text-[var(--color-warning)]", + text: "text-[var(--color-warning)]", + }, + other: { + icon: "⚠️", + box: "border-[var(--color-warning)] bg-[var(--color-warning-soft)]", + chip: "bg-[var(--color-warning-soft)] text-[var(--color-warning)]", + text: "text-[var(--color-warning)]", + }, + resolved: { + icon: "✅", + box: "border-[var(--color-border)] bg-[var(--color-surface-strong)]", + chip: "bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]", + text: "text-[var(--color-text-muted)]", + }, +}; + +export const SmsProblemNoticeView = ({ summary, onClose, className = "" }) => { if (!summary) return null; + const tone = TONES[summary.kind] || TONES.other; const period = summary.firstAt ? summary.firstAt === summary.lastAt ? `за ${fmtDay(summary.firstAt)}` : `с ${fmtDay(summary.firstAt)} по ${fmtDay(summary.lastAt)}` : ""; - const groupsText = `${summary.groups} ${plural(summary.groups, "заказ", "заказа", "заказов")}`; const messagesText = `${summary.messages} ${plural(summary.messages, "сообщение", "сообщения", "сообщений")}`; const tableText = @@ -35,43 +69,60 @@ export const SmsProblemNoticeView = ({ summary, className = "" }) => { ? "В текущей выборке таблицы таких заказов нет." : ""; + const headline = + summary.kind === "funds" + ? `SMS не отправлено: ${summary.reason}` + : summary.kind === "low_balance" + ? `На счёте SMS-сервиса мало денег: ${fmtMoney(summary.balance)}` + : summary.kind === "resolved" + ? `SMS снова уходят: на счёте есть деньги (${fmtMoney(summary.balance)})` + : `SMS не отправлено: ${summary.reason}`; + + const hint = + summary.kind === "funds" + ? "Пополните счёт SMS-сервиса — отправка возобновится автоматически, повторять вручную ничего не нужно." + : summary.kind === "low_balance" + ? `Осталось меньше ${fmtMoney(summary.lowBalanceThreshold)} — пополните счёт заранее, иначе отправка остановится.` + : summary.kind === "resolved" + ? `Эти сообщения не ушли ${period} из-за нехватки средств — они остались в разделе «SMS-рассылки». Новые отправки идут нормально, делать ничего не нужно.` + : "Проверьте настройки отправки SMS в разделе «SMS-рассылки»."; + + // Для «всё наладилось» не подмешиваем счётчик из таблицы — он там уже не важен. + const showTableText = summary.kind !== "resolved"; + return ( -
-
- {summary.hasFundsReason ? "⛔" : "⚠️"} - - SMS не отправлено: {summary.reason} - - - {groupsText} - +
+
+ {tone.icon} + {headline} + {summary.kind !== "low_balance" && ( + + {groupsText} + + )} + {onClose && ( + + )}
-
- {messagesText} - {period ? ` · ${period}` : ""} - {tableText ? ` · ${tableText}` : ""} -
+ {summary.kind !== "low_balance" && ( +
+ {messagesText} + {period ? ` · ${period}` : ""} + {showTableText && tableText ? ` · ${tableText}` : ""} +
+ )} -
- {summary.hasFundsReason - ? "Пополните счёт SMS-сервиса — отправка возобновится автоматически, повторять вручную ничего не нужно." - : "Проверьте настройки отправки SMS в разделе «SMS-рассылки»."} +
+ {hint}
); diff --git a/src/services/smsProblemSummary.js b/src/services/smsProblemSummary.js index d66f8a6..c4a7fc9 100644 --- a/src/services/smsProblemSummary.js +++ b/src/services/smsProblemSummary.js @@ -3,19 +3,25 @@ * @description Складывает неудачные отправки SMS в одну понятную причину. * * В таблице видно только «SMS не отправлено», а причина в логе — код сервиса. - * Здесь считаем: сколько сообщений, сколько заказов, за какой период и что - * именно случилось чаще всего (например, что на счёте закончились средства). + * Здесь считаем: сколько сообщений, сколько заказов, за какой период, что + * случилось чаще всего — и главное, актуальна ли проблема до сих пор (по балансу + * счёта SMS-сервиса). Пополнили счёт — предупреждение гасим, а не держим месяцами. */ import { getSmsFailureReason } from "./orderGroupViews"; +/** Через сколько дней после последней ошибки показываем, что всё наладилось. */ +export const RESOLVED_VISIBLE_DAYS = 7; + /** * @param {Array} rows строки sms_campaign_log со статусами ошибок * @param {Array} visibleIds id заказов, которые сейчас видны в таблице - * @returns {null|{reason, messages, groups, affectedInTable, hasFundsReason, firstAt, lastAt}} + * @param {{balance?: number|null, lowBalance?: number}} options баланс счёта SMS-сервиса + * @returns {null|object} null — говорить не о чем */ -export const summarizeSmsFailures = (rows = [], visibleIds = []) => { +export const summarizeSmsFailures = (rows = [], visibleIds = [], options = {}) => { + const { balance = null, lowBalance = 100 } = options; + const balanceKnown = Number.isFinite(balance); const list = Array.isArray(rows) ? rows.filter(Boolean) : []; - if (!list.length) return null; const reasons = new Map(); const lastByGroup = new Map(); @@ -35,19 +41,53 @@ export const summarizeSmsFailures = (rows = [], visibleIds = []) => { } } - const [reason, topMessages] = [...reasons.entries()].sort((a, b) => b[1] - a[1])[0]; + const [reason, topMessages] = list.length + ? [...reasons.entries()].sort((a, b) => b[1] - a[1])[0] + : [null, 0]; + + const hasFundsReason = !!reason && /закончились средства/.test(reason); const visible = new Set(visibleIds); - return { + // Базовая сводка по логу (нужна в любом состоянии). + const base = { reason, - messages: list.length, topMessages, + messages: list.length, groups: lastByGroup.size, affectedInTable: [...lastByGroup.keys()].filter((id) => visible.has(id)).length, hasVisibleRows: visible.size > 0, - hasFundsReason: /закончились средства/.test(reason), + hasFundsReason, firstAt, lastAt, + balance, + balanceKnown, + lowBalanceThreshold: lowBalance, + }; + + const within = (days) => { + if (!lastAt) return false; + return Date.now() - new Date(lastAt).getTime() <= days * 86400000; + }; + + let kind = null; + if (!list.length) { + // Ошибок нет, но денег на счёте мало — предупреждаем заранее. + kind = balanceKnown && balance < lowBalance ? "low_balance" : null; + } else if (hasFundsReason && balanceKnown && balance >= lowBalance) { + // Деньги пополнены — проблема снята, показываем это недолго и без нагнетания. + kind = within(RESOLVED_VISIBLE_DAYS) ? "resolved" : null; + } else if (hasFundsReason) { + kind = "funds"; + } else { + kind = "other"; + } + + if (!kind) return null; + + return { + ...base, + kind, + signature: [kind, reason || "", lastAt || "", balanceKnown ? Math.round(balance) : ""].join("|"), }; }; diff --git a/src/services/smsProblemSummary.test.js b/src/services/smsProblemSummary.test.js index fcda378..d6d9f11 100644 --- a/src/services/smsProblemSummary.test.js +++ b/src/services/smsProblemSummary.test.js @@ -1,7 +1,7 @@ /** * @file smsProblemSummary.test.js - * @description Причина неудачных SMS для блока над таблицей: считаем по логу, - * чаще всего встречающуюся причину пишем словами, пустой лог — ничего не показываем. + * @description Что показывать над таблицей: причина недоставки, «всё наладилось», + * предупреждение о низком балансе — и когда не показывать ничего. */ import { describe, it, expect } from "vitest"; import { summarizeSmsFailures } from "./smsProblemSummary"; @@ -16,7 +16,9 @@ const row = (over = {}) => ({ ...over, }); -describe("summarizeSmsFailures", () => { +const daysAgo = (days) => new Date(Date.now() - days * 86400000).toISOString(); + +describe("summarizeSmsFailures — причина недоставки", () => { it("объясняет нехватку средств и считает заказы с сообщениями", () => { const summary = summarizeSmsFailures([ row({ order_group_id: "g1", created_at: "2026-09-10T10:06:11Z" }), @@ -24,19 +26,11 @@ describe("summarizeSmsFailures", () => { row({ order_group_id: "g2", created_at: "2026-09-13T06:03:04Z" }), ], ["g1", "g2"]); + expect(summary.kind).toBe("funds"); expect(summary.reason).toBe("на счёте SMS-сервиса закончились средства"); - expect(summary.hasFundsReason).toBe(true); expect(summary.groups).toBe(2); expect(summary.messages).toBe(3); expect(summary.affectedInTable).toBe(2); - expect(summary.firstAt).toBe("2026-09-10T10:06:11Z"); - expect(summary.lastAt).toBe("2026-09-13T06:03:03Z".replace("06:03:03", "06:03:04")); - }); - - it("не считает заказ в таблице, если его там нет", () => { - const summary = summarizeSmsFailures([row({ order_group_id: "g9" })], ["g1"]); - expect(summary.affectedInTable).toBe(0); - expect(summary.hasVisibleRows).toBe(true); }); it("берёт самую частую причину, когда их несколько", () => { @@ -50,15 +44,60 @@ describe("summarizeSmsFailures", () => { expect(summary.topMessages).toBe(2); }); - it("пустой лог — нет повода что-то показывать", () => { - expect(summarizeSmsFailures([], ["g1"])).toBeNull(); - expect(summarizeSmsFailures(null, [])).toBeNull(); - }); - - it("без id заказов причина всё равно объясняется", () => { - const summary = summarizeSmsFailures([row()], []); - expect(summary.hasFundsReason).toBe(true); - expect(summary.hasVisibleRows).toBe(false); - expect(summary.affectedInTable).toBe(0); + it("другая причина — отдельный вид блока", () => { + const summary = summarizeSmsFailures([row({ sms_code: "202", error_message: "202" })], []); + expect(summary.kind).toBe("other"); + }); +}); + +describe("summarizeSmsFailures — актуальность по балансу", () => { + it("счёт пополнен: вместо красного предупреждения — «всё наладилось»", () => { + const summary = summarizeSmsFailures([row({ created_at: daysAgo(1) })], [], { balance: 12717.45 }); + expect(summary.kind).toBe("resolved"); + expect(summary.balance).toBe(12717.45); + }); + + it("денег на счёте нет: проблема актуальна", () => { + const summary = summarizeSmsFailures([row({ created_at: daysAgo(1) })], [], { balance: 42 }); + expect(summary.kind).toBe("funds"); + }); + + it("баланс узнать не удалось — считаем проблему актуальной", () => { + const summary = summarizeSmsFailures([row({ created_at: daysAgo(1) })], [], { balance: null }); + expect(summary.kind).toBe("funds"); + }); + + it("старая история не висит месяцами: неделя прошла — не показываем", () => { + const summary = summarizeSmsFailures([row({ created_at: daysAgo(8) })], [], { balance: 12717.45 }); + expect(summary).toBeNull(); + }); + + it("ошибок нет, но денег мало — предупреждаем заранее", () => { + const summary = summarizeSmsFailures([], [], { balance: 55 }); + expect(summary.kind).toBe("low_balance"); + expect(summary.balance).toBe(55); + expect(summary.reason).toBeNull(); + }); + + it("ошибок нет и денег достаточно — блок не нужен", () => { + expect(summarizeSmsFailures([], [], { balance: 12717.45 })).toBeNull(); + expect(summarizeSmsFailures(null, [], { balance: null })).toBeNull(); + }); +}); + +describe("отпечаток состояния для закрытия блока", () => { + it("меняется, когда проблема другая — закрытый блок вернётся с новой ошибкой", () => { + const first = summarizeSmsFailures([row({ created_at: daysAgo(1) })], [], { balance: 42 }); + const second = summarizeSmsFailures([row({ created_at: daysAgo(2) })], [], { balance: 42 }); + const third = summarizeSmsFailures([row({ created_at: daysAgo(1), sms_code: "202", error_message: "202" })], [], { balance: 42 }); + + expect(first.signature).not.toBe(second.signature); + expect(first.signature).not.toBe(third.signature); + }); + + it("одинаковые данные — одинаковый отпечаток (повторно не всплывает)", () => { + const a = summarizeSmsFailures([row({ created_at: daysAgo(1) })], [], { balance: 42 }); + const b = summarizeSmsFailures([row({ created_at: daysAgo(1) })], [], { balance: 42 }); + expect(a.signature).toBe(b.signature); }); }); diff --git a/supabase/functions/sms-balance/index.ts b/supabase/functions/sms-balance/index.ts new file mode 100644 index 0000000..80d6963 --- /dev/null +++ b/supabase/functions/sms-balance/index.ts @@ -0,0 +1,72 @@ +/** Баланс счёта SMS-сервиса — для предупреждения в интерфейсе. + * + * Зачем: 10–13 сентября рассылки падали с кодом 201 («не хватает средств»), + * и об этом никто не знал. Ключ доступа к SMS живёт только на сервере, поэтому + * баланс отдаём отдельной функцией, а не читаем ключ в браузере. + */ +import { createClient } from "npm:@supabase/supabase-js@2"; + +const ALLOWED_ORIGINS = [ + "https://dost.supersamsev.ru", + "https://supa.supersamsev.ru", + "https://dev.mkn8n.ru", + "http://localhost:5173", +]; + +const BALANCE_URL = "https://sms.ru/my/balance"; +// Ниже этого остатка предупреждаем заранее, до остановки отправки. +const LOW_BALANCE_RUB = 100; + +const cors = (origin: string) => ({ + "Access-Control-Allow-Origin": ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0], + "Access-Control-Allow-Methods": "POST,GET,OPTIONS", + "Access-Control-Allow-Headers": "Content-Type,Authorization,apikey", +}); + +Deno.serve(async (req: Request) => { + const origin = req.headers.get("origin") || ""; + const headers = { ...cors(origin), "Content-Type": "application/json" }; + + if (req.method === "OPTIONS") return new Response(null, { headers }); + + try { + const supabaseUrl = Deno.env.get("SUPABASE_URL") || ""; + const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") || ""; + const sb = createClient(supabaseUrl, serviceKey); + + const { data: settings } = await sb + .from("sms_campaign_settings") + .select("sms_api_id") + .eq("campaign_type", "first_sms") + .single(); + + const apiId = settings?.sms_api_id || Deno.env.get("SMS_API_ID") || ""; + if (!apiId) { + return new Response(JSON.stringify({ ok: false, error: "api_id not configured" }), { status: 500, headers }); + } + + const resp = await fetch(`${BALANCE_URL}?api_id=${encodeURIComponent(apiId)}&json=1`); + const text = await resp.text(); + + let payload: Record = {}; + try { + payload = JSON.parse(text); + } catch { + return new Response(JSON.stringify({ ok: false, error: "unexpected response", raw: text.slice(0, 200) }), { status: 200, headers }); + } + + if (payload.status !== "OK") { + return new Response(JSON.stringify({ ok: false, code: payload.status_code ?? null, raw: text.slice(0, 200) }), { status: 200, headers }); + } + + const balance = Number(payload.balance); + return new Response(JSON.stringify({ + ok: true, + balance, + low: balance < LOW_BALANCE_RUB, + threshold: LOW_BALANCE_RUB, + }), { headers }); + } catch (e) { + return new Response(JSON.stringify({ ok: false, error: String(e) }), { status: 500, headers }); + } +});