From 68b6de3405f9f31ee45a3ae99478c7a4b7818523 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Fri, 28 Aug 2026 20:41:08 +0000 Subject: [PATCH] Moshpit is lifetime pricing: $5 an ending, $2 a name, paid once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 010 gave endings a one-year term with renewals, per PRD 0005 §5. That is withdrawn. The prices are unchanged; what changed is that they are not charged again. The reason is what the namespace is for. A name that lapses is a name somebody else can catch, and the whole pitch is that you can finally hold the clean name instead of the hyphenated one you settled for. An annual invoice with a drop date attached is the thing people are trying to get away from, and selling it back to them undoes the pitch. Names were already sold this way and only the comments said otherwise: `moshpit_names` has never had an expiry column and nothing has ever renewed a name. The PRD called it a yearly fee, the schema sold it outright, and the doc comment described the PRD. It now describes the code. Endings really did have the machinery, but it never shipped -- nothing in the app could open an ending checkout, so `quoteTld` and `quoteRenewal` were unreachable and only the webhook settler was wired up. No ending was ever charged a renewal and no row in the wild has an expiry this takes away, which is why migration 016 is a plain drop rather than a grandfathering policy: §21.8 asks for one before putting endings INTO a lifecycle, and taking them back out of one nobody was in needs no such thing. - `expires_at` and `term_started_at` are dropped from `moshpit_tlds`, columns and index. Gone rather than left NULL, because a nullable expiry is an annual term waiting to be switched back on. - `moshpit_tld_purchases` KEEPS `kind` and `years`. That is a financial record of what was sold at the time, and a ledger is not something to rewrite once the product changes. - A 'renew' row can no longer be created, but one may still settle late. It is honoured rather than refused -- the buyer keeps the ending, which now keeps itself. Refusing it would take money for nothing. - `isExpired()` stays and always answers no, so callers asking a fair question get a permanent answer instead of an import that fails. The pit page leads with it, since it is the one claim here worth making above the fold and the whole reason to prefer this over a registrar. Note for whoever writes the next migration: nothing may follow the last statement in a .sql file but whitespace. migrate.mjs splits on semicolons and hands each piece to libSQL, and a trailing comment-only piece comes back as `SQLITE_OK: not an error`, which is as unhelpful as it sounds. 591 pass in apps/pwa. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GWhPX5Uzd29whRg5WPAYM7 --- apps/pwa/src/lib/moshpit-name.mjs | 23 +++- .../src/migrations/016_moshpit_lifetime.sql | 37 ++++++ apps/pwa/src/moshpit.mjs | 111 +++++++++--------- apps/pwa/src/routes/moshpit.mjs | 17 ++- apps/pwa/test/moshpit-terms.test.mjs | 111 ++++++++++-------- 5 files changed, 189 insertions(+), 110 deletions(-) create mode 100644 apps/pwa/src/migrations/016_moshpit_lifetime.sql diff --git a/apps/pwa/src/lib/moshpit-name.mjs b/apps/pwa/src/lib/moshpit-name.mjs index f7be06cd..19d19523 100644 --- a/apps/pwa/src/lib/moshpit-name.mjs +++ b/apps/pwa/src/lib/moshpit-name.mjs @@ -226,20 +226,25 @@ export function shortCount(n) { } /** - * The most a child name should cost per year. + * The most a child name should cost. Once, for good. * * This is the `me.whatever` price — what a buyer pays to mint a name under an * ending someone else holds. It is not the price of `.whatever` itself, which - * is a separate thing the registry does not charge for yet. + * is a separate thing. * * $2 flat. PRD 0005 R3 wrote this as $1.99; the extra cent buys nothing but a * price tag that looks like a supermarket shelf, and every number a person has * to reason about here — a default, a cap, a per-line override — reads better * round. The PRD number is superseded by this one. * - * The ceiling is on the annual registration/renewal price only. A one-time - * Buy Now resale transfers ownership rather than starting a term, and §10.2.4 - * puts no ceiling on that. + * Not an annual price, and never was in practice: `moshpit_names` has never had + * an expiry column and nothing has ever renewed a name. The PRD called it a + * yearly fee, the schema sold it outright, and this comment used to describe + * the PRD. It now describes what the code does, which is the thing buyers were + * actually getting. + * + * A one-time Buy Now resale transfers ownership rather than starting anything, + * and §10.2.4 puts no ceiling on that. */ export const MAX_CHILD_PRICE_USD = 2; @@ -247,7 +252,7 @@ export const MAX_CHILD_PRICE_USD = 2; export const CHILD_PRICE_USD = MAX_CHILD_PRICE_USD; /** - * What a direct ending costs per year: `.whatever` itself. + * What a direct ending costs, once: `.whatever` itself. * * Nothing charges this yet — `registerTld` inserts a row and claiming is free. * It lives here anyway so the two prices sit together and the number is settled @@ -257,6 +262,12 @@ export const CHILD_PRICE_USD = MAX_CHILD_PRICE_USD; * $5 flat, for the same reason the child price is $2: PRD 0005 §10.1 wrote * these as $4.99 and $1.99, and the trailing cents buy nothing but a price tag * shaped like a supermarket shelf. + * + * Paid once and held for good. §5's one-year term with renewals is withdrawn + * (migration 016). A name that lapses is a name somebody else can catch, and + * the whole reason to be here is to stop settling for the hyphenated version of + * the name you wanted — an annual invoice with a drop date attached is the + * thing people are leaving, not something to sell them again. */ export const ENDING_PRICE_USD = 5; diff --git a/apps/pwa/src/migrations/016_moshpit_lifetime.sql b/apps/pwa/src/migrations/016_moshpit_lifetime.sql new file mode 100644 index 00000000..790e1568 --- /dev/null +++ b/apps/pwa/src/migrations/016_moshpit_lifetime.sql @@ -0,0 +1,37 @@ +-- Endings are sold once and held for good. The term is withdrawn. +-- +-- Migration 010 gave endings a one-year term with renewals, per PRD 0005 §5. +-- That is reversed here: $5 buys `.eggs` outright and $2 buys a name under one, +-- both paid once. The prices have not changed -- what changed is that they are +-- not charged again. +-- +-- The reason is what the namespace is for rather than generosity. A name that +-- lapses is a name somebody else can catch, and the pitch is that you can +-- finally hold the clean name instead of the hyphenated one you settled for. An +-- annual invoice with a drop date on it is the thing people are trying to get +-- away from; selling it back to them undoes the pitch. +-- +-- Worth recording that the term never actually shipped: nothing in the app +-- could open an ending checkout, so `quoteTld` and `quoteRenewal` were +-- unreachable and only the webhook settler was wired up. No ending was ever +-- charged a renewal, and no row in the wild has an expiry that this drop takes +-- away from somebody. That is why this is a plain drop rather than a +-- grandfathering policy -- §21.8 asks for one before putting endings INTO a +-- lifecycle, and taking them back out of one nobody was in needs no such thing. +-- +-- `moshpit_tld_purchases` keeps its `kind` and `years` columns, deliberately. +-- They are a financial record of what was sold at the time, and a ledger is not +-- something to rewrite once the product changes. New rows are written +-- 'register' and 1 for good; a 'renew' row that settles late is honoured rather +-- than refused, because the buyer is owed what they were promised. +-- +-- (Nothing may follow the last statement here but whitespace: migrate.mjs +-- splits on semicolons and hands each piece to libSQL, and a trailing +-- comment-only piece comes back as the opaque `SQLITE_OK: not an error`.) + +-- The index goes first: SQLite refuses to drop a column an index reads. +DROP INDEX IF EXISTS idx_moshpit_tlds_expires; + +ALTER TABLE moshpit_tlds DROP COLUMN expires_at; + +ALTER TABLE moshpit_tlds DROP COLUMN term_started_at; diff --git a/apps/pwa/src/moshpit.mjs b/apps/pwa/src/moshpit.mjs index e347b0fd..727f0d1e 100644 --- a/apps/pwa/src/moshpit.mjs +++ b/apps/pwa/src/moshpit.mjs @@ -1403,13 +1403,27 @@ export function summarizeBulkClaim(result, limit = MAX_BULK_TLDS) { return parts.length ? parts.join(". ") + "." : "nothing to claim — paste one ending per line."; } -/* ---- buying and renewing an ending ---- */ +/* ---- buying an ending ---- */ -/** A term is a year. Ten is the ceiling PRD 0005 R6 puts on one checkout. */ -export const TERM_MS = 365 * 24 * 60 * 60 * 1000; -export const MAX_TERM_YEARS = 10; - -const TLD_COLS_FULL = `tld, user_id, owner_email, alias_of, price_usd, term_started_at, expires_at, created_at`; +/** + * Endings are sold once and held for good. + * + * They used to carry a one-year term with renewals, per PRD 0005 §5. That is + * withdrawn: $5 buys `.eggs` outright, $2 buys a name under one, and neither + * ever comes up for renewal. The prices are unchanged -- what changed is that + * they are paid once. + * + * The reason is not generosity, it is what the namespace is for. A name that + * lapses is a name somebody else can catch, and the whole pitch here is that + * you can finally have the clean name instead of the hyphenated one you settled + * for. An annual invoice with a drop date attached is the thing people are + * trying to get away from, and selling it back to them undoes the pitch. + * + * The term columns are gone (migration 016). The purchase ledger keeps its + * `kind` and `years` columns, because those record what was actually sold at + * the time and a financial record is not something to rewrite after the fact. + */ +const TLD_COLS_FULL = `tld, user_id, owner_email, alias_of, price_usd, created_at`; export async function getTldWithTerm(tld) { return get(`SELECT ${TLD_COLS_FULL} FROM moshpit_tlds WHERE tld = ?`, [tld]); @@ -1423,18 +1437,13 @@ export async function getTldWithTerm(tld) { * reserved, already held, and "you already own it" are three different answers * and a single "unavailable" would be none of them. */ -export async function quoteTld({ tld: tldInput, buyerId, years = 1, now = Date.now() }) { +export async function quoteTld({ tld: tldInput, buyerId, now = Date.now() }) { const tld = normalizeTld(tldInput); if (!tld) return { ok: false, error: "not a valid TLD — letters, digits and dashes only, no dots" }; const why = tldRejection(tld); if (why) return { ok: false, error: why }; - const term = Number(years); - if (!Number.isInteger(term) || term < 1 || term > MAX_TERM_YEARS) { - return { ok: false, error: `a term is 1 to ${MAX_TERM_YEARS} years` }; - } - const owner = await getTldWithTerm(tld); if (owner) { if (owner.user_id === buyerId) return { ok: false, error: `.${tld} is already yours`, taken: true }; @@ -1450,46 +1459,40 @@ export async function quoteTld({ tld: tldInput, buyerId, years = 1, now = Date.n ); if (held) return { ok: false, error: `.${tld} is in someone's checkout right now — try again shortly`, taken: true }; - return { ok: true, tld, years: term, priceUsd: Math.round(ENDING_PRICE_USD * term * 100) / 100 }; + // Not multiplied by anything. There is one price and one purchase, and a + // quote that still carried a term would be an offer the checkout cannot make. + return { ok: true, tld, priceUsd: ENDING_PRICE_USD }; } -/** What it costs to keep one you hold. */ -export async function quoteRenewal({ tld: tldInput, userId, years = 1 }) { - const tld = normalizeTld(tldInput); - if (!tld) return { ok: false, error: "not a valid TLD" }; - - const term = Number(years); - if (!Number.isInteger(term) || term < 1 || term > MAX_TERM_YEARS) { - return { ok: false, error: `a term is 1 to ${MAX_TERM_YEARS} years` }; - } - - const owner = await getTldWithTerm(tld); - if (!owner) return { ok: false, error: `.${tld} is not registered` }; - if (owner.user_id !== userId) return { ok: false, error: `you do not own .${tld}` }; - - return { ok: true, tld, years: term, priceUsd: Math.round(ENDING_PRICE_USD * term * 100) / 100, expiresAt: owner.expires_at }; -} - -export async function openTldPurchase({ paymentId, tld, userId, amountUsd, years = 1, kind = "register", now = Date.now() }) { +/** + * Open a checkout for an ending. + * + * `kind` and `years` are written as the constants they now always are rather + * than dropped from the INSERT: the columns are the ledger's, and a row that + * left them NULL would be indistinguishable from one written before they + * existed. Every ending sold from here on is one registration, held for good. + */ +export async function openTldPurchase({ paymentId, tld, userId, amountUsd, now = Date.now() }) { await run( `INSERT INTO moshpit_tld_purchases (id, tld, user_id, amount_usd, kind, status, years, created_at, reserved_until) - VALUES (?,?,?,?,?, 'pending', ?,?,?)`, - [paymentId, tld, userId, amountUsd, kind, years, now, now + RESERVATION_MS], + VALUES (?,?,?,?, 'register', 'pending', 1, ?,?)`, + [paymentId, tld, userId, amountUsd, now, now + RESERVATION_MS], ); } /** - * Hand over a paid-for ending, or extend one. Idempotent on the payment id. + * Hand over a paid-for ending. Idempotent on the payment id. * * The claim is a conditional UPDATE for the same reason every other settlement * here uses one: CoinPay retries a webhook it never got an ack for, so two * deliveries can be in flight at once and both read 'pending' before either * write lands. * - * A renewal never shortens a term. It extends from whichever is later — the - * current expiry or now — so renewing early adds to what is left rather than - * throwing it away, and renewing late does not backdate the new term into the - * past. PRD 0005 R7. + * A 'renew' row can no longer be created, but one may still arrive here: a + * checkout opened before endings went lifetime can settle after. It is honoured + * as what the buyer was actually promised -- they hold the ending, and it now + * holds for good -- rather than refused for naming a kind this code no longer + * sells. Refusing it would take money for nothing. */ export async function settleTldPurchase(paymentId, now = Date.now()) { const p = await get(`SELECT * FROM moshpit_tld_purchases WHERE id = ? AND status = 'pending'`, [paymentId]); @@ -1499,8 +1502,6 @@ export async function settleTldPurchase(paymentId, now = Date.now()) { `UPDATE moshpit_tld_purchases SET status = 'settling' WHERE id = ? AND status = 'pending'`, [paymentId]); if (!claimed.rowsAffected) return { ok: false, error: "already settled" }; - const span = TERM_MS * (p.years || 1); - if (p.kind === "renew") { const owner = await getTldWithTerm(p.tld); if (!owner || owner.user_id !== p.user_id) { @@ -1508,19 +1509,17 @@ export async function settleTldPurchase(paymentId, now = Date.now()) { console.error(`[moshpit] .${p.tld} left ${p.user_id} before renewal ${paymentId} settled — refund due`); return { ok: false, error: "ending changed hands before the renewal settled", refundDue: true }; } - const from = Math.max(owner.expires_at || 0, now); - await run(`UPDATE moshpit_tlds SET expires_at = ? WHERE tld = ? AND user_id = ?`, - [from + span, p.tld, p.user_id]); + // Nothing to extend any more. The ending is already theirs for good. await run(`UPDATE moshpit_tld_purchases SET status = 'cleared' WHERE id = ?`, [paymentId]); - await logAction(p.tld, p.user_id, `renew:${p.years}y`); - return { ok: true, tld: p.tld, userId: p.user_id, expiresAt: from + span, renewed: true }; + await logAction(p.tld, p.user_id, `renew:lifetime`); + return { ok: true, tld: p.tld, userId: p.user_id, renewed: true, lifetime: true }; } try { await run( - `INSERT INTO moshpit_tlds (tld, user_id, owner_email, owner_key, created_at, term_started_at, expires_at) - VALUES (?,?,?,?,?,?,?)`, - [p.tld, p.user_id, null, null, now, now, now + span], + `INSERT INTO moshpit_tlds (tld, user_id, owner_email, owner_key, created_at) + VALUES (?,?,?,?,?)`, + [p.tld, p.user_id, null, null, now], ); } catch { // Claimed by someone else between checkout and confirmation. Real money @@ -1532,7 +1531,7 @@ export async function settleTldPurchase(paymentId, now = Date.now()) { await run(`UPDATE moshpit_tld_purchases SET status = 'cleared' WHERE id = ?`, [paymentId]); await logAction(p.tld, p.user_id, `bought:.${p.tld}`); - return { ok: true, tld: p.tld, userId: p.user_id, expiresAt: now + span }; + return { ok: true, tld: p.tld, userId: p.user_id, lifetime: true }; } export async function listTldPurchases(userId, limit = 50) { @@ -1544,14 +1543,16 @@ export async function listTldPurchases(userId, limit = 50) { } /** - * Is this ending inside its term? + * Kept, and it always answers no. * - * A NULL expiry is not expired. Every ending claimed before terms existed has - * one, and treating "no term recorded" as "term ended" would expire a few - * hundred namespaces that nobody agreed to put on a clock. + * Endings do not expire any more. This stays as a named answer rather than + * being deleted because "does this ending still belong to its holder" is a + * question callers are entitled to keep asking -- the CLI, the DNS bridge and + * the resolvers all reasonably might -- and the honest reply is now a permanent + * no rather than a missing export that fails at import time. */ -export function isExpired(tld, now = Date.now()) { - return Boolean(tld?.expires_at) && tld.expires_at <= now; +export function isExpired() { + return false; } /* ---- short links: /f/ ---- */ diff --git a/apps/pwa/src/routes/moshpit.mjs b/apps/pwa/src/routes/moshpit.mjs index 600c98c3..094bf383 100644 --- a/apps/pwa/src/routes/moshpit.mjs +++ b/apps/pwa/src/routes/moshpit.mjs @@ -64,7 +64,9 @@ import { countSearchTlds, countTldsNotOwnedBy, createLink, + CHILD_PRICE_USD, DEFAULT_TLD_PRICE_USD, + ENDING_PRICE_USD, deleteContent, deleteLink, getContent, @@ -1868,6 +1870,12 @@ const PIT_CSS = ` .pit-forsale{border-color:color-mix(in srgb,var(--acid) 35%,var(--line))} .pit-tab .count{font-size:.68rem;color:var(--faint);margin-left:6px} .pit-tab.on .count{color:var(--acid)} +/* The one claim worth making above the fold, so it reads as a fact about the + namespace rather than as a banner. Bordered on one side only — a full box + here would sit next to the error and success boxes and be mistaken for one. */ +.pit-forever{border-left:2px solid var(--acid);padding:2px 0 2px 14px;margin:18px 0 0;max-width:62ch} +.pit-forever b{color:var(--acid);font-weight:600} +.pit-forever .mono{color:var(--acid)} .pit-msg{border-radius:8px;padding:10px 14px;margin:14px 0;font-family:var(--mono);font-size:.84rem} .pit-msg.err{border:1px solid var(--danger);color:var(--danger)} .pit-msg.ok{border:1px solid var(--acid);color:var(--acid)} @@ -2457,6 +2465,12 @@ moshpitRouter.get("/pit", async (req, res) => { foo.agentic resolve to foo.agent — while any name you exempt stays exactly where it is.

+

+ Bought once. Yours for good.
+ $${ENDING_PRICE_USD} an ending, $${CHILD_PRICE_USD} a name — + paid one time, not every year. Nothing here renews, nothing lapses, and no name you hold can drop + because an invoice went to an address you stopped reading. +

${landingCard(req, landing)} ${msg} ${req.user ? claimForm(req) + bulkClaimForm(req) : ""} @@ -2486,7 +2500,8 @@ moshpitRouter.get("/pit", async (req, res) => {

Endings somebody else holds. Where the operator has set a price you can buy a name under it — foo.whatever without owning .whatever. Paid in crypto - through CoinPay; the name lands the moment the payment confirms. + through CoinPay; the name lands the moment the payment confirms, and it is yours from then on — + there is no renewal and no expiry date.

${theirsHtml} ${pager({ diff --git a/apps/pwa/test/moshpit-terms.test.mjs b/apps/pwa/test/moshpit-terms.test.mjs index b6f45ddf..708b849a 100644 --- a/apps/pwa/test/moshpit-terms.test.mjs +++ b/apps/pwa/test/moshpit-terms.test.mjs @@ -32,18 +32,24 @@ test("ending terms", { skip: installed ? false : "pwa dependencies not installed const uniq = () => `e${randomBytes(4).toString("hex")}`; const pay = () => `pay-${randomBytes(6).toString("hex")}`; - await t.test("an unclaimed ending quotes at the ending price", async () => { + await t.test("an unclaimed ending quotes at the ending price, once", async () => { const q = await m.quoteTld({ tld: uniq(), buyerId: ALICE }); assert.equal(q.ok, true); assert.equal(q.priceUsd, 5, "PRD 0005 §10.1, rounded to whole dollars"); - assert.equal(q.years, 1); + assert.equal(q.years, undefined, "there is no term to quote"); }); - await t.test("multiple years multiply, up to the cap", async () => { - assert.equal((await m.quoteTld({ tld: uniq(), buyerId: ALICE, years: 3 })).priceUsd, 15); - assert.equal((await m.quoteTld({ tld: uniq(), buyerId: ALICE, years: 11 })).ok, false); - assert.equal((await m.quoteTld({ tld: uniq(), buyerId: ALICE, years: 0 })).ok, false); - assert.equal((await m.quoteTld({ tld: uniq(), buyerId: ALICE, years: 1.5 })).ok, false); + await t.test("there is no term to buy more of", async () => { + // A quantity used to multiply the price. Passing one now is not an error + // that needs naming, it is a field nothing reads -- what matters is that it + // cannot quietly produce a different price. + assert.equal((await m.quoteTld({ tld: uniq(), buyerId: ALICE, years: 3 })).priceUsd, 5); + assert.equal((await m.quoteTld({ tld: uniq(), buyerId: ALICE, years: 11 })).priceUsd, 5); + // And renewing is gone entirely rather than left as a no-op somebody could + // still wire a checkout to. + assert.equal(typeof m.quoteRenewal, "undefined"); + assert.equal(typeof m.MAX_TERM_YEARS, "undefined"); + assert.equal(typeof m.TERM_MS, "undefined"); }); await t.test("every refusal names itself", async () => { @@ -72,7 +78,7 @@ test("ending terms", { skip: installed ? false : "pwa dependencies not installed assert.equal((await m.quoteTld({ tld, buyerId: BOB })).ok, true); }); - await t.test("settling hands it over with a term", async () => { + await t.test("settling hands it over for good", async () => { const tld = uniq(); const id = pay(); const now = Date.now(); @@ -80,11 +86,26 @@ test("ending terms", { skip: installed ? false : "pwa dependencies not installed const result = await m.settleTldPurchase(id, now); assert.equal(result.ok, true); + assert.equal(result.lifetime, true); const row = await m.getTldWithTerm(tld); assert.equal(row.user_id, ALICE); - assert.equal(row.term_started_at, now); - assert.equal(row.expires_at, now + m.TERM_MS, "one year"); + // The columns are gone, not merely unset: a NULL expiry somebody could + // later populate is an annual term waiting to be switched back on. + assert.equal("expires_at" in row, false); + assert.equal("term_started_at" in row, false); + }); + + await t.test("the ledger still records what was sold", async () => { + const tld = uniq(); + const id = pay(); + await m.openTldPurchase({ paymentId: id, tld, userId: ALICE, amountUsd: 5 }); + await m.settleTldPurchase(id); + + const row = (await m.listTldPurchases(ALICE, 50)).find((r) => r.id === id); + assert.equal(row.kind, "register"); + assert.equal(row.years, 1); + assert.equal(row.status, "cleared"); }); await t.test("a redelivered webhook does not settle twice", async () => { @@ -113,56 +134,50 @@ test("ending terms", { skip: installed ? false : "pwa dependencies not installed assert.ok(row, "the purchase is still on Alice's record"); }); - await t.test("renewing extends, and never shortens", async () => { + await t.test("a renewal opened before the change is honoured, not refunded", async () => { + // Nothing can create one of these any more, but a checkout opened before + // endings went lifetime may still settle afterwards. The buyer is owed what + // they were promised -- they keep the ending, and it now keeps itself. const tld = uniq(); - const now = Date.now(); - const first = pay(); - await m.openTldPurchase({ paymentId: first, tld, userId: ALICE, amountUsd: 5, now }); - await m.settleTldPurchase(first, now); - - // Renewing early adds to what is left rather than throwing it away. - const second = pay(); - await m.openTldPurchase({ paymentId: second, tld, userId: ALICE, amountUsd: 5, kind: "renew", now }); - await m.settleTldPurchase(second, now + 1000); + await m.registerTld({ tld, userId: ALICE }); + const id = pay(); + await run( + `INSERT INTO moshpit_tld_purchases (id,tld,user_id,amount_usd,kind,status,years,created_at,reserved_until) + VALUES (?,?,?,?, 'renew', 'pending', 1, ?, ?)`, + [id, tld, ALICE, 5, Date.now(), Date.now() + 60_000], + ); - assert.equal((await m.getTldWithTerm(tld)).expires_at, now + m.TERM_MS * 2); + const result = await m.settleTldPurchase(id); + assert.equal(result.ok, true, result.error); + assert.equal(result.renewed, true); + assert.equal(result.lifetime, true); + assert.equal((await m.getTldWithTerm(tld)).user_id, ALICE); + assert.equal((await m.listTldPurchases(ALICE, 50)).find((r) => r.id === id).status, "cleared"); }); - await t.test("renewing a lapsed term runs from now, not from the past", async () => { + await t.test("a renewal for an ending that changed hands is still a refund", async () => { const tld = uniq(); - const past = Date.now() - m.TERM_MS * 2; + await m.registerTld({ tld, userId: BOB }); + const id = pay(); await run( - `INSERT INTO moshpit_tlds (tld,user_id,created_at,term_started_at,expires_at) VALUES (?,?,?,?,?)`, - [tld, ALICE, past, past, past + m.TERM_MS], + `INSERT INTO moshpit_tld_purchases (id,tld,user_id,amount_usd,kind,status,years,created_at,reserved_until) + VALUES (?,?,?,?, 'renew', 'pending', 1, ?, ?)`, + [id, tld, ALICE, 5, Date.now(), Date.now() + 60_000], ); - const id = pay(); - const now = Date.now(); - await m.openTldPurchase({ paymentId: id, tld, userId: ALICE, amountUsd: 5, kind: "renew", now }); - await m.settleTldPurchase(id, now); - - assert.equal((await m.getTldWithTerm(tld)).expires_at, now + m.TERM_MS, "not backdated"); - }); - - await t.test("only the holder may renew", async () => { - const tld = uniq(); - await m.registerTld({ tld, userId: ALICE }); - assert.match((await m.quoteRenewal({ tld, userId: BOB })).error, /do not own/); - assert.equal((await m.quoteRenewal({ tld, userId: ALICE })).ok, true); + const result = await m.settleTldPurchase(id); + assert.equal(result.ok, false); + assert.equal(result.refundDue, true); }); - await t.test("an ending with no term recorded is not expired", async () => { - // Every ending claimed before terms existed has a NULL expiry. Treating - // that as expired would put a few hundred namespaces on a clock nobody - // agreed to. + await t.test("nothing expires any more", async () => { const tld = uniq(); await m.registerTld({ tld, userId: ALICE }); - const row = await m.getTldWithTerm(tld); - - assert.equal(row.expires_at, null); - assert.equal(m.isExpired(row), false); - assert.equal(m.isExpired({ expires_at: Date.now() - 1 }), true); - assert.equal(m.isExpired({ expires_at: Date.now() + 1000 }), false); + assert.equal(m.isExpired(await m.getTldWithTerm(tld)), false); + // Including for a row that still carries a date from somewhere. The answer + // is a permanent no, not a comparison against a column that no longer runs. + assert.equal(m.isExpired({ expires_at: Date.now() - 1 }), false); + assert.equal(m.isExpired(), false); }); });