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 });
+ }
+});