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
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:
@@ -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 1–120.")));
|
||||
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1159,6 +1159,15 @@
|
||||
"save": "حفظ",
|
||||
"saved": "تم الحفظ",
|
||||
"loading": "جاري التحميل..."
|
||||
},
|
||||
"grant": {
|
||||
"title": "منح اشتراك مجاني",
|
||||
"plan": "الباقة",
|
||||
"months": "عدد الأشهر",
|
||||
"submit": "منح",
|
||||
"granted": "تم منح الاشتراك",
|
||||
"failed": "تعذّر منح الاشتراك",
|
||||
"currentExpiry": "انتهاء الصلاحية الحالي"
|
||||
}
|
||||
},
|
||||
"integrations": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user