From 352c3b41cb91ad073abd95f6e1e9197ec34e46d2 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Sat, 27 Jun 2026 19:41:33 +0330 Subject: [PATCH] =?UTF-8?q?feat(admin):=20grant=20a=20free=20subscription?= =?UTF-8?q?=20to=20any=20caf=C3=A9=20from=20the=20admin=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds POST /api/admin/cafes/{cafeId}/grant-subscription (admin-auth): sets the café's plan and adds N months of coverage, appended to any time it already has so a grant never shortens existing paid time. Records the gift as a SubscriptionPayment (provider Manual, amount 0, Completed) for billing history/audit. New PaymentProvider.Manual = 4 (int append, no migration). Admin-web café cards get a "grant free subscription" panel (plan select + months + apply), showing the current expiry; fa/en/ar strings. Co-Authored-By: Claude Opus 4.8 --- .../Controllers/AdminCafesController.cs | 17 +++++ src/Meezi.Admin.API/Models/AdminDtos.cs | 4 ++ .../Services/AdminPlatformService.cs | 39 +++++++++++ src/Meezi.Core/Enums/PaymentProvider.cs | 4 +- web/admin/messages/ar.json | 9 +++ web/admin/messages/en.json | 9 +++ web/admin/messages/fa.json | 9 +++ .../src/components/admin/admin-screens.tsx | 65 +++++++++++++++++++ 8 files changed, 155 insertions(+), 1 deletion(-) diff --git a/src/Meezi.Admin.API/Controllers/AdminCafesController.cs b/src/Meezi.Admin.API/Controllers/AdminCafesController.cs index 4461224..2766077 100644 --- a/src/Meezi.Admin.API/Controllers/AdminCafesController.cs +++ b/src/Meezi.Admin.API/Controllers/AdminCafesController.cs @@ -32,6 +32,23 @@ public class AdminCafesController : AdminApiControllerBase return Ok(new ApiResponse(true, new { cafeId })); } + /// Gift a café a free subscription (set plan + add N months of coverage). + [HttpPost("{cafeId}/grant-subscription")] + public async Task GrantSubscription( + string cafeId, + [FromBody] AdminGrantSubscriptionRequest request, + CancellationToken cancellationToken) + { + if (request.Months is < 1 or > 120) + return BadRequest(new ApiResponse(false, null, new ApiError("INVALID_MONTHS", "Months must be 1–120."))); + + var ok = await _platform.GrantSubscriptionAsync(cafeId, request.PlanTier, request.Months, cancellationToken); + if (!ok) + return NotFound(new ApiResponse(false, null, new ApiError("NOT_FOUND", "Cafe not found or invalid plan."))); + + return Ok(new ApiResponse(true, new { cafeId })); + } + [HttpPut("{cafeId}/features")] public async Task SetFeature( string cafeId, diff --git a/src/Meezi.Admin.API/Models/AdminDtos.cs b/src/Meezi.Admin.API/Models/AdminDtos.cs index bb30a18..51bf174 100644 --- a/src/Meezi.Admin.API/Models/AdminDtos.cs +++ b/src/Meezi.Admin.API/Models/AdminDtos.cs @@ -50,4 +50,8 @@ public record AdminCafePatchRequest( bool? IsVerified, IReadOnlyList? DiscoverBadges = null); +/// Admin gifts a café a free subscription: set the plan and add +/// of coverage (appended to any time it already has). +public record AdminGrantSubscriptionRequest(PlanTier PlanTier, int Months); + public record CafeFeatureOverrideRequest(string FeatureKey, bool IsEnabled); diff --git a/src/Meezi.Admin.API/Services/AdminPlatformService.cs b/src/Meezi.Admin.API/Services/AdminPlatformService.cs index a5cd832..fca4779 100644 --- a/src/Meezi.Admin.API/Services/AdminPlatformService.cs +++ b/src/Meezi.Admin.API/Services/AdminPlatformService.cs @@ -24,6 +24,7 @@ public interface IAdminPlatformService Task UpdateFeatureAsync(string featureKey, UpdateFeatureRequest request, CancellationToken cancellationToken = default); Task> ListCafesAsync(CancellationToken cancellationToken = default); Task PatchCafeAsync(string cafeId, AdminCafePatchRequest request, CancellationToken cancellationToken = default); + Task GrantSubscriptionAsync(string cafeId, PlanTier tier, int months, CancellationToken cancellationToken = default); Task<(bool Success, string? Key, string? ErrorCode)> GenerateRecoveryKeyAsync(string cafeId, CancellationToken cancellationToken = default); Task RevokeRecoveryKeyAsync(string cafeId, CancellationToken cancellationToken = default); Task SetCafeFeatureOverrideAsync(string cafeId, CafeFeatureOverrideRequest request, CancellationToken cancellationToken = default); @@ -207,6 +208,44 @@ public class AdminPlatformService : IAdminPlatformService return true; } + public async Task GrantSubscriptionAsync( + string cafeId, PlanTier tier, int months, CancellationToken cancellationToken = default) + { + if (tier == PlanTier.Free || months is < 1 or > 120) + return false; + + var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken); + if (cafe is null) return false; + + var now = DateTime.UtcNow; + // Append to existing paid coverage so a grant never shortens time the café already has. + var coverageEnd = cafe.PlanTier != PlanTier.Free && cafe.PlanExpiresAt.HasValue && cafe.PlanExpiresAt.Value > now + ? cafe.PlanExpiresAt.Value + : now; + var newExpiry = coverageEnd.AddMonths(months); + + cafe.PlanTier = tier; + cafe.PlanExpiresAt = newExpiry; + + // Record the gift for billing history / audit (free → amount 0, provider Manual). + _db.SubscriptionPayments.Add(new SubscriptionPayment + { + CafeId = cafeId, + PlanTier = tier, + Months = months, + AmountToman = 0m, + AmountRials = 0, + Provider = PaymentProvider.Manual, + Status = SubscriptionPaymentStatus.Completed, + EffectiveFrom = now, + EffectiveTo = newExpiry, + RefId = "admin-grant", + }); + + await _db.SaveChangesAsync(cancellationToken); + return true; + } + public async Task SetCafeFeatureOverrideAsync( string cafeId, CafeFeatureOverrideRequest request, diff --git a/src/Meezi.Core/Enums/PaymentProvider.cs b/src/Meezi.Core/Enums/PaymentProvider.cs index 709095d..875f56f 100644 --- a/src/Meezi.Core/Enums/PaymentProvider.cs +++ b/src/Meezi.Core/Enums/PaymentProvider.cs @@ -6,7 +6,9 @@ public enum PaymentProvider Tara = 1, SnappPay = 2, // Appended (stored as int) so existing rows keep their meaning — no migration needed. - FlatPay = 3 + FlatPay = 3, + /// A free subscription granted by a platform admin (no money changed hands). + Manual = 4 } public static class PaymentProviderIds diff --git a/web/admin/messages/ar.json b/web/admin/messages/ar.json index 9490911..537fd0b 100644 --- a/web/admin/messages/ar.json +++ b/web/admin/messages/ar.json @@ -1159,6 +1159,15 @@ "save": "حفظ", "saved": "تم الحفظ", "loading": "جاري التحميل..." + }, + "grant": { + "title": "منح اشتراك مجاني", + "plan": "الباقة", + "months": "عدد الأشهر", + "submit": "منح", + "granted": "تم منح الاشتراك", + "failed": "تعذّر منح الاشتراك", + "currentExpiry": "انتهاء الصلاحية الحالي" } }, "integrations": { diff --git a/web/admin/messages/en.json b/web/admin/messages/en.json index 578d82b..e6bcff3 100644 --- a/web/admin/messages/en.json +++ b/web/admin/messages/en.json @@ -1152,6 +1152,15 @@ "save": "Save", "saved": "Saved", "loading": "Loading..." + }, + "grant": { + "title": "Grant free subscription", + "plan": "Plan", + "months": "Months", + "submit": "Grant", + "granted": "Subscription granted", + "failed": "Could not grant subscription", + "currentExpiry": "Current expiry" } }, "integrations": { diff --git a/web/admin/messages/fa.json b/web/admin/messages/fa.json index 6067fa8..320aec1 100644 --- a/web/admin/messages/fa.json +++ b/web/admin/messages/fa.json @@ -1152,6 +1152,15 @@ "save": "ذخیره", "saved": "ذخیره شد", "loading": "در حال بارگذاری..." + }, + "grant": { + "title": "افزودن اشتراک رایگان", + "plan": "پلن", + "months": "تعداد ماه", + "submit": "اعطا", + "granted": "اشتراک اعطا شد", + "failed": "اعطای اشتراک ناموفق بود", + "currentExpiry": "انقضای فعلی" } }, "integrations": { diff --git a/web/admin/src/components/admin/admin-screens.tsx b/web/admin/src/components/admin/admin-screens.tsx index 86b8b0d..ea9aad2 100644 --- a/web/admin/src/components/admin/admin-screens.tsx +++ b/web/admin/src/components/admin/admin-screens.tsx @@ -493,6 +493,7 @@ export function AdminCafesScreen() { + {profileCafeId === c.id ? ( @@ -504,6 +505,70 @@ export function AdminCafesScreen() { ); } +/** Gift a café a free subscription: pick a plan + number of months and apply. + * Months are appended to any coverage the café already has. */ +function GrantSubscriptionPanel({ cafe }: { cafe: AdminCafe }) { + const t = useTranslations("admin.cafes.grant"); + const qc = useQueryClient(); + const [tier, setTier] = useState("Pro"); + const [months, setMonths] = useState(1); + + const TIERS = ["Starter", "Pro", "Business", "Enterprise"]; + + const grant = useMutation({ + mutationFn: () => + adminPost(`/api/admin/cafes/${cafe.id}/grant-subscription`, { planTier: tier, months }), + onSuccess: () => { + notify.success(t("granted")); + void qc.invalidateQueries({ queryKey: ["admin", "cafes"] }); + }, + onError: () => notify.error(t("failed")), + }); + + return ( +
+

{t("title")}

+
+ + + +
+ {cafe.planExpiresAt ? ( +

+ {t("currentExpiry")}: {new Date(cafe.planExpiresAt).toLocaleDateString("fa-IR")} +

+ ) : null} +
+ ); +} + /** * Generate / revoke a café's permanent recovery key. The raw key is returned * once on generate — shown here for copy, never retrievable again.