Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/self-hosted/hosting/api/src/payment-listener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions apps/self-hosted/hosting/api/src/routes/card-availability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
const body = (await res.json()) as { reservation: { graceDays: number } };
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
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);
});
});
7 changes: 7 additions & 0 deletions apps/self-hosted/hosting/api/src/routes/payments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* Payment Routes
*/

import { reservationGraceDays } from '../services/subscription';
import { Hono } from 'hono';
import { internalSecret } from './internal';
import { db } from '../db/client';
Expand Down Expand Up @@ -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(),
},
});
});

Expand Down
10 changes: 10 additions & 0 deletions apps/self-hosted/hosting/api/src/services/subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/features/hosting-signup/hosting-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
17 changes: 16 additions & 1 deletion apps/web/src/features/hosting-signup/hosting-manage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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" && (
<div className="text-sm">
<div className="text-sm flex flex-col gap-1">
<a
href={`/hosting?resume=${encodeURIComponent(t.username)}`}
className="text-blue-dark-sky hover:underline"
>
{i18next.t("hosting.manage-continue-payment")}
</a>
{!!graceDays && (
<span className="opacity-60">
{i18next.t("hosting.reservation-grace-manage", { count: graceDays })}
</span>
)}
</div>
)}

Expand Down
18 changes: 18 additions & 0 deletions apps/web/src/features/hosting-signup/hosting-signup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -846,6 +855,15 @@ export function HostingSignup() {

{step === "payment" && (
<div className="flex flex-col gap-4">
{/* 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 && (
<p className="text-sm opacity-75">
{i18next.t("hosting.reservation-grace", { count: methods.reservation.graceDays })}
</p>
)}
{/* Term */}
<div className="flex gap-2 flex-wrap">
{TERMS.map((m) => (
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/features/i18n/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
84 changes: 84 additions & 0 deletions apps/web/src/specs/features/hosting-signup/hosting-signup.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<HostingSignup />);
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(<HostingSignup />);
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(<HostingSignup />);
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();
});
});
Loading