feat(sms): bring-your-own-provider — cafés use their own SMS account
CI/CD / CI · API (dotnet build + test) (push) Successful in 40s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 30s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m8s
CI/CD / CI · Admin Web (tsc) (push) Successful in 37s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 5m16s

The platform no longer sells SMS. Each café saves its OWN Kavenegar API
key + sender line (new Cafes columns + migration) and campaigns are sent
and billed through that account.

Backend:
- GET/PUT /sms/settings (Manager/Owner; key echoed masked, verified
  against the provider before saving)
- campaign + balance use the café's credentials; SMS_NOT_CONFIGURED
  error when missing; plan-tier SMS gating removed everywhere
  (PlanLimitChecker, SmsMarketingService, billing status)
- platform Kavenegar config stays ONLY for login OTPs (env/DB)
- design-time DbContext factory so `dotnet ef migrations add` works
  without booting the host

Dashboard:
- SMS screen: provider-settings card, not-configured callout, campaign
  form disabled until configured; quota bar removed (usage stays as info)
- subscription screen + plan comparison no longer show SMS limits

Admin panel:
- Kavenegar/SMS section removed from integrations (request field now
  optional; stored OTP config untouched)
- SMS limit field removed from the plan editor
- nav label "درگاه و پیامک" → "درگاه پرداخت و AI"

fa/en/ar translations. 86 tests pass; all tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-12 09:23:50 +03:30
parent 615d5348de
commit 00649d0248
24 changed files with 3953 additions and 188 deletions
+1 -1
View File
@@ -1020,7 +1020,7 @@
"title": "مدیریت سامانه",
"dashboard": "داشبورد",
"plans": "اشتراک و قیمت",
"integrations": "درگاه و پیامک",
"integrations": "درگاه پرداخت و AI",
"notifications": "اعلان‌ها",
"settings": "تنظیمات اپ",
"features": "قابلیت‌ها",
@@ -143,7 +143,7 @@ const LIMIT_FIELDS: { key: keyof PlanLimitsData; label: string }[] = [
{ key: "maxMenuItems", label: "maxItems" },
{ key: "maxCustomers", label: "maxCustomers" },
{ key: "maxReportHistoryDays", label: "maxReportDays" },
{ key: "maxSmsPerMonth", label: "maxSms" },
// No SMS limit — marketing SMS is bring-your-own-provider per café.
{ key: "maxMenuAi3dPerMonth", label: "maxAi3d" },
];
@@ -701,11 +701,6 @@ export function AdminIntegrationsScreen() {
hasStoredClientSecret: prev?.hasStoredClientSecret ?? false,
...patch,
});
const [kavenegar, setKavenegar] = useState({
isEnabled: true,
apiKey: "",
otpTemplate: "verify",
});
const [openAi, setOpenAi] = useState({
isEnabled: false,
apiKey: "",
@@ -722,11 +717,6 @@ export function AdminIntegrationsScreen() {
if (!data) return;
setActiveGateway(data.activePaymentGateway);
setGateways(data.paymentGateways.map((g) => ({ ...g })));
setKavenegar({
isEnabled: data.kavenegar.isEnabled,
apiKey: data.kavenegar.apiKey ?? "",
otpTemplate: data.kavenegar.otpTemplate,
});
setOpenAi({
isEnabled: data.ai.openAi.isEnabled,
apiKey: data.ai.openAi.apiKey ?? "",
@@ -770,7 +760,7 @@ export function AdminIntegrationsScreen() {
}
: undefined,
})),
kavenegar,
// SMS is bring-your-own-provider per café — no platform SMS config here.
ai: { openAi, meshy },
}),
onSuccess: () => {
@@ -998,39 +988,6 @@ export function AdminIntegrationsScreen() {
))}
</section>
<section className="space-y-3">
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t("kavenegarTitle")}
</p>
<Card className="rounded-xl border border-border/80 p-4 space-y-3">
<div className="flex items-center gap-2 text-sm">
<Toggle
checked={kavenegar.isEnabled}
onChange={(v) => setKavenegar((k) => ({ ...k, isEnabled: v }))}
/>
<span>{t("enabled")}</span>
</div>
<label className="block text-sm">
{t("apiKey")}
<Input
className="mt-1"
type="password"
placeholder={data?.kavenegar.hasStoredApiKey ? "••••••••" : ""}
value={kavenegar.apiKey}
onChange={(e) => setKavenegar((k) => ({ ...k, apiKey: e.target.value }))}
/>
</label>
<label className="block text-sm">
{t("otpTemplate")}
<Input
className="mt-1"
value={kavenegar.otpTemplate}
onChange={(e) => setKavenegar((k) => ({ ...k, otpTemplate: e.target.value }))}
/>
</label>
</Card>
</section>
<section className="space-y-3">
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
{t("aiTitle")}
+28 -2
View File
@@ -481,10 +481,36 @@
"targetGroup": "المجموعة المستهدفة",
"allCustomers": "كل العملاء",
"send": "إرسال",
"usage": "الاستخدام هذا الشهر",
"usage": "المُرسَل هذا الشهر",
"unlimited": "غير محدود",
"sent": "تم الإرسال",
"failed": "فشل"
"failed": "فشل",
"charCount": "{count} حرفاً",
"smsPartsHint": "{parts} رسالة",
"balance": "رصيد حسابك",
"balanceAmount": "{amount} ريال",
"balanceNotConfigured": "خدمة SMS غير مفعّلة",
"sender": "خط الإرسال",
"recipientsCount": "{count} مستلماً",
"sendConfirm": "إرسال إلى {count} شخصاً؟",
"sending": "جارٍ الإرسال...",
"byoHint": "تُرسل الرسائل عبر حسابك وخطك الخاص — تُحتسب تكلفة الإرسال مباشرة لدى مزوّد SMS الخاص بك.",
"notConfiguredOwner": "لإرسال الرسائل، احفظ أولاً مفتاح API ورقم خط كاوه‌نگار في الإعدادات أعلاه.",
"notConfiguredStaff": "لم يقم مدير المقهى بإعداد خدمة SMS بعد.",
"settings": {
"title": "إعدادات مزوّد SMS",
"hint": "أنشئ مفتاح API من لوحة كاوه‌نگار (kavenegar.com) وأدخله مع رقم خط الإرسال.",
"apiKey": "مفتاح API",
"apiKeyPlaceholder": "API Key",
"senderNumber": "رقم خط الإرسال",
"senderPlaceholder": "10004346...",
"configured": "خدمة SMS مفعّلة.",
"notConfigured": "لم يتم الإعداد بعد.",
"save": "حفظ",
"saving": "جارٍ التحقق…",
"saved": "تم حفظ إعدادات SMS.",
"saveFailed": "مفتاح API غير صالح أو فشل الحفظ."
}
},
"reports": {
"title": "التقارير والتحليلات",
+21 -4
View File
@@ -500,19 +500,36 @@
"targetGroup": "Target group",
"allCustomers": "All customers",
"send": "Send",
"usage": "Usage this month",
"usage": "Sent this month",
"unlimited": "Unlimited",
"sent": "Sent",
"failed": "Failed",
"charCount": "{count} chars",
"smsPartsHint": "{parts} SMS",
"balance": "Account credit",
"balance": "Your account credit",
"balanceAmount": "{amount} Rials",
"balanceNotConfigured": "Kavenegar not configured",
"balanceNotConfigured": "SMS service not set up",
"sender": "Sender line",
"recipientsCount": "{count} recipients",
"sendConfirm": "Send to {count} people?",
"sending": "Sending..."
"sending": "Sending...",
"byoHint": "SMS is sent through your OWN provider account and line — sending costs are billed directly by your SMS provider.",
"notConfiguredOwner": "To send SMS, first save your Kavenegar API key and sender line in the settings above.",
"notConfiguredStaff": "The SMS service has not been set up by the café manager yet.",
"settings": {
"title": "SMS provider settings",
"hint": "Create an API key in your Kavenegar panel (kavenegar.com) and enter it with your sender line number.",
"apiKey": "Kavenegar API key",
"apiKeyPlaceholder": "API Key",
"senderNumber": "Sender line number",
"senderPlaceholder": "10004346...",
"configured": "SMS service is active.",
"notConfigured": "Not set up yet.",
"save": "Save",
"saving": "Verifying…",
"saved": "SMS settings saved.",
"saveFailed": "The API key is invalid or saving failed."
}
},
"reports": {
"title": "Reports & analytics",
+22 -5
View File
@@ -500,19 +500,36 @@
"targetGroup": "گروه هدف",
"allCustomers": "همه مشتریان",
"send": "ارسال",
"usage": "مصرف این ماه",
"usage": "ارسال‌شده این ماه",
"unlimited": "نامحدود",
"sent": "ارسال شد",
"failed": "ناموفق",
"charCount": "{count} حرف",
"smsPartsHint": "{parts} پیامک",
"balance": "اعتبار حساب",
"balance": "اعتبار حساب شما",
"balanceAmount": "{amount} ریال",
"balanceNotConfigured": "Kavenegar پیکربندی نشده",
"balanceNotConfigured": "سرویس پیامک راه‌اندازی نشده",
"sender": "خط فرستنده",
"recipientsCount": "{count} مخاطب",
"sendConfirm": "ارسال به {count} نفر؟",
"sending": "در حال ارسال..."
"sending": "در حال ارسال...",
"byoHint": "پیامک با حساب و خط اختصاصی خود شما ارسال می‌شود — هزینه ارسال مستقیماً با اپراتور پیامک شماست.",
"notConfiguredOwner": "برای ارسال پیامک ابتدا کلید API و شماره خط کاوه‌نگار خود را در تنظیمات بالا ثبت کنید.",
"notConfiguredStaff": "سرویس پیامک هنوز توسط مدیر کافه راه‌اندازی نشده است.",
"settings": {
"title": "تنظیمات سرویس پیامک",
"hint": "از پنل کاوه‌نگار (kavenegar.com) کلید API بسازید و همراه شماره خط خود وارد کنید.",
"apiKey": "کلید API کاوه‌نگار",
"apiKeyPlaceholder": "API Key",
"senderNumber": "شماره خط ارسال",
"senderPlaceholder": "10004346...",
"configured": "سرویس پیامک فعال است.",
"notConfigured": "هنوز راه‌اندازی نشده.",
"save": "ذخیره",
"saving": "در حال بررسی…",
"saved": "تنظیمات پیامک ذخیره شد.",
"saveFailed": "کلید API نامعتبر است یا ذخیره ناموفق بود."
}
},
"reports": {
"title": "گزارش‌ها و تحلیل",
@@ -1481,7 +1498,7 @@
"title": "مدیریت سامانه",
"dashboard": "داشبورد",
"plans": "اشتراک و قیمت",
"integrations": "درگاه و پیامک",
"integrations": "درگاه پرداخت و AI",
"notifications": "اعلان‌ها",
"settings": "تنظیمات اپ",
"features": "قابلیت‌ها",
@@ -19,13 +19,13 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
/** Limit rows shown at the top of the comparison, in display order. */
// NOTE: no SMS row — marketing SMS is bring-your-own-provider, not a plan limit.
const LIMIT_ROWS: { key: keyof PlanLimits; zeroAsDash?: boolean }[] = [
{ key: "maxOrdersPerDay" },
{ key: "maxBranches" },
{ key: "maxTerminals" },
{ key: "maxTables" },
{ key: "maxCustomers" },
{ key: "maxSmsPerMonth", zeroAsDash: true },
{ key: "maxMenuItems" },
{ key: "maxReportHistoryDays" },
{ key: "maxMenuAi3dPerMonth", zeroAsDash: true },
+131 -42
View File
@@ -3,17 +3,20 @@
import { useMemo, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations, useLocale } from "next-intl";
import { MessageSquare, Zap, Users } from "lucide-react";
import { apiGet, apiPost } from "@/lib/api/client";
import type { CustomerGroup, SmsCampaignResult, SmsUsage, SmsBalance } from "@/lib/api/types";
import { KeyRound, MessageSquare, Settings2, Zap } from "lucide-react";
import { apiGet, apiPost, apiPut } from "@/lib/api/client";
import type { CustomerGroup, SmsCampaignResult, SmsSettings, SmsUsage, SmsBalance } from "@/lib/api/types";
import { useAuthStore } from "@/lib/stores/auth.store";
import { formatNumber } from "@/lib/format";
import { notify, notifyError } from "@/lib/notify";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils";
const GROUPS: (CustomerGroup | "all")[] = ["all", "Regular", "Vip", "New", "Employee"];
const MANAGER_ROLES = new Set(["Owner", "Manager"]);
/** Kavenegar SMS character limits. */
function calcSmsParts(text: string): { chars: number; parts: number } {
@@ -32,13 +35,21 @@ export function SmsScreen() {
const tCrm = useTranslations("crm");
const locale = useLocale();
const cafeId = useAuthStore((s) => s.user?.cafeId);
const role = useAuthStore((s) => s.user?.role);
const queryClient = useQueryClient();
const canManage = MANAGER_ROLES.has(role ?? "");
const [message, setMessage] = useState("");
const [target, setTarget] = useState<CustomerGroup | "all">("all");
const [result, setResult] = useState<SmsCampaignResult | null>(null);
// ── API queries ─────────────────────────────────────────────────────────────
const { data: settings } = useQuery({
queryKey: ["sms-settings", cafeId],
queryFn: () => apiGet<SmsSettings>(`/api/cafes/${cafeId}/sms/settings`),
enabled: !!cafeId && canManage,
});
const { data: usage } = useQuery({
queryKey: ["sms-usage", cafeId],
queryFn: () => apiGet<SmsUsage>(`/api/cafes/${cafeId}/sms/usage`),
@@ -69,47 +80,38 @@ export function SmsScreen() {
// ── Derived state ────────────────────────────────────────────────────────────
const { chars, parts } = useMemo(() => calcSmsParts(message), [message]);
const usagePct = useMemo(() => {
if (!usage || usage.monthlyLimit <= 0) return null;
return Math.min(100, Math.round((usage.usedThisMonth / usage.monthlyLimit) * 100));
}, [usage]);
const usageLabel =
usage?.monthlyLimit === -1
? t("unlimited")
: `${formatNumber(usage?.usedThisMonth ?? 0, locale)} / ${formatNumber(usage?.monthlyLimit ?? 0, locale)}`;
// Provider configured? Balance endpoint answers for every role; the settings
// endpoint refines it for managers (e.g. key saved but provider unreachable).
const isConfigured = settings?.isConfigured ?? balance?.isConfigured ?? false;
if (!cafeId) return null;
return (
<div className="mx-auto max-w-2xl space-y-4">
<h2 className="text-xl font-bold">{t("title")}</h2>
<p className="text-sm text-muted-foreground">{t("byoHint")}</p>
{/* ── Provider settings (Owner/Manager) ────────────────────────────────── */}
{canManage ? (
<ProviderSettingsCard cafeId={cafeId} settings={settings} />
) : null}
{/* ── Status row ──────────────────────────────────────────────────────── */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{/* Usage */}
<div className="grid grid-cols-2 gap-3">
{/* Usage this month (informational — your provider account is the only cap) */}
<Card>
<CardContent className="p-4">
<p className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<MessageSquare className="h-3.5 w-3.5" />
{t("usage")}
</p>
<p className="text-lg font-bold tabular-nums text-foreground">{usageLabel}</p>
{usagePct !== null && (
<div className="mt-1.5 h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className={cn(
"h-full rounded-full transition-all",
usagePct >= 90 ? "bg-destructive" : "bg-primary"
)}
style={{ width: `${usagePct}%` }}
/>
</div>
)}
<p className="text-lg font-bold tabular-nums text-foreground">
{formatNumber(usage?.usedThisMonth ?? 0, locale)}
</p>
</CardContent>
</Card>
{/* Balance */}
{/* Balance of the café's own provider account */}
<Card>
<CardContent className="p-4">
<p className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
@@ -125,23 +127,17 @@ export function SmsScreen() {
)}
</CardContent>
</Card>
{/* Sender */}
<Card className="hidden sm:block">
<CardContent className="p-4">
<p className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<Users className="h-3.5 w-3.5" />
{t("sender")}
</p>
<p className="text-lg font-bold tabular-nums tracking-wider text-foreground" dir="ltr">
90005671
</p>
</CardContent>
</Card>
</div>
{/* ── Not configured callout ───────────────────────────────────────────── */}
{!isConfigured ? (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
{canManage ? t("notConfiguredOwner") : t("notConfiguredStaff")}
</div>
) : null}
{/* ── Campaign form ────────────────────────────────────────────────────── */}
<Card>
<Card className={cn(!isConfigured && "pointer-events-none opacity-50")}>
<CardContent className="space-y-4 pt-6">
{/* Target group */}
<LabeledField label={t("targetGroup")} htmlFor="sms-target">
@@ -201,7 +197,7 @@ export function SmsScreen() {
{/* Send button */}
<Button
className="w-full"
disabled={!message.trim() || sendCampaign.isPending}
disabled={!message.trim() || sendCampaign.isPending || !isConfigured}
onClick={() => sendCampaign.mutate()}
>
{sendCampaign.isPending ? t("sending") : t("send")}
@@ -237,3 +233,96 @@ export function SmsScreen() {
</div>
);
}
/**
* Bring-your-own-provider credentials: the café's Kavenegar API key + sender
* line. The platform does not sell SMS — every campaign goes through and is
* billed to the café's own provider account.
*/
function ProviderSettingsCard({
cafeId,
settings,
}: {
cafeId: string;
settings?: SmsSettings;
}) {
const t = useTranslations("sms.settings");
const queryClient = useQueryClient();
const [apiKey, setApiKey] = useState("");
const [sender, setSender] = useState<string | null>(null);
const senderValue = sender ?? settings?.senderNumber ?? "";
const save = useMutation({
mutationFn: () =>
apiPut<SmsSettings>(`/api/cafes/${cafeId}/sms/settings`, {
// Empty key field = keep the existing stored key.
apiKey: apiKey.trim() || null,
senderNumber: senderValue.trim(),
}),
onSuccess: () => {
notify.success(t("saved"));
setApiKey("");
void queryClient.invalidateQueries({ queryKey: ["sms-settings", cafeId] });
void queryClient.invalidateQueries({ queryKey: ["sms-balance", cafeId] });
},
onError: (err) => notifyError(err, t("saveFailed")),
});
const canSave =
senderValue.trim().length > 0 && (apiKey.trim().length > 0 || !!settings?.isConfigured);
return (
<Card className="border-primary/20">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Settings2 className="h-4 w-4 text-primary" aria-hidden />
{t("title")}
</CardTitle>
<p className="text-xs text-muted-foreground">{t("hint")}</p>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<LabeledField label={t("apiKey")} htmlFor="sms-api-key">
<div className="relative">
<KeyRound className="pointer-events-none absolute start-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
id="sms-api-key"
type="password"
autoComplete="off"
dir="ltr"
className="ps-9"
placeholder={settings?.apiKeyMasked ?? t("apiKeyPlaceholder")}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
</div>
</LabeledField>
<LabeledField label={t("senderNumber")} htmlFor="sms-sender">
<Input
id="sms-sender"
inputMode="numeric"
dir="ltr"
placeholder={t("senderPlaceholder")}
value={senderValue}
onChange={(e) => setSender(e.target.value)}
/>
</LabeledField>
</div>
<div className="flex items-center justify-between gap-3">
<p className="text-[11px] text-muted-foreground">
{settings?.isConfigured ? t("configured") : t("notConfigured")}
</p>
<Button
size="sm"
disabled={!canSave || save.isPending}
onClick={() => save.mutate()}
>
{save.isPending ? t("saving") : t("save")}
</Button>
</div>
</CardContent>
</Card>
);
}
@@ -37,8 +37,6 @@ type BillingStatus = {
ordersDailyLimit: number | null;
customersCount: number;
customersLimit: number | null;
smsUsedThisMonth: number;
smsMonthlyLimit: number;
menu3dEnabled: boolean;
discoverProfileEnabled: boolean;
isPlanExpired: boolean;
@@ -164,11 +162,6 @@ export function SubscriptionScreen() {
{status.customersLimit != null &&
` / ${formatNumber(status.customersLimit)}`}
</li>
<li>
{t("smsUsage")}: {formatNumber(status.smsUsedThisMonth)}
{status.smsMonthlyLimit >= 0 &&
` / ${formatNumber(status.smsMonthlyLimit)}`}
</li>
<li>
{t("featureMenu3d")}:{" "}
{status.menu3dEnabled ? t("featureOn") : t("featureOff")}
+7
View File
@@ -202,6 +202,13 @@ export interface SmsBalance {
isConfigured: boolean;
}
/** Café's own SMS provider settings (bring-your-own-provider; key comes back masked). */
export interface SmsSettings {
isConfigured: boolean;
apiKeyMasked?: string | null;
senderNumber?: string | null;
}
export interface Table {
id: string;
branchId?: string;