Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/lib/pricing.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
56 changes: 55 additions & 1 deletion src/views/Settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,16 @@
<b-card-text v-else>
Subscribe to keep your shard running.
</b-card-text>
<template v-if="currentSizePriceCents !== null">
<b-card-text class="mb-1">
Plan: <b>{{ $store.state.profile.vm_size | uppercase }}</b>
— {{ formatPrice(centsToEur(currentSizePriceCents)) }}/month
</b-card-text>
<b-card-text class="text-muted small">
Includes {{ pricing.volume_size_gb }} GB disk,
incl. 19% VAT ({{ formatPrice(vatAmountEur(currentSizePriceCents)) }})
</b-card-text>
</template>
<div ref="paypalButton"></div>
</template>

Expand Down Expand Up @@ -249,6 +259,9 @@
:disabled="!sizeIsAvailable(size) || resize.waitingForRestart || hasPendingResize"
:variant="variantForSize(size)">
{{ size | uppercase }}
<span v-if="priceCentsForSize(size) !== null" class="d-block small">
{{ formatPrice(centsToEur(priceCentsForSize(size))) }}
</span>
</b-button>
</b-button-group>

Expand All @@ -271,6 +284,11 @@
</b-button>
</div>

<b-card-text v-if="pricing" class="text-muted small mt-2">
Prices are per month at your current {{ pricing.volume_size_gb }} GB disk,
incl. 19% VAT.
</b-card-text>

<b-card-text v-if="hasPendingResize" class="text-muted small mt-2">
A size change is already pending; new resizes are disabled until it completes.
</b-card-text>
Expand Down Expand Up @@ -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;
Expand All @@ -361,6 +379,7 @@ export default {
result: '',
},
backupInfo: {"last_report": null},
pricing: null,
passphraseLoading: false,
passphrase: null, // This will be handled later
navOverlays: {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down
56 changes: 56 additions & 0 deletions tests/unit/pricing.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
centsToEur,
computeMonthlyPrice,
formatPrice,
grossPriceCentsFromComponents,
vatAmountEur,
} from '@/lib/pricing';

Expand Down Expand Up @@ -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();
});
});
Loading