openvibers/openvibe.host Commits

A plan states its term and what it renews at; every link says what it earns us (#246)

OP OpenVibers committed 11 days ago · 6ee075df4d · 7 files, +65 −9

Migration 0014 (expand): catalog_plans.price_term (monthly, 1-year, 3-year) and renewal_price. Every sourced price is
the provider's monthly list price, so sourced plans are month to month; a staff entry states its term or the page says
"Term not published". A 1- or 3-year term says what it renews at, or "Renewal price not published". A link with no
affiliate deal says "No commission: we earn nothing from this link" (affiliate links were already marked).

Changes

ddiff --git a/migrations/0014_catalog_price_term.sql b/migrations/0014_catalog_price_term.sql
nnew file mode 100644
iindex 0000000..5cf0b7b
---- /dev/null
++++ b/migrations/0014_catalog_price_term.sql
@@@ -0,0 +1,11 @@
++-- phase: expand
++-- The term a catalog plan's price is quoted for, and the price it renews at when the source states one.
++--
++-- Every sourced plan's stored price is a monthly price (server/catalog/mapping.js reads Linode's price.monthly,
++-- Vultr's monthly_cost, Lightsail's hourly rate for its 744-hour month, Azure's hourly rate for 730 hours, and
++-- OVHcloud's commitment=0 one-month price), so its term is known: monthly. A staff entry states its own term, or
++-- leaves it null and the page says "term not published". No source states a renewal price for a server plan, so
++-- renewal_price stays null until one does and the page says "renewal price not published".
++ALTER TABLE catalog_plans ADD COLUMN price_term text COLLATE "C" CHECK (price_term IN ('monthly', '1-year', '3-year'));
++ALTER TABLE catalog_plans ADD COLUMN renewal_price numeric(12,4) CHECK (renewal_price >= 0);
++UPDATE catalog_plans SET price_term = 'monthly' WHERE origin = 'sources' AND price_term IS NULL;
ddiff --git a/public/css/catalog.css b/public/css/catalog.css
iindex 5d278b3..70c622a 100644
---- a/public/css/catalog.css
++++ b/public/css/catalog.css
@@@ -36,6 +36,8 @@
.hc-kind { display: block; font-size: 12px; font-weight: 400; color: var(--hs-muted); }
.hc-price { font-weight: 700; font-size: 15px; color: var(--hs-fg); }
.hc-per { color: var(--hs-muted); font-size: 12px; margin-left: 1px; }
++/* The term a price is quoted for, when it is not month to month (market outcome 1). */
++.hc-term { display: block; color: var(--hs-muted); font-size: 11.5px; font-weight: 600; line-height: 1.3; }
.hc-dim { color: var(--hs-muted); }
.hc-unknown { color: var(--hs-muted); font-style: italic; font-size: 13px; }
.hc-checked { white-space: nowrap; color: var(--hs-muted); font-variant-numeric: tabular-nums; }
ddiff --git a/server/catalog/mapping.js b/server/catalog/mapping.js
iindex 7ad0bae..90c4c0e 100644
---- a/server/catalog/mapping.js
++++ b/server/catalog/mapping.js
@@@ -115,6 +115,10 @@ function mapItem(item) {
const common = {
provider, external_id: id, currency: 'USD', source_key: item.source_key, source_item_id: item.id,
source_url: provider.source_url, checked_at: Date.parse(item.provenance.retrieved_at), first_seen_at: Date.parse(item.provenance.first_seen_at),
++ // Every price the mappers below read is the source's monthly price (Lightsail's and Azure's hourly rates
++ // become a month's worth, OVHcloud is its commitment=0 one-month price), so the row's term is monthly. No
++ // source states a renewal price for a server plan, so it stays unknown rather than guessed.
++ price_term: 'monthly', renewal_price: null,
};
if (item.source_key === 'aws-lightsail-bundles') return { ...common, ...lightsail(x, id) };
if (item.source_key === 'azure-retail-prices') {
ddiff --git a/server/catalog/store.js b/server/catalog/store.js
iindex b679b4b..b74c9e9 100644
---- a/server/catalog/store.js
++++ b/server/catalog/store.js
@@@ -20,7 +20,7 @@ const invalid = (message) => { throw new ApiError(422, 'host.catalog.invalid', m
const missing = () => { throw new ApiError(404, 'host.catalog.not_found', 'Catalog entry not found'); };
const fields = {
providers: ['slug', 'name', 'homepage_url', 'description', 'affiliate_url', 'affiliate_note', 'status'],
-- plans: ['provider_id', 'external_id', 'name', 'kind', 'vcpu', 'ram_mb', 'disk_gb', 'disk_type', 'bandwidth_gb', 'unmetered', 'port_mbps', 'ipv4', 'locations', 'currency', 'price_monthly', 'price_hourly', 'setup_fee', 'source_url', 'checked_at', 'status'],
++ plans: ['provider_id', 'external_id', 'name', 'kind', 'vcpu', 'ram_mb', 'disk_gb', 'disk_type', 'bandwidth_gb', 'unmetered', 'port_mbps', 'ipv4', 'locations', 'currency', 'price_monthly', 'price_hourly', 'price_term', 'renewal_price', 'setup_fee', 'source_url', 'checked_at', 'status'],
promos: ['provider_id', 'title', 'details', 'code', 'url', 'affiliate', 'starts_at', 'ends_at', 'status'],
};
const kinds = new Set(['cloud', 'vps', 'dedicated', 'storage', 'domain']);
@@@ -30,7 +30,7 @@ const clean = (type, input, current, now) => {
if (!input || typeof input !== 'object' || Array.isArray(input)) invalid('Body must be an object');
for (const k of Object.keys(input)) if (!fields[type].includes(k)) invalid(`Unknown field: ${k}`);
const v = { ...(current || {}), ...input };
-- const strings = type === 'providers' ? ['slug', 'name', 'homepage_url', 'description', 'affiliate_url', 'affiliate_note', 'status'] : type === 'plans' ? ['provider_id', 'external_id', 'name', 'kind', 'disk_type', 'currency', 'source_url', 'status'] : ['provider_id', 'title', 'details', 'code', 'url', 'status'];
++ const strings = type === 'providers' ? ['slug', 'name', 'homepage_url', 'description', 'affiliate_url', 'affiliate_note', 'status'] : type === 'plans' ? ['provider_id', 'external_id', 'name', 'kind', 'disk_type', 'currency', 'price_term', 'source_url', 'status'] : ['provider_id', 'title', 'details', 'code', 'url', 'status'];
for (const k of strings) if (v[k] != null && (typeof v[k] !== 'string' || v[k].length > 2048 || (v[k] === '' && !['description', 'affiliate_note', 'details', 'code'].includes(k)))) invalid(`Invalid ${k}`);
const required = type === 'providers' ? ['slug', 'name', 'homepage_url'] : type === 'plans' ? ['provider_id', 'external_id', 'name', 'kind', 'currency', 'price_monthly', 'source_url'] : ['provider_id', 'title', 'url'];
for (const k of required) if (v[k] == null || v[k] === '') invalid(`${k} is required`);
@@@ -38,15 +38,16 @@ const clean = (type, input, current, now) => {
if (type === 'providers' && !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(v.slug)) invalid('Invalid slug');
if (type === 'plans') {
if (!kinds.has(v.kind) || !currencies.has(v.currency)) invalid('Invalid kind or currency');
-- for (const k of ['vcpu', 'ram_mb', 'disk_gb', 'bandwidth_gb', 'port_mbps', 'ipv4', 'price_monthly', 'price_hourly', 'setup_fee']) {
++ for (const k of ['vcpu', 'ram_mb', 'disk_gb', 'bandwidth_gb', 'port_mbps', 'ipv4', 'price_monthly', 'price_hourly', 'renewal_price', 'setup_fee']) {
if (input[k] != null && (typeof input[k] !== 'number' || !Number.isFinite(input[k]) || input[k] < 0)) invalid(`${k} must be a non-negative number`);
}
++ if (v.price_term != null && !['monthly', '1-year', '3-year'].includes(v.price_term)) invalid('Invalid price term');
if (v.ipv4 != null && !Number.isInteger(v.ipv4)) invalid('ipv4 must be an integer');
if (v.disk_type != null && !['ssd', 'nvme', 'hdd'].includes(v.disk_type)) invalid('Invalid disk type');
if (v.unmetered != null && typeof v.unmetered !== 'boolean') invalid('unmetered must be boolean');
if (v.locations != null && (!Array.isArray(v.locations) || v.locations.some((x) => typeof x !== 'string' || !x))) invalid('Invalid locations');
if (v.checked_at == null || (current && input.price_monthly !== undefined && input.checked_at === undefined)) v.checked_at = now;
-- if (v.price_monthly >= 100000000 || (v.price_hourly != null && v.price_hourly >= 1000000) || (v.setup_fee != null && v.setup_fee >= 100000000)) invalid('Price is outside catalog range');
++ if (v.price_monthly >= 100000000 || (v.price_hourly != null && v.price_hourly >= 1000000) || (v.renewal_price != null && v.renewal_price >= 100000000) || (v.setup_fee != null && v.setup_fee >= 100000000)) invalid('Price is outside catalog range');
if (!Number.isSafeInteger(v.checked_at) || v.checked_at < 0) invalid('Invalid checked_at');
if (v.status != null && !['active', 'gone', 'hidden'].includes(v.status)) invalid('Invalid status');
}
@@@ -137,7 +138,7 @@ function createCatalog({ store, community = null, log = console, indexnow = null
fresh.push(`/catalog/providers/${encodeURIComponent(v.provider.slug)}`);
}
const old = await t.prepare('SELECT * FROM catalog_plans WHERE provider_id = ? AND external_id = ?').get(p.id, v.external_id);
-- const cols = ['name', 'kind', 'vcpu', 'ram_mb', 'disk_gb', 'disk_type', 'bandwidth_gb', 'unmetered', 'port_mbps', 'ipv4', 'locations', 'currency', 'price_monthly', 'price_hourly', 'source_key', 'source_item_id', 'source_url', 'checked_at'];
++ const cols = ['name', 'kind', 'vcpu', 'ram_mb', 'disk_gb', 'disk_type', 'bandwidth_gb', 'unmetered', 'port_mbps', 'ipv4', 'locations', 'currency', 'price_monthly', 'price_hourly', 'price_term', 'renewal_price', 'source_key', 'source_item_id', 'source_url', 'checked_at'];
if (old) {
if (old.origin !== 'sources' || v.checked_at < Number(old.checked_at)) return 'hold';
if (Number(old.price_monthly) !== v.price_monthly || old.currency !== v.currency) {
ddiff --git a/server/http/catalog.js b/server/http/catalog.js
iindex 0ac4434..1e080d7 100644
---- a/server/http/catalog.js
++++ b/server/http/catalog.js
@@@ -159,7 +159,7 @@ function createCatalogPages({ catalog, domainCheck, benchmarks, community, confi
const csrf = csrfToken(config, req.viewer);
const blocks = await Promise.all(['providers', 'plans', 'promos'].map(async (type) => {
const rows = await catalog.all(type);
-- const keys = { providers: ['slug', 'name', 'homepage_url', 'description', 'affiliate_url', 'affiliate_note', 'status'], plans: ['provider_id', 'external_id', 'name', 'kind', 'vcpu', 'ram_mb', 'disk_gb', 'disk_type', 'bandwidth_gb', 'unmetered', 'port_mbps', 'ipv4', 'locations', 'currency', 'price_monthly', 'price_hourly', 'setup_fee', 'source_url', 'checked_at', 'status'], promos: ['provider_id', 'title', 'details', 'code', 'url', 'affiliate', 'starts_at', 'ends_at', 'status'] }[type];
++ const keys = { providers: ['slug', 'name', 'homepage_url', 'description', 'affiliate_url', 'affiliate_note', 'status'], plans: ['provider_id', 'external_id', 'name', 'kind', 'vcpu', 'ram_mb', 'disk_gb', 'disk_type', 'bandwidth_gb', 'unmetered', 'port_mbps', 'ipv4', 'locations', 'currency', 'price_monthly', 'price_hourly', 'price_term', 'renewal_price', 'setup_fee', 'source_url', 'checked_at', 'status'], promos: ['provider_id', 'title', 'details', 'code', 'url', 'affiliate', 'starts_at', 'ends_at', 'status'] }[type];
const form = (row) => '<form method="post" action="/catalog/manage/' + type + (row ? '/' + esc(row.id) : '') + '"><input type="hidden" name="csrf" value="' + csrf + '">' + keys.map((k) => '<label>' + esc(k) + ' <input name="' + k + '" value="' + esc(row && row[k] != null ? Array.isArray(row[k]) ? row[k].join(',') : row[k] : '') + '"></label>').join('') + '<button>Save</button></form>';
return '<section><h2>' + type + '</h2>' + form(null) + rows.map((r) => '<h3>' + esc(r.name || r.title || r.id) + '</h3>' + form(r)).join('') + '</section>';
}));
@@@ -174,7 +174,7 @@ function createCatalogPages({ catalog, domainCheck, benchmarks, community, confi
const body = { ...req.body }; delete body.csrf;
for (const k of Object.keys(body)) {
if (body[k] === '') { delete body[k]; continue; }
-- if (['provider_id', 'vcpu', 'ram_mb', 'disk_gb', 'bandwidth_gb', 'port_mbps', 'ipv4', 'price_monthly', 'price_hourly', 'setup_fee', 'checked_at', 'starts_at', 'ends_at'].includes(k)) body[k] = Number(body[k]);
++ if (['provider_id', 'vcpu', 'ram_mb', 'disk_gb', 'bandwidth_gb', 'port_mbps', 'ipv4', 'price_monthly', 'price_hourly', 'renewal_price', 'setup_fee', 'checked_at', 'starts_at', 'ends_at'].includes(k)) body[k] = Number(body[k]);
if (['unmetered', 'affiliate'].includes(k)) body[k] = body[k] === 'true';
if (k === 'locations') body[k] = body[k].split(',').map((x) => x.trim()).filter(Boolean);
}
ddiff --git a/server/render/catalog.js b/server/render/catalog.js
iindex c5e56dd..0a2976a 100644
---- a/server/render/catalog.js
++++ b/server/render/catalog.js
@@@ -61,7 +61,24 @@ function visit(p, label = 'Visit provider') {
if (p.affiliate_url) {
return `<span class="hc-visit">${out(p.affiliate_url, `${esc(label)} <span class="hc-chip">Affiliate</span>`, { affiliate: true, cls: 'button' })}${p.affiliate_note ? `<small class="hc-note">${esc(p.affiliate_note)}</small>` : ''}</span>`;
}
-- return p.homepage_url ? `<span class="hc-visit">${out(p.homepage_url, esc(label), { cls: 'button hc-button-quiet' })}</span>` : '';
++ // What a link earns us, said on every link (market outcome 7): an affiliate link is marked above; this one earns nothing.
++ return p.homepage_url ? `<span class="hc-visit">${out(p.homepage_url, esc(label), { cls: 'button hc-button-quiet' })}<small class="hc-note">No commission: we earn nothing from this link.</small></span>` : '';
++}
++
++/**
++ * The term a price is quoted for, and what it renews at (market outcomes 1 and 2, migration 0014). A sourced plan's
++ * price is the provider's own monthly list price; a staff entry states its term or leaves it unknown, and an unknown
++ * renewal price is said to be unknown, never assumed.
++ */
++const TERMS = { monthly: 'Month to month', '1-year': '1-year term', '3-year': '3-year term' };
++function termLines(p) {
++ if (isStorageRate(p)) return '';
++ const term = TERMS[p.price_term];
++ if (!term) return '<p class="hc-dim">Term not published.</p>';
++ const renews = p.renewal_price != null && Number.isFinite(Number(p.renewal_price))
++ ? `Renews at ${esc(money(Number(p.renewal_price), p.currency))} a month.`
++ : p.price_term === 'monthly' ? 'At the provider\'s list price.' : 'Renewal price not published.';
++ return `<p class="hc-dim">${esc(term)}. ${renews}</p>`;
}
function priceCell(p) {
@@@ -69,7 +86,8 @@ function priceCell(p) {
// A per-GB rate is not a monthly price: it is shown as the rate per GB it is (server/catalog/pricing.js).
if (isStorageRate(p)) return `<span class="hc-price">${Number.isFinite(price) ? esc(money(price, p.currency, 4)) : 'price not stated'}</span><span class="hc-per">/GB/month</span>`;
const m = Number.isFinite(price) && price > 0 ? money(price, p.currency) : null;
-- return `<span class="hc-price">${m ? esc(m) : 'price not stated'}</span><span class="hc-per">/mo</span>`;
++ const term = p.price_term === 'monthly' ? '' : TERMS[p.price_term] ? `<span class="hc-term">${esc(TERMS[p.price_term])}</span>` : '<span class="hc-term">term not published</span>';
++ return `<span class="hc-price">${m ? esc(m) : 'price not stated'}</span><span class="hc-per">/mo</span>${term}`;
}
const isoAt = (v) => new Date(v).toISOString();
@@@ -312,6 +330,7 @@ function plan(p, discussion = null, { baseUrl = '', watchUrl = '', extra = '', n
${isStorageRate(p) ? '<p class="hc-dim">A storage rate: the price per GB a month, not a monthly server price.</p>' : ''}
${p.price_hourly != null ? `<p class="hc-dim">${esc(money(p.price_hourly, p.currency, 4))} an hour</p>` : ''}
${p.setup_fee != null && Number(p.setup_fee) > 0 ? `<p class="hc-dim">Setup fee ${esc(money(p.setup_fee, p.currency))}</p>` : ''}
++${termLines(p)}
<p class="hc-checked-line">Checked <strong>${esc(day(p.checked_at) || 'unknown')}</strong>${staleMark(p, now)} · ${out(p.source_url, 'source')}</p>
${isStale(p.checked_at, now) ? '<p class="hc-dim">This price was last checked over a year ago, so it is not counted as a cheapest plan.</p>' : ''}
${visit(p)}
ddiff --git a/test/catalog-manage.test.js b/test/catalog-manage.test.js
iindex a114345..027b3bb 100644
---- a/test/catalog-manage.test.js
++++ b/test/catalog-manage.test.js
@@@ -21,6 +21,25 @@ const { boot, check, done } = require('./helpers/boot');
const bad = await t.api('POST', '/api/v1/catalog/plans', { as: staff, json: { provider_id: provider.id, name: 'No source', external_id: 'bad', kind: 'vps', currency: 'USD', price_monthly: 1 } }); assert.strictEqual(bad.status, 422); assert.strictEqual(bad.json().code, 'host.catalog.invalid');
const unknown = await t.api('PATCH', '/api/v1/catalog/promos/hpr_00000000000000000000000000', { as: staff, json: { title: 'x' } }); assert.strictEqual(unknown.status, 404); assert.strictEqual(unknown.json().code, 'host.catalog.not_found');
});
++ await check('a plan states its term and renewal, and every link what it earns us (market outcomes 1, 2, 7)', async () => {
++ // A staff plan with no term: the page says so rather than guessing.
++ let page = await t.api('GET', `/catalog/plans/${plan.id}`);
++ assert.strictEqual(page.status, 200, page.text.slice(0, 200));
++ assert.match(page.text, /Term not published\./);
++ assert.match(page.text, /No commission: we earn nothing from this link\./, 'a homepage link says it earns nothing');
++ // A 1-year term with no renewal price stated, then one with it.
++ let r = await t.api('PATCH', `/api/v1/catalog/plans/${plan.id}`, { as: staff, json: { price_term: '1-year' } }); assert.strictEqual(r.status, 200, r.text);
++ page = await t.api('GET', `/catalog/plans/${plan.id}`);
++ assert.match(page.text, /1-year term\. Renewal price not published\./);
++ r = await t.api('PATCH', `/api/v1/catalog/plans/${plan.id}`, { as: staff, json: { renewal_price: 9.5 } }); assert.strictEqual(r.status, 200, r.text);
++ page = await t.api('GET', `/catalog/plans/${plan.id}`);
++ assert.match(page.text, /1-year term\. Renews at \$9\.50 a month\./);
++ // A term that is not one of the three is refused.
++ r = await t.api('PATCH', `/api/v1/catalog/plans/${plan.id}`, { as: staff, json: { price_term: 'weekly' } }); assert.strictEqual(r.status, 422, r.text);
++ r = await t.api('PATCH', `/api/v1/catalog/plans/${plan.id}`, { as: staff, json: { price_term: 'monthly', renewal_price: null } }); assert.strictEqual(r.status, 200, r.text);
++ page = await t.api('GET', `/catalog/plans/${plan.id}`);
++ assert.match(page.text, /Month to month\. At the provider&#39;s list price\.|Month to month\. At the provider's list price\./);
++ });
await check('staff manage form is server rendered and CSRF protected', async () => {
const r = await t.api('GET', '/catalog/manage', { session: staff }); assert.strictEqual(r.status, 200); assert.match(r.text, /<form method="post"/); assert.match(r.text, /name="csrf"/);
const denied = await t.api('POST', '/catalog/manage/providers', { session: staff, form: { slug: 'injected', name: 'Injected', homepage_url: 'https://example.test' } }); assert.strictEqual(denied.status, 403);