diff --git a/apps/self-hosted/hosting/api/src/payment-listener.ts b/apps/self-hosted/hosting/api/src/payment-listener.ts
index 9f55ff0ec6..b6733f1ead 100644
--- a/apps/self-hosted/hosting/api/src/payment-listener.ts
+++ b/apps/self-hosted/hosting/api/src/payment-listener.ts
@@ -8,7 +8,7 @@
import { callRPC, setNodes } from '@ecency/sdk/hive';
import { db } from './db/client';
import { COMMUNITY_NAME, TenantService } from './services/tenant-service';
-import { parseGraceDays } from './services/subscription';
+import { parseGraceDays, reservationGraceDays } from './services/subscription';
import { ConfigService } from './services/config-service';
import { parseMemo, mapTenantFromDb, type ParsedMemo } from './types';
import { AuditService } from './services/audit-service';
@@ -40,7 +40,7 @@ const CONFIG = {
// it 'abandoned'). Must be a positive integer: a zero/negative/NaN value would make the SQL
// cutoff `NOW() - (n * INTERVAL '1 day')` land at or after now and sweep EVERY inactive tenant,
// so a misconfigured env falls back to 7 rather than mass-reclaiming.
- ABANDONED_GRACE_DAYS: parseAbandonedGraceDays(process.env.ABANDONED_TENANT_GRACE_DAYS),
+ ABANDONED_GRACE_DAYS: reservationGraceDays(),
UNVERIFIED_DOMAIN_CLAIM_DAYS: parseGraceDays(
process.env.UNVERIFIED_DOMAIN_CLAIM_DAYS,
DEFAULT_UNVERIFIED_DOMAIN_CLAIM_DAYS
diff --git a/apps/self-hosted/hosting/api/src/routes/card-availability.test.ts b/apps/self-hosted/hosting/api/src/routes/card-availability.test.ts
index 83fd47babe..58a21e75ab 100644
--- a/apps/self-hosted/hosting/api/src/routes/card-availability.test.ts
+++ b/apps/self-hosted/hosting/api/src/routes/card-availability.test.ts
@@ -49,3 +49,28 @@ describe('card rail availability', () => {
expect(await cardEnabled()).toBe(false);
});
});
+
+describe('GET /v1/payments/methods reservation window', () => {
+ const originalGrace = process.env.ABANDONED_TENANT_GRACE_DAYS;
+ afterEach(() => {
+ // Restore even when an assertion throws, so a leaked value cannot skew later suites.
+ if (originalGrace === undefined) delete process.env.ABANDONED_TENANT_GRACE_DAYS;
+ else process.env.ABANDONED_TENANT_GRACE_DAYS = originalGrace;
+ });
+
+ it('reports the same grace window the sweep enforces, fail-safe 7', async () => {
+ delete process.env.ABANDONED_TENANT_GRACE_DAYS;
+ process.env.HOSTING_INTERNAL_SECRET = STRONG;
+ const res = await paymentRoutes.request('http://localhost/methods');
+ const body = (await res.json()) as { reservation: { graceDays: number } };
+ expect(body.reservation.graceDays).toBe(7);
+ });
+
+ it('follows a configured window', async () => {
+ process.env.ABANDONED_TENANT_GRACE_DAYS = '14';
+ process.env.HOSTING_INTERNAL_SECRET = STRONG;
+ const res = await paymentRoutes.request('http://localhost/methods');
+ const body = (await res.json()) as { reservation: { graceDays: number } };
+ expect(body.reservation.graceDays).toBe(14);
+ });
+});
diff --git a/apps/self-hosted/hosting/api/src/routes/payments.ts b/apps/self-hosted/hosting/api/src/routes/payments.ts
index baea9e5e8f..5cae6eaf9a 100644
--- a/apps/self-hosted/hosting/api/src/routes/payments.ts
+++ b/apps/self-hosted/hosting/api/src/routes/payments.ts
@@ -2,6 +2,7 @@
* Payment Routes
*/
+import { reservationGraceDays } from '../services/subscription';
import { Hono } from 'hono';
import { internalSecret } from './internal';
import { db } from '../db/client';
@@ -47,6 +48,12 @@ paymentRoutes.get('/methods', async (c) => {
enabled: cardEnabled,
monthlyUsdCents: parseInt(process.env.HOSTING_CARD_USD_CENTS || '200', 10),
},
+ // How long an unpaid reservation (and its customized look) is held. Surfaced so the
+ // signup and manage panel can state the window instead of hardcoding a number that
+ // would drift from the sweep's env-configured value.
+ reservation: {
+ graceDays: reservationGraceDays(),
+ },
});
});
diff --git a/apps/self-hosted/hosting/api/src/services/subscription.ts b/apps/self-hosted/hosting/api/src/services/subscription.ts
index 3a70966111..1381cad046 100644
--- a/apps/self-hosted/hosting/api/src/services/subscription.ts
+++ b/apps/self-hosted/hosting/api/src/services/subscription.ts
@@ -36,6 +36,16 @@ export const DEFAULT_PRO_GRACE_DAYS = 14;
*/
export const PRO_GRACE_DAYS = parseGraceDays(process.env.PRO_GRACE_DAYS, DEFAULT_PRO_GRACE_DAYS);
+/**
+ * Days an unpaid (inactive) reservation holds its name before the sweep
+ * reclaims it. One resolver, so the sweep and every user-facing surface
+ * (payment step, manage panel) quote the same number. Fail-safe 7: a
+ * zero/negative/NaN value would sweep every inactive reservation at once.
+ */
+export function reservationGraceDays(): number {
+ return parseGraceDays(process.env.ABANDONED_TENANT_GRACE_DAYS, 7);
+}
+
export type CapabilityState =
/** Paid and current. */
| 'active'
diff --git a/apps/web/src/features/hosting-signup/hosting-api.ts b/apps/web/src/features/hosting-signup/hosting-api.ts
index 60130a0bae..25b95c7df8 100644
--- a/apps/web/src/features/hosting-signup/hosting-api.ts
+++ b/apps/web/src/features/hosting-signup/hosting-api.ts
@@ -13,6 +13,8 @@ export interface HostingPaymentMethods {
hbd: { enabled: boolean; monthly: string; account: string };
x402: { enabled: boolean; monthly: string };
card: { enabled: boolean; monthlyUsdCents: number };
+ /** How long an unpaid reservation (and its customized look) is held before release. */
+ reservation?: { graceDays: number };
}
export interface HostingConfigInput {
diff --git a/apps/web/src/features/hosting-signup/hosting-manage.tsx b/apps/web/src/features/hosting-signup/hosting-manage.tsx
index eb63600fcf..5a1dbbea46 100644
--- a/apps/web/src/features/hosting-signup/hosting-manage.tsx
+++ b/apps/web/src/features/hosting-signup/hosting-manage.tsx
@@ -28,6 +28,16 @@ export function HostingManage() {
});
const tenants = data?.tenants ?? [];
+ // The reservation window, for the awaiting-payment note below. Fetched only when an
+ // unpaid reservation is actually listed.
+ const hasInactive = tenants.some((t) => t.subscriptionStatus === "inactive");
+ const { data: methods } = useQuery({
+ queryKey: ["hosting", "payment-methods"],
+ queryFn: () => hostingApi.paymentMethods(),
+ enabled: hasInactive
+ });
+ const graceDays = methods?.reservation?.graceDays;
+
useEffect(() => {
setDomainOpenFor(null);
setUpgradeOpenFor(null);
@@ -108,13 +118,18 @@ export function HostingManage() {
{/* Awaiting payment (never activated): let the owner jump straight back into the payment
step for this reservation instead of dead-ending on the status label. */}
{t.subscriptionStatus === "inactive" && (
-
+
)}
diff --git a/apps/web/src/features/hosting-signup/hosting-signup.tsx b/apps/web/src/features/hosting-signup/hosting-signup.tsx
index c5e98d980a..42f62787cc 100644
--- a/apps/web/src/features/hosting-signup/hosting-signup.tsx
+++ b/apps/web/src/features/hosting-signup/hosting-signup.tsx
@@ -130,6 +130,12 @@ export function HostingSignup() {
const [busy, setBusy] = useState(false);
// Card confirmed -> the term/method are locked so a remount can't cancel the activation poll.
const [paying, setPaying] = useState(false);
+ // Whether this payment-step entry is for an UNPAID reservation (fresh create, refresh or
+ // resume), which is what the grace-window notice is about. Deliberately not derived from
+ // renewBaselineExpiryRef: that is also null for expired and suspended tenants, whose names
+ // the sweep never reclaims (it only targets inactive rows with no payments), so telling a
+ // renewing owner their name will be released would be false.
+ const [isFreshReservation, setIsFreshReservation] = useState(false);
// What we last reserved: the name AND the exact config sent. Going back and changing
// anything (name, look, identity) must re-send createTenant before payment: the server
// refreshes a same-owner unpaid reservation with the latest submission, so the look on
@@ -344,6 +350,7 @@ export function HostingSignup() {
setBlogUrl(res.tenant.blogUrl);
renewBaselineExpiryRef.current = null; // freshly created, inactive
}
+ setIsFreshReservation(true);
setStep("payment");
} catch (e) {
const msg = (e as Error).message;
@@ -385,6 +392,7 @@ export function HostingSignup() {
// checkActivation confirms on active status alone.
renewBaselineExpiryRef.current =
existing.subscriptionStatus === "active" ? (existing.subscriptionExpiresAt ?? null) : null;
+ setIsFreshReservation(false);
setStep("payment");
} finally {
setBusy(false);
@@ -451,6 +459,7 @@ export function HostingSignup() {
useEffect(() => {
if (resumeName && step === "username" && tenantUsername === resumeName && activeUser) {
setResumeName(null);
+ setIsFreshReservation(true);
setStep("payment");
}
}, [resumeName, step, tenantUsername, activeUser]);
@@ -846,6 +855,15 @@ export function HostingSignup() {
{step === "payment" && (
+ {/* A fresh reservation is held with its customized look for a limited window; say so
+ plainly (pay to keep it). Renewals (an expiry baseline exists) are not reservations
+ and skip the line. The window comes from the API so this number cannot drift from
+ the sweep's configuration. */}
+ {isFreshReservation && !!methods?.reservation?.graceDays && (
+
+ {i18next.t("hosting.reservation-grace", { count: methods.reservation.graceDays })}
+
+ )}
{/* Term */}
{TERMS.map((m) => (
diff --git a/apps/web/src/features/i18n/locales/en-US.json b/apps/web/src/features/i18n/locales/en-US.json
index d989d45d63..df32b0f406 100644
--- a/apps/web/src/features/i18n/locales/en-US.json
+++ b/apps/web/src/features/i18n/locales/en-US.json
@@ -33,6 +33,10 @@
"font-technical": "Technical (sans with mono headings)",
"font-system": "System (fastest, no downloaded fonts)",
"changeable-later": "Everything here can be changed later from your site's settings panel.",
+ "reservation-grace": "This name and your saved look are held for {{count}} days. Complete payment to keep them; unpaid reservations are then released.",
+ "reservation-grace_one": "This name and your saved look are held for one day. Complete payment to keep them; unpaid reservations are then released.",
+ "reservation-grace-manage": "Held with your saved look for {{count}} days from your last checkout visit, then released if unpaid.",
+ "reservation-grace-manage_one": "Held with your saved look for one day from your last checkout visit, then released if unpaid.",
"term-months": "{{n}} mo",
"pay-card": "Card ${{amount}}",
"pay-hbd": "{{amount}} HBD",
diff --git a/apps/web/src/specs/features/hosting-signup/hosting-signup.spec.tsx b/apps/web/src/specs/features/hosting-signup/hosting-signup.spec.tsx
index 6dab01c628..0f056155f6 100644
--- a/apps/web/src/specs/features/hosting-signup/hosting-signup.spec.tsx
+++ b/apps/web/src/specs/features/hosting-signup/hosting-signup.spec.tsx
@@ -476,3 +476,87 @@ describe("HostingSignup customize step: coverage the mutation review demanded",
expect(hostingApi.createTenant.mock.calls[1][2].styleTemplate).toBe("magazine");
});
});
+
+describe("HostingSignup reservation grace notice", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ sessionStorage.clear();
+ localStorage.clear();
+ window.history.replaceState(null, "", "/");
+ mocks.authLoginType = "keychain";
+ mocks.profiles = {};
+ hostingApi.templates.mockResolvedValue({ templates: [] });
+ hostingApi.createTenant.mockResolvedValue({
+ tenant: {
+ username: "alice",
+ subscriptionStatus: "inactive",
+ blogUrl: "https://alice.blogs.ecency.com"
+ }
+ });
+ hostingApi.paymentInstructions.mockResolvedValue(INSTRUCTIONS);
+ });
+
+ it("states the window on the payment step for a fresh reservation", async () => {
+ hostingApi.paymentMethods.mockResolvedValue({
+ hbd: { enabled: true, monthly: "2.000", account: "ecency.hosting" },
+ x402: { enabled: false, monthly: "2.000" },
+ card: { enabled: false, monthlyUsdCents: 200 },
+ reservation: { graceDays: 7 }
+ });
+ renderWithQueryClient();
+ fireEvent.click(screen.getByText("g.continue"));
+ fireEvent.click(await screen.findByText("g.continue"));
+ await screen.findByText("hosting.reservation-grace");
+ });
+
+ it("stays silent when the API does not report a window", async () => {
+ hostingApi.paymentMethods.mockResolvedValue({
+ hbd: { enabled: true, monthly: "2.000", account: "ecency.hosting" },
+ x402: { enabled: false, monthly: "2.000" },
+ card: { enabled: false, monthlyUsdCents: 200 }
+ });
+ renderWithQueryClient();
+ fireEvent.click(screen.getByText("g.continue"));
+ fireEvent.click(await screen.findByText("g.continue"));
+ await screen.findAllByText("hosting.term-months");
+ expect(screen.queryByText("hosting.reservation-grace")).toBeNull();
+ });
+});
+
+describe("HostingSignup reservation grace notice: renewals stay silent", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ sessionStorage.clear();
+ localStorage.clear();
+ window.history.replaceState(null, "", "/");
+ mocks.authLoginType = "keychain";
+ mocks.profiles = {};
+ hostingApi.templates.mockResolvedValue({ templates: [] });
+ hostingApi.paymentMethods.mockResolvedValue({
+ hbd: { enabled: true, monthly: "2.000", account: "ecency.hosting" },
+ x402: { enabled: false, monthly: "2.000" },
+ card: { enabled: false, monthlyUsdCents: 200 },
+ reservation: { graceDays: 7 }
+ });
+ hostingApi.paymentInstructions.mockResolvedValue(INSTRUCTIONS);
+ });
+
+ it("an expired tenant renewing is NOT told the name will be released", async () => {
+ // The sweep only reclaims inactive rows with no payments; an expired tenant's
+ // name is safe, so the reservation notice would be false and alarming.
+ hostingApi.createTenant.mockRejectedValue(new Error("Username already registered"));
+ hostingApi.tenant.mockResolvedValue({
+ username: "alice",
+ owner: "alice",
+ subscriptionStatus: "expired",
+ subscriptionExpiresAt: "2026-08-01T00:00:00.000Z"
+ });
+
+ renderWithQueryClient();
+ fireEvent.click(screen.getByText("g.continue"));
+ fireEvent.click(await screen.findByText("g.continue"));
+
+ await screen.findAllByText("hosting.term-months");
+ expect(screen.queryByText("hosting.reservation-grace")).toBeNull();
+ });
+});