From 550dbdc375299b75bd5e00804c2d8269e79661bf Mon Sep 17 00:00:00 2001 From: feruzm Date: Tue, 11 Aug 2026 21:58:38 +0000 Subject: [PATCH 1/3] hosting: state the reservation grace window where it matters The expiry lifecycle already existed end to end (the sweep reclaims unpaid reservations after ABANDONED_TENANT_GRACE_DAYS, the name frees after quarantine and a reclaim overwrites the draft config), but nothing user-facing said so: an owner had no way to know their customized reservation is held for a limited window. The window resolves through one shared reservationGraceDays() in the subscription service, used by the sweep's config and now surfaced in GET /v1/payments/methods, so the number quoted to users cannot drift from the number the sweep enforces. The signup payment step states it for fresh reservations (renewals are not reservations and stay silent), and the manage panel's awaiting-payment entry states it next to the resume link. Silent when an older service omits the field. Part of #1415 --- .../hosting/api/src/payment-listener.ts | 4 +- .../api/src/routes/card-availability.test.ts | 19 ++++++++ .../hosting/api/src/routes/payments.ts | 7 +++ .../hosting/api/src/services/subscription.ts | 10 ++++ .../features/hosting-signup/hosting-api.ts | 2 + .../hosting-signup/hosting-manage.tsx | 17 ++++++- .../hosting-signup/hosting-signup.tsx | 9 ++++ apps/web/src/features/i18n/locales/en-US.json | 2 + .../hosting-signup/hosting-signup.spec.tsx | 46 +++++++++++++++++++ 9 files changed, 113 insertions(+), 3 deletions(-) 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..da8e892b0b 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,22 @@ describe('card rail availability', () => { expect(await cardEnabled()).toBe(false); }); }); + +describe('GET /v1/payments/methods reservation window', () => { + 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); + delete process.env.ABANDONED_TENANT_GRACE_DAYS; + }); +}); 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..e94a6df55a 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" && ( -
+
{i18next.t("hosting.manage-continue-payment")} + {!!graceDays && ( + + {i18next.t("hosting.reservation-grace-manage", { n: graceDays })} + + )}
)} diff --git a/apps/web/src/features/hosting-signup/hosting-signup.tsx b/apps/web/src/features/hosting-signup/hosting-signup.tsx index c5e98d980a..14285f26c0 100644 --- a/apps/web/src/features/hosting-signup/hosting-signup.tsx +++ b/apps/web/src/features/hosting-signup/hosting-signup.tsx @@ -846,6 +846,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. */} + {renewBaselineExpiryRef.current === null && !!methods?.reservation?.graceDays && ( +

+ {i18next.t("hosting.reservation-grace", { n: 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..45467329f6 100644 --- a/apps/web/src/features/i18n/locales/en-US.json +++ b/apps/web/src/features/i18n/locales/en-US.json @@ -33,6 +33,8 @@ "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 {{n}} days. Complete payment to keep them; unpaid reservations are then released.", + "reservation-grace-manage": "Held with your saved look for {{n}} days 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..caa8d9fc93 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,49 @@ 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(); + }); +}); From ec4829e9fea2a6749da050f7ff9ce97a07437ee1 Mon Sep 17 00:00:00 2001 From: feruzm Date: Tue, 11 Aug 2026 22:06:07 +0000 Subject: [PATCH 2/3] hosting: restore the grace env var after the methods tests --- .../hosting/api/src/routes/card-availability.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 da8e892b0b..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 @@ -51,6 +51,13 @@ describe('card rail availability', () => { }); 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; @@ -65,6 +72,5 @@ describe('GET /v1/payments/methods reservation window', () => { const res = await paymentRoutes.request('http://localhost/methods'); const body = (await res.json()) as { reservation: { graceDays: number } }; expect(body.reservation.graceDays).toBe(14); - delete process.env.ABANDONED_TENANT_GRACE_DAYS; }); }); From c9f24eb70fb8c4403b537c119baedc30237754dd Mon Sep 17 00:00:00 2001 From: feruzm Date: Wed, 12 Aug 2026 06:04:10 +0000 Subject: [PATCH 3/3] hosting: gate the grace notice on an actual reservation Review finding: the notice keyed on a null renewal baseline, which is also the state for expired and suspended tenants, so an owner renewing an expired blog was told their name would be released after the window, which is false: the sweep only reclaims inactive rows with no payments. An explicit fresh-reservation flag is now set by each entry into the payment step (fresh create and resume true, the 409 renewal path false), pinned by a renewal spec. The window strings switch to count based plurals so a one-day window does not read as 1 days. --- .../hosting-signup/hosting-manage.tsx | 2 +- .../hosting-signup/hosting-signup.tsx | 13 ++++++- apps/web/src/features/i18n/locales/en-US.json | 6 ++- .../hosting-signup/hosting-signup.spec.tsx | 38 +++++++++++++++++++ 4 files changed, 54 insertions(+), 5 deletions(-) diff --git a/apps/web/src/features/hosting-signup/hosting-manage.tsx b/apps/web/src/features/hosting-signup/hosting-manage.tsx index e94a6df55a..5a1dbbea46 100644 --- a/apps/web/src/features/hosting-signup/hosting-manage.tsx +++ b/apps/web/src/features/hosting-signup/hosting-manage.tsx @@ -127,7 +127,7 @@ export function HostingManage() { {!!graceDays && ( - {i18next.t("hosting.reservation-grace-manage", { n: graceDays })} + {i18next.t("hosting.reservation-grace-manage", { count: graceDays })} )}
diff --git a/apps/web/src/features/hosting-signup/hosting-signup.tsx b/apps/web/src/features/hosting-signup/hosting-signup.tsx index 14285f26c0..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]); @@ -850,9 +859,9 @@ export function HostingSignup() { 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. */} - {renewBaselineExpiryRef.current === null && !!methods?.reservation?.graceDays && ( + {isFreshReservation && !!methods?.reservation?.graceDays && (

- {i18next.t("hosting.reservation-grace", { n: methods.reservation.graceDays })} + {i18next.t("hosting.reservation-grace", { count: methods.reservation.graceDays })}

)} {/* Term */} diff --git a/apps/web/src/features/i18n/locales/en-US.json b/apps/web/src/features/i18n/locales/en-US.json index 45467329f6..df32b0f406 100644 --- a/apps/web/src/features/i18n/locales/en-US.json +++ b/apps/web/src/features/i18n/locales/en-US.json @@ -33,8 +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 {{n}} days. Complete payment to keep them; unpaid reservations are then released.", - "reservation-grace-manage": "Held with your saved look for {{n}} days from your last checkout visit, then released if unpaid.", + "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 caa8d9fc93..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 @@ -522,3 +522,41 @@ describe("HostingSignup reservation grace notice", () => { 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(); + }); +});