From 0e850fc66a298a661295a847f6abfb0bf4075d75 Mon Sep 17 00:00:00 2001 From: ClaydeCode Date: Wed, 2 Sep 2026 12:22:04 +0000 Subject: [PATCH] settings, lib: show the shard's prices on trial and against every size button A trial user could not find out what subscribing costs without leaving the product: the trial branch of the Subscription card rendered one sentence and the PayPal button, and the Size card's buttons were bare letters in every state, so even an active subscriber first saw a resize price on PayPal's approval sheet. Both now read GET /api/shards/self/prices through the management proxy, which the controller added in FreeshardBase/freeshard-controller#440. Its `prices` list is rendered directly, so the UI does no pricing arithmetic of its own for anything a user sees. `grossPriceCentsFromComponents` exists only for the self-check the response model asks clients to run: it recomputes each size from the published `components` at the shard's current disk and warns to the console when the result disagrees with the server, which stays the displayed figure. It reproduces the controller's grouping (each component to euro before combining) and its half-up rounding, both of which diverge by 1 ct from the obvious alternatives. The endpoint is not deployed yet, so the fetch 404s today. That is the same path as any other failure: log to console, leave `pricing` null, and every price block disappears from the template, leaving the page as it renders on main. Closes #48 --- src/lib/pricing.js | 19 +++++++++++++ src/views/Settings.vue | 56 +++++++++++++++++++++++++++++++++++++- tests/unit/pricing.spec.js | 56 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 1 deletion(-) diff --git a/src/lib/pricing.js b/src/lib/pricing.js index 3a5e21a..8e00f8e 100644 --- a/src/lib/pricing.js +++ b/src/lib/pricing.js @@ -43,3 +43,22 @@ export function vatAmountEur(grossCents) { const net = Math.round(grossCents / VAT_MULTIPLIER); return (grossCents - net) / 100; } + +// Gross monthly price in integer cents, recomputed from the controller's published +// `components` block. Mirrors `compute_price` in the controller's +// `freeshard_controller/service/pricing.py`, whose arithmetic it must reproduce +// exactly: each component to euro before combining, then half-up rounding +// (Math.round, not toFixed and not banker's rounding). +export function grossPriceCentsFromComponents(components, vmSize, volumeSizeGb) { + if (!components || !Array.isArray(components.net_prices)) return null; + if (!Number.isFinite(volumeSizeGb)) return null; + if (!Number.isFinite(components.disk_net_price_cents_per_gb)) return null; + if (!Number.isFinite(components.vat_rate)) return null; + const entry = components.net_prices.find((p) => p.vm_size === vmSize); + if (!entry || !Number.isFinite(entry.net_price_cents)) return null; + const netEur = + entry.net_price_cents / 100 + + volumeSizeGb * (components.disk_net_price_cents_per_gb / 100); + const grossEur = netEur * (1 + components.vat_rate); + return Math.round(grossEur * 100); +} diff --git a/src/views/Settings.vue b/src/views/Settings.vue index 9728989..f899911 100644 --- a/src/views/Settings.vue +++ b/src/views/Settings.vue @@ -54,6 +54,16 @@ Subscribe to keep your shard running. +
@@ -249,6 +259,9 @@ :disabled="!sizeIsAvailable(size) || resize.waitingForRestart || hasPendingResize" :variant="variantForSize(size)"> {{ size | uppercase }} + + {{ formatPrice(centsToEur(priceCentsForSize(size))) }} + @@ -271,6 +284,11 @@ + + Prices are per month at your current {{ pricing.volume_size_gb }} GB disk, + incl. 19% VAT. + + A size change is already pending; new resizes are disabled until it completes. @@ -337,7 +355,7 @@ import TextField from "@/components/TextField.vue"; import {toastMixin} from "@/mixins"; import pjson from "@/../package.json"; import {EventBus} from "@/event-bus"; -import {centsToEur, formatPrice, vatAmountEur} from "@/lib/pricing"; +import {centsToEur, formatPrice, grossPriceCentsFromComponents, vatAmountEur} from "@/lib/pricing"; import {loadPaypalSdk} from "@/lib/paypal-sdk"; const INTERSTITIAL_POLL_MS = 3000; @@ -361,6 +379,7 @@ export default { result: '', }, backupInfo: {"last_report": null}, + pricing: null, passphraseLoading: false, passphrase: null, // This will be handled later navOverlays: { @@ -426,6 +445,11 @@ export default { const p = this.$store.state.profile; return !!(p && p.billing_enabled && p.subscription && p.subscription.status === 'active'); }, + currentSizePriceCents() { + const profile = this.$store.state.profile; + if (!profile) return null; + return this.priceCentsForSize(profile.vm_size); + }, }, watch: { @@ -491,6 +515,35 @@ export default { this.toastError('Error during loading', e.response.data.detail); } }, + async refreshPricing() { + try { + const response = await this.$http.get('/core/protected/management/api/shards/self/prices'); + this.pricing = response.data; + this.checkPricingArithmetic(); + } catch (e) { + console.warn('Could not load shard prices', e); + this.pricing = null; + } + }, + checkPricingArithmetic() { + const pricing = this.pricing; + if (!pricing || !Array.isArray(pricing.prices)) return; + for (const entry of pricing.prices) { + const local = grossPriceCentsFromComponents( + pricing.components, entry.vm_size, pricing.volume_size_gb); + if (local !== null && local !== entry.price_cents) { + console.warn( + `Pricing mismatch for ${entry.vm_size} at ${pricing.volume_size_gb} GB: ` + + `server ${entry.price_cents} cents, locally computed ${local} cents. ` + + 'Displaying the server figure.'); + } + } + }, + priceCentsForSize(size) { + if (!this.pricing || !Array.isArray(this.pricing.prices)) return null; + const entry = this.pricing.prices.find((p) => p.vm_size === size); + return entry ? entry.price_cents : null; + }, async fetchPassphrase() { this.passphraseLoading = true; try { @@ -708,6 +761,7 @@ export default { async mounted() { document.title = `Shard [${this.$store.getters.short_shard_id}] - About`; this.refreshBackupInfo(); // Load backup info in the background + this.refreshPricing(); // Load prices in the background EventBus.$on('backup_update', () => { this.refreshBackupInfo(); diff --git a/tests/unit/pricing.spec.js b/tests/unit/pricing.spec.js index 0fdb916..982a978 100644 --- a/tests/unit/pricing.spec.js +++ b/tests/unit/pricing.spec.js @@ -2,6 +2,7 @@ import { centsToEur, computeMonthlyPrice, formatPrice, + grossPriceCentsFromComponents, vatAmountEur, } from '@/lib/pricing'; @@ -70,3 +71,58 @@ describe('vatAmountEur', () => { expect(vatAmountEur(null)).toBeNull(); }); }); + +describe('grossPriceCentsFromComponents', () => { + // The block GET /api/shards/self/prices embeds as `components`. + const components = { + currency: 'EUR', + net_prices: [ + {vm_size: 'xs', net_price_cents: 825}, + {vm_size: 's', net_price_cents: 1650}, + {vm_size: 'm', net_price_cents: 2970}, + {vm_size: 'l', net_price_cents: 7650}, + {vm_size: 'xl', net_price_cents: 15300}, + ], + disk_net_price_cents_per_gb: 6, + vat_rate: 0.19, + }; + + test('s + 30GB => 2178 cents', () => { + expect(grossPriceCentsFromComponents(components, 's', 30)).toBe(2178); + }); + + test('xs + 30GB => 1196 cents', () => { + expect(grossPriceCentsFromComponents(components, 'xs', 30)).toBe(1196); + }); + + test('xl + 400GB => 21063 cents', () => { + expect(grossPriceCentsFromComponents(components, 'xl', 400)).toBe(21063); + }); + + // Half-cent result: half-up gives 3749, banker's rounding would give 3748. + test('s + 250GB rounds half-up to 3749 cents', () => { + expect(grossPriceCentsFromComponents(components, 's', 250)).toBe(3749); + }); + + // Grouping: cents-first arithmetic yields 19100 here, euro-first 19099. + test('m + 2180GB groups in euro and yields 19099 cents', () => { + expect(grossPriceCentsFromComponents(components, 'm', 2180)).toBe(19099); + }); + + test('matches centsToEur/formatPrice for display', () => { + const cents = grossPriceCentsFromComponents(components, 's', 30); + expect(formatPrice(centsToEur(cents))).toBe('\u20ac21.78'); + }); + + test('unknown vm size => null', () => { + expect(grossPriceCentsFromComponents(components, 'xxl', 30)).toBeNull(); + }); + + test('missing components => null', () => { + expect(grossPriceCentsFromComponents(null, 's', 30)).toBeNull(); + }); + + test('missing disk size => null', () => { + expect(grossPriceCentsFromComponents(components, 's', undefined)).toBeNull(); + }); +});