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" && ( -
+
{i18next.t("hosting.manage-continue-payment")} + {!!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 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(); + }); +});