feat(admin): grant a free subscription to any café from the admin panel
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m43s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 33s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m14s
CI/CD / CI · Admin Web (tsc) (push) Successful in 40s
CI/CD / CI · Website (tsc) (push) Successful in 47s
CI/CD / CI · Koja (tsc) (push) Successful in 54s
CI/CD / Deploy · all services (push) Successful in 5m13s

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 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-27 19:41:33 +03:30
parent 4cc1c3a423
commit 352c3b41cb
8 changed files with 155 additions and 1 deletions
@@ -32,6 +32,23 @@ public class AdminCafesController : AdminApiControllerBase
return Ok(new ApiResponse<object>(true, new { cafeId }));
}
/// <summary>Gift a café a free subscription (set plan + add N months of coverage).</summary>
[HttpPost("{cafeId}/grant-subscription")]
public async Task<IActionResult> GrantSubscription(
string cafeId,
[FromBody] AdminGrantSubscriptionRequest request,
CancellationToken cancellationToken)
{
if (request.Months is < 1 or > 120)
return BadRequest(new ApiResponse<object>(false, null, new ApiError("INVALID_MONTHS", "Months must be 1120.")));
var ok = await _platform.GrantSubscriptionAsync(cafeId, request.PlanTier, request.Months, cancellationToken);
if (!ok)
return NotFound(new ApiResponse<object>(false, null, new ApiError("NOT_FOUND", "Cafe not found or invalid plan.")));
return Ok(new ApiResponse<object>(true, new { cafeId }));
}
[HttpPut("{cafeId}/features")]
public async Task<IActionResult> SetFeature(
string cafeId,
+4
View File
@@ -50,4 +50,8 @@ public record AdminCafePatchRequest(
bool? IsVerified,
IReadOnlyList<string>? DiscoverBadges = null);
/// <summary>Admin gifts a café a free subscription: set the plan and add <see cref="Months"/>
/// of coverage (appended to any time it already has).</summary>
public record AdminGrantSubscriptionRequest(PlanTier PlanTier, int Months);
public record CafeFeatureOverrideRequest(string FeatureKey, bool IsEnabled);
@@ -24,6 +24,7 @@ public interface IAdminPlatformService
Task<bool> UpdateFeatureAsync(string featureKey, UpdateFeatureRequest request, CancellationToken cancellationToken = default);
Task<IReadOnlyList<AdminCafeListItemDto>> ListCafesAsync(CancellationToken cancellationToken = default);
Task<bool> PatchCafeAsync(string cafeId, AdminCafePatchRequest request, CancellationToken cancellationToken = default);
Task<bool> GrantSubscriptionAsync(string cafeId, PlanTier tier, int months, CancellationToken cancellationToken = default);
Task<(bool Success, string? Key, string? ErrorCode)> GenerateRecoveryKeyAsync(string cafeId, CancellationToken cancellationToken = default);
Task<bool> RevokeRecoveryKeyAsync(string cafeId, CancellationToken cancellationToken = default);
Task<bool> SetCafeFeatureOverrideAsync(string cafeId, CafeFeatureOverrideRequest request, CancellationToken cancellationToken = default);
@@ -207,6 +208,44 @@ public class AdminPlatformService : IAdminPlatformService
return true;
}
public async Task<bool> 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<bool> SetCafeFeatureOverrideAsync(
string cafeId,
CafeFeatureOverrideRequest request,
+3 -1
View File
@@ -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,
/// <summary>A free subscription granted by a platform admin (no money changed hands).</summary>
Manual = 4
}
public static class PaymentProviderIds
+9
View File
@@ -1159,6 +1159,15 @@
"save": "حفظ",
"saved": "تم الحفظ",
"loading": "جاري التحميل..."
},
"grant": {
"title": "منح اشتراك مجاني",
"plan": "الباقة",
"months": "عدد الأشهر",
"submit": "منح",
"granted": "تم منح الاشتراك",
"failed": "تعذّر منح الاشتراك",
"currentExpiry": "انتهاء الصلاحية الحالي"
}
},
"integrations": {
+9
View File
@@ -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": {
+9
View File
@@ -1152,6 +1152,15 @@
"save": "ذخیره",
"saved": "ذخیره شد",
"loading": "در حال بارگذاری..."
},
"grant": {
"title": "افزودن اشتراک رایگان",
"plan": "پلن",
"months": "تعداد ماه",
"submit": "اعطا",
"granted": "اشتراک اعطا شد",
"failed": "اعطای اشتراک ناموفق بود",
"currentExpiry": "انقضای فعلی"
}
},
"integrations": {
@@ -493,6 +493,7 @@ export function AdminCafesScreen() {
</Button>
</div>
</div>
<GrantSubscriptionPanel cafe={c} />
<RecoveryKeyPanel cafe={c} />
{profileCafeId === c.id ? (
<CafeDiscoverProfilePanel cafeId={c.id} mode="admin" compact />
@@ -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 (
<div className="space-y-2 rounded-lg border border-border/70 bg-muted/20 p-3 text-sm">
<p className="font-medium">{t("title")}</p>
<div className="flex flex-wrap items-end gap-2">
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">{t("plan")}</span>
<select
value={tier}
onChange={(e) => setTier(e.target.value)}
className="h-9 rounded-md border border-input bg-background px-2 text-sm"
>
{TIERS.map((x) => (
<option key={x} value={x}>
{x}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">{t("months")}</span>
<Input
type="number"
min={1}
max={120}
value={months}
onChange={(e) =>
setMonths(Math.max(1, Math.min(120, Number(e.target.value) || 1)))
}
className="h-9 w-24"
/>
</label>
<Button size="sm" disabled={grant.isPending} onClick={() => grant.mutate()}>
{t("submit")}
</Button>
</div>
{cafe.planExpiresAt ? (
<p className="text-xs text-muted-foreground">
{t("currentExpiry")}: {new Date(cafe.planExpiresAt).toLocaleDateString("fa-IR")}
</p>
) : null}
</div>
);
}
/**
* Generate / revoke a café's permanent recovery key. The raw key is returned
* once on generate — shown here for copy, never retrievable again.