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.
+
+
+ Plan: {{ $store.state.profile.vm_size | uppercase }}
+ — {{ formatPrice(centsToEur(currentSizePriceCents)) }}/month
+
+
+ Includes {{ pricing.volume_size_gb }} GB disk,
+ incl. 19% VAT ({{ formatPrice(vatAmountEur(currentSizePriceCents)) }})
+
+
@@ -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();
+ });
+});