feat(orders): блок про SMS смотрит на баланс счёта — гасится после пополнения, закрывается крестиком, предупреждает о низком балансе (эдж-функция sms-balance)

This commit is contained in:
Hermes 2026-09-14 15:58:30 +02:00
parent dc262d2be7
commit 3706f64ca7
6 changed files with 381 additions and 104 deletions

View File

@ -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
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);
.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 <SmsProblemNoticeView summary={summary} className={className} />;
const handleClose = useCallback(() => {
if (!summary) return;
writeDismissed(summary.signature);
setDismissed(summary.signature);
}, [summary]);
const visible = summary && summary.signature !== dismissed ? summary : null;
return <SmsProblemNoticeView summary={visible} onClose={handleClose} className={className} />;
}

View File

@ -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(<SmsProblemNoticeView summary={fundsSummary} />);
it("при нехватке средств пишет причину и что делать", () => {
const html = renderToStaticMarkup(<SmsProblemNoticeView summary={{ ...base, kind: "funds" }} />);
expect(html).toContain("SMS не отправлено: на счёте SMS-сервиса закончились средства");
expect(html).toContain("8 заказов");
@ -31,28 +37,41 @@ describe("SmsProblemNoticeView", () => {
expect(html).toContain("Пополните счёт SMS-сервиса");
});
it("после пополнения счёта красное предупреждение сменяется спокойным «снова уходят»", () => {
const html = renderToStaticMarkup(<SmsProblemNoticeView summary={{ ...base, kind: "resolved" }} />);
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(
<SmsProblemNoticeView summary={{ ...base, kind: "low_balance", reason: null, groups: 0, messages: 0, balance: 55 }} />,
);
expect(plain(html)).toContain("На счёте SMS-сервиса мало денег: 55 ₽");
expect(html).toContain("пополните счёт заранее");
});
it("для другой причины подсказка другая", () => {
const html = renderToStaticMarkup(
<SmsProblemNoticeView
summary={{ ...fundsSummary, reason: "неверный номер получателя", hasFundsReason: false, groups: 1 }}
/>,
<SmsProblemNoticeView summary={{ ...base, kind: "other", reason: "неверный номер получателя", hasFundsReason: false, groups: 1 }} />,
);
expect(html).toContain("SMS не отправлено: неверный номер получателя");
expect(html).toContain("1 заказ");
expect(html).toContain("разделе «SMS-рассылки»");
expect(html).not.toContain("Пополните счёт");
});
it("одно сообщение — «сообщение», одно число — «заказ»", () => {
const html = renderToStaticMarkup(
<SmsProblemNoticeView
summary={{ ...fundsSummary, groups: 1, messages: 1, firstAt: "2026-09-10T10:06:11Z", lastAt: "2026-09-10T10:06:11Z" }}
/>,
);
expect(html).toContain("1 заказ");
expect(html).toContain("1 сообщение");
expect(html).toContain("за 10.09");
it("крестик есть, когда блок можно закрыть", () => {
const withClose = renderToStaticMarkup(<SmsProblemNoticeView summary={{ ...base, kind: "funds" }} onClose={vi.fn()} />);
const withoutClose = renderToStaticMarkup(<SmsProblemNoticeView summary={{ ...base, kind: "funds" }} />);
expect(withClose).toContain("Скрыть сообщение");
expect(withoutClose).not.toContain("Скрыть сообщение");
});
it("без сводки ничего не рисуется", () => {

View File

@ -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 (
<div
className={[
"rounded-[28px] border p-4 text-sm",
summary.hasFundsReason
? "border-[var(--color-danger)] bg-[rgba(239,68,68,0.08)]"
: "border-[var(--color-warning)] bg-[var(--color-warning-soft)]",
className,
].join(" ")}
>
<div className="flex flex-wrap items-center gap-2">
<span className="text-base leading-none">{summary.hasFundsReason ? "⛔" : "⚠️"}</span>
<span className="font-semibold text-[var(--color-text)]">
SMS не отправлено: {summary.reason}
</span>
<span
className={[
"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold",
summary.hasFundsReason
? "bg-[rgba(239,68,68,0.14)] text-[var(--color-danger)]"
: "bg-[var(--color-warning-soft)] text-[var(--color-warning)]",
].join(" ")}
>
<div className={["rounded-[28px] border p-4 text-sm", tone.box, className].join(" ")}>
<div className="flex flex-wrap items-start gap-2">
<span className="text-base leading-none">{tone.icon}</span>
<span className="min-w-0 flex-1 font-semibold text-[var(--color-text)]">{headline}</span>
{summary.kind !== "low_balance" && (
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold ${tone.chip}`}>
{groupsText}
</span>
)}
{onClose && (
<button
type="button"
onClick={onClose}
aria-label="Скрыть сообщение"
title="Скрыть сообщение"
className="-mr-1 -mt-1 rounded-full px-2 py-0.5 text-base leading-none text-[var(--color-text-muted)] transition hover:bg-[var(--color-surface-strong)] hover:text-[var(--color-text)]"
>
×
</button>
)}
</div>
{summary.kind !== "low_balance" && (
<div className="mt-1.5 text-xs text-[var(--color-text-muted)]">
{messagesText}
{period ? ` · ${period}` : ""}
{tableText ? ` · ${tableText}` : ""}
{showTableText && tableText ? ` · ${tableText}` : ""}
</div>
)}
<div className="mt-2 text-xs text-[var(--color-text)]">
{summary.hasFundsReason
? "Пополните счёт SMS-сервиса — отправка возобновится автоматически, повторять вручную ничего не нужно."
: "Проверьте настройки отправки SMS в разделе «SMS-рассылки»."}
<div className={`mt-2 text-xs ${summary.kind === "resolved" ? "text-[var(--color-text-muted)]" : "text-[var(--color-text)]"}`}>
{hint}
</div>
</div>
);

View File

@ -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<string>} 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("|"),
};
};

View File

@ -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("другая причина — отдельный вид блока", () => {
const summary = summarizeSmsFailures([row({ sms_code: "202", error_message: "202" })], []);
expect(summary.kind).toBe("other");
});
});
it("без id заказов причина всё равно объясняется", () => {
const summary = summarizeSmsFailures([row()], []);
expect(summary.hasFundsReason).toBe(true);
expect(summary.hasVisibleRows).toBe(false);
expect(summary.affectedInTable).toBe(0);
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);
});
});

View File

@ -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<string, unknown> = {};
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 });
}
});