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(); + }); +});