Tweak subscriptions some more

This commit is contained in:
2026-10-06 00:46:54 +02:00
parent 1f7238841a
commit a50ac7310f
9 changed files with 107 additions and 372 deletions

View File

@@ -1,353 +0,0 @@
<script>
import Tooltip from "layout/Tooltip.svelte";
import Euro from "util/Euro.svelte";
import OtherPlans from "layout/checkout/SubscriptionPlans.svelte";
import { user } from "lib/UserStore";
</script>
<section>
<p>
Nova features two different payment modes. We offer a monthly
subscription which is managed by Patreon, and a Prepaid subscription
which supports a dozen different payment providers. With Prepaid you pay
in advance to charge your credit, then usage will be subtracted from
your account credit. For low usage Prepaid is usually better as the
monthly fee is much lower.
</p>
</section>
<div class="horizontal_scroll">
<div class="grid">
<div></div>
<div class="top_row free_feat">
<span class="bold">Free</span>
</div>
<div class="top_row pro_feat">
<span class="bold">Pro</span>
</div>
<div class="top_row prepaid_feat">
<span class="bold">Prepaid</span>
</div>
<div class="left_col">
Recurring price
</div>
<div class="feature_cell free_feat">
<span class="bold">None</span>
</div>
<div class="feature_cell pro_feat">
<Tooltip>
{#snippet label()}
<span>
<span class="bold">€4 / month</span> or
<span class="bold">€40 / year</span>
</span>
{/snippet}
The Pro subscription is managed by Patreon. Patreon's own fees
and sales tax will be added to this price. After paying you need
to link your Nova account to Patreon to activate the plan.
</Tooltip>
</div>
<div class="feature_cell prepaid_feat">
<Tooltip>
{#snippet label()}
<span class="bold">€1 / month minimum</span>
{/snippet}
<p>
The minimum fee is only charged when usage is less than €1.
This calculation is per day, the €1 amount is divided by the
number of days in the month and if your usage is lower than
that, you will be charged the minimum fee.
</p>
</Tooltip>
</div>
<div class="left_col">
<a href="/about#toc_3">Transfer limit</a>
</div>
<div class="feature_cell free_feat">
<Tooltip>
{#snippet label()}
<span class="bold">6 GB per day</span>
{/snippet}
<p>
Free users are limited to downloading 6 GB per day, this
limit is linked to your IP address, even if you are logged
in to an account.
</p>
<p>
When the download limit is exceeded the download speed is
reduced to 1 MiB/s. You can have at most three concurrent
downloads running.
</p>
</Tooltip>
</div>
<div class="feature_cell pro_feat">
<Tooltip>
{#snippet label()}
<span class="bold">4 TB per 30 days</span>
{/snippet}
<p>
The transfer limit is used for downloading, sharing and
hotlinking files.
</p>
<p>
Paid plans do not have a concurrent connection limit.
</p>
</Tooltip>
</div>
<div class="feature_cell prepaid_feat">
<Tooltip>
{#snippet label()}
<span class="bold">€1 per TB transferred</span>
{/snippet}
<p>
Prepaid does not have a transfer limit, instead you are
charged for what you use at a rate of €1 per terabyte
downloaded. This applies to downloading your own files,
other people's files and others downloading your files (if
you have hotlinking enabled in your account settings).
</p>
<p>
Paid plans do not have a concurrent connection limit.
</p>
</Tooltip>
</div>
<div class="left_col">
<a href="/about#toc_6">Hotlinking</a>
</div>
<div class="feature_cell free_feat">
<span class="bold">Hotlinking not supported</span><br/>
Hotlinked files get blocked
</div>
<div class="feature_cell span2 right pro_feat">
<span class="bold">Hotlinking supported</span><br/>
Hotlinking uses your transfer limit
</div>
<div class="left_col">
Storage
</div>
<div class="feature_cell span2 left pro_feat">
<span class="bold">No limit</span><br/>
Files expire when they are not downloaded
</div>
<div class="feature_cell prepaid_feat">
<span class="bold">€4 / TB / month</span><br/>
No limit, you pay for what you use
</div>
<div class="left_col">
<a href="/about#toc_1">File expiry</a>
</div>
<div class="feature_cell free_feat">
<Tooltip>
{#snippet label()}
<span class="bold">120 days (4 months)</span>
{/snippet}
<p>
Files expire when they have not been downloaded in the last
120 days. A download is counted when more than 10% of the
whole file was downloaded in a single request.
</p>
</Tooltip>
</div>
<div class="feature_cell pro_feat">
<Tooltip>
{#snippet label()}
<span class="bold">240 days (8 months)</span>
{/snippet}
<p>
The Pro plan has 240 day file expiry. The same rules apply
as the free plan. Higher Patreon subscription plans are
available where files do not expire at all.
</p>
</Tooltip>
</div>
<div class="feature_cell prepaid_feat">
<Tooltip>
{#snippet label()}
<span class="bold">Files do not expire</span>
{/snippet}
<p>
Files don't expire while your Prepaid plan is active. If
your credit runs out you have one week to top up your
balance. If you fail to do so your files will revert to the
regular file expiry rules.
</p>
</Tooltip>
</div>
<div class="left_col">
Download page customization
</div>
<div class="feature_cell free_feat">
<span class="bold">None</span>
</div>
<div class="feature_cell pro_feat">
None
</div>
<div class="feature_cell prepaid_feat">
Theme, background, header and footer image can be customized
</div>
<div class="left_col">
Max file size
</div>
<div class="feature_cell free_feat">
<span class="bold">20 GB</span> per file
</div>
<div class="feature_cell span2 right pro_feat">
<span class="bold">100 GB</span> per file
</div>
<div class="left_col">
Payment processors
</div>
<div class="feature_cell free_feat">
<span class="bold">None</span>
</div>
<div class="feature_cell pro_feat">
<span class="bold">PayPal</span>, <span class="bold">Credit/debit</span>
</div>
<div class="feature_cell prepaid_feat">
<span class="bold">PayPal</span>,
<span class="bold">Bitcoin</span>,
<span class="bold">Litecoin</span>,
<span class="bold">Monero</span>
</div>
<div></div>
<div class="bottom_row free_feat">
Free
</div>
<div class="bottom_row pro_feat">
{#if $user !== null && $user.subscription.id === "patreon_1"}
You have this plan<br/>
Thank you for supporting Nova!
{:else}
<a href="https://www.patreon.com/join/nova/checkout?rid=5736701&cadence=1" class="button button_highlight round">
€ 4 per month
</a>
or
<a href="https://www.patreon.com/join/nova/checkout?rid=5736701&cadence=12" class="button button_highlight round">
€ 40 per year!
</a>
<br/>
(Excluding tax)
<br/>
Subscription managed by Patreon
{/if}
</div>
<div class="bottom_row prepaid_feat">
{#if $user === null}
<!-- User is not logged in -->
Account required<br/>
<a href="/login?redirect=/user/subscription/manage" class="button button_highlight round">
<i class="icon">login</i>
Log in
</a>
or
<a href="/register?redirect=/user/subscription/manage" class="button button_highlight round">
<i class="icon">how_to_reg</i>
Register
</a>
<br/>
Payments processed by Mollie<br/>
No recurring payments
{:else}
<!-- User is logged in -->
{#if $user.subscription.type === ""}
<a href="/user/subscription/manage#deposit" class="button button_highlight round">
Deposit credit
</a>
to activate Prepaid
{:else if $user.subscription.type === "patreon"}
Patreon subscription active. Prepaid cannot be activated
{:else if $user.subscription.type === "prepaid"}
Prepaid subscription is active<br/>
Current balance <Euro amount={$user.balance_micro_eur}/>
<br/>
<a href="/user/subscription/manage#deposit" class="button button_highlight round">
Top up my credit
</a>
{/if}
{/if}
</div>
</div>
</div>
<section>
<h2>Other plans available on Patreon</h2>
<OtherPlans/>
</section>
<style>
.bold {
font-weight: bold;
}
.horizontal_scroll {
overflow-x: scroll;
overflow-y: hidden;
width: 100%;
padding: 10px;
}
.grid {
display: grid;
grid-auto-flow: row;
grid-template-columns: 9em 1fr 1fr 1fr;
min-width: 50em;
max-width: 70em;
gap: 5px;
margin: auto;
}
.grid > div {
justify-content: center;
align-items: center;
align-content: center;
text-align: center;
padding: 0.25em;
min-height: 3em;
}
.left_col {
border-top-left-radius: 6px;
border-bottom-left-radius: 6px;
border: 1px solid var(--separator);
}
.top_row {
border-top-left-radius: 6px;
border-top-right-radius: 6px;
}
.bottom_row {
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
border: 1px solid var(--separator);
font-weight: bold;
}
.feature_cell {
background-color: var(--card_color);
border: 1px solid var(--background_color);
}
.pro_feat {
border: 1px solid var(--accent_color);
}
.free_feat {
border: 1px solid #ebcb8b;
}
.prepaid_feat {
border: 1px solid #ec2cfa;
}
.span2 {
grid-column: span 2;
}
.span2.left {
border-image: linear-gradient(to right, #ebcb8b 0%, var(--accent_color) 100%) 1;
}
.span2.right {
border-image: linear-gradient(to right, var(--accent_color) 0%, #ec2cfa 100%) 1;
}
</style>

View File

@@ -208,6 +208,20 @@ export const subscription_periods: SubscriptionPeriod[] = [
// same value the server divides the monthly fee by
export const days_per_month = 365.25 / 12
// The price of the subscription period the user is in. This is also how far
// the balance can go below zero before usage above the plan is refused
export const period_price = (u: User) => {
const period = subscription_periods.find(p => p.months === u.subscription_months)
return period === undefined ? 0 :
u.subscription.monthly_fee * period.months * period.discount
}
// Whether usage above a limit of the plan is billed instead of refused. price
// is what the plan charges for that usage. The server does the same check in
// billsExtra
export const bills_extra = (u: User, price: number) =>
u.usage_based_billing && price > 0 && u.balance_micro_eur >= -period_price(u)
export const get_misc_subscriptions = async () => {
return await check_response(
await fetch(get_endpoint() + "/misc/subscriptions")

View File

@@ -2,6 +2,7 @@
import ProgressBar from "util/ProgressBar.svelte";
import { formatDataVolume, formatEuros } from "util/Formatting"
import { user } from "lib/UserStore";
import { bills_extra } from "lib/NovaAPI";
let { total = 0, used = 0 }: {
total?: number;
@@ -10,8 +11,11 @@ let { total = 0, used = 0 }: {
let frac = $derived(used / total)
let bill_shock = $derived($user !== null && $user.monthly_transfer_cap === total)
let billable = $derived($user !== null && $user.subscription.price_per_tb_bandwidth > 0)
let billed = $derived(billable && $user.usage_based_billing)
let price = $derived($user === null ? 0 : $user.subscription.price_per_tb_bandwidth)
let billed = $derived($user !== null && bills_extra($user, price))
// Usage based billing is on, but the balance is too far below zero for it
let overdrawn = $derived($user !== null && $user.usage_based_billing && price > 0 && !billed)
let included = $derived($user === null ? 0 : $user.subscription.monthly_transfer_cap)
</script>
@@ -50,6 +54,14 @@ let included = $derived($user === null ? 0 : $user.subscription.monthly_transfer
This is your bill shock limit. <a href="/user/sharing">Increase
or disable the limit</a> to continue sharing files.
</p>
{:else if overdrawn}
<p>
Your balance is too far below zero to bill more data transfer.
Top up your credit to continue sharing files.
</p>
<a class="button button_highlight" href="/user/subscription/manage">
<i class="icon">paid</i> Top up credit
</a>
{:else}
<p>
Upgrade your plan or enable

View File

@@ -3,11 +3,12 @@ import { onMount } from "svelte";
import { formatDataVolume, formatEuros } from "util/Formatting";
import ProgressBar from "util/ProgressBar.svelte";
import { loading_run } from "lib/Loading";
import { get_endpoint, put_user } from 'lib/NovaAPI';
import { get_endpoint, period_price, put_user } from 'lib/NovaAPI';
import SuccessMessage from 'util/SuccessMessage.svelte';
import { user } from "lib/UserStore";
let success_message: SuccessMessage = $state()
let transfer_cap = $state($user === null ? 0 : $user.monthly_transfer_cap / 1e12)
const update_user = async (e: SubmitEvent) => {
@@ -99,6 +100,12 @@ onMount(async () => {
{formatEuros($user.subscription.price_per_tb_bandwidth, 2)} per TB
for data transfer.
</p>
<p>
This usage is charged from your account credit afterwards. When
your balance is more than {formatEuros(period_price($user), 2)} below zero
(the price of your subscription period) the limits of your plan
apply again, until you top up your credit.
</p>
<label>
<input type="checkbox" checked={$user.usage_based_billing} onchange={set_usage_based_billing}/>
Enable usage based billing
@@ -125,10 +132,10 @@ onMount(async () => {
<ProgressBar used={transfer_used} total={transfer_cap*1e12} warn></ProgressBar>
<p>
The billshock limit limits how much bandwidth your account can use in a
30 day window. When this limit is reached hotlinking will be disabled
and you will no longer be charged for bandwidth usage. This is mostly
useful for the Prepaid subscription, but it works for Patreon plans too.
Set to 0 to disable the limit.
30 day window. When this limit is reached your files can't be downloaded
until your usage drops below it, and you will no longer be charged for
bandwidth usage. This is mostly useful with usage based billing, but it
works without it too. Set to 0 to disable the limit.
</p>
<h2>Embedding controls</h2>

View File

@@ -2,6 +2,7 @@
import { formatDataVolume, formatEuros } from "util/Formatting"
import ProgressBar from "util/ProgressBar.svelte";
import { user } from "lib/UserStore";
import { bills_extra } from "lib/NovaAPI";
let { total = 0, used = 0 }: {
total?: number;
@@ -9,8 +10,11 @@ let { total = 0, used = 0 }: {
} = $props();
let frac = $derived(total <= 0 ? 0 : used / total)
let billable = $derived($user !== null && $user.subscription.price_per_tb_storage > 0)
let billed = $derived(billable && $user.usage_based_billing)
let price = $derived($user === null ? 0 : $user.subscription.price_per_tb_storage)
let billed = $derived($user !== null && bills_extra($user, price))
// Usage based billing is on, but the balance is too far below zero for it
let overdrawn = $derived($user !== null && $user.usage_based_billing && price > 0 && !billed)
</script>
<ProgressBar total={total} used={used} warn></ProgressBar>
@@ -35,6 +39,17 @@ let billed = $derived(billable && $user.usage_based_billing)
month.
</p>
{/if}
{:else if frac > 1 && overdrawn}
<div class="highlight_yellow">
<p>
You are storing more than your plan includes, and your balance is
too far below zero to bill more storage. You can't upload new files
until you top up your credit. Your existing files stay available.
</p>
<a class="button button_highlight" href="/user/subscription/manage">
<i class="icon">paid</i> Top up credit
</a>
</div>
{:else if frac > 1}
<div class="highlight_yellow">
<p>

View File

@@ -99,11 +99,13 @@ onMount(() => {
<h2>Transaction log</h2>
<p>
Here is a log of all transactions on your account balance. Usage is
calculated per day. The storage charge is divided by the average number
of days in a month (30.4375).
calculated per day. Storage and bandwidth are only charged for what you
use above the amounts included in your plan, and that is the usage which
is listed here. The storage charge is divided by the average number of
days in a month (30.4375).
</p>
<p>
Example: If you have 2 TB stored on your Nova account at €4 per TB
Example: If you store 2 TB more than your plan includes at €4 per TB
then the daily charge will be:<br/>
2 TB * € 4 = € 8<br/>
@@ -129,11 +131,11 @@ onMount(() => {
</li>
<li>
Storage charge: <Euro amount={transactions.total_storage_charge} precision="4"/>
(used {formatDataVolume(transactions.total_storage_used, 3)})
({formatDataVolume(transactions.total_storage_used, 3)} above your plan)
</li>
<li>
Bandwidth charge: <Euro amount={transactions.total_bandwidth_charge} precision="4"/>
(used {formatDataVolume(transactions.total_bandwidth_used, 3)})
({formatDataVolume(transactions.total_bandwidth_used, 3)} above your plan)
</li>
<li>
Total charge: <Euro amount={transactions.total_deducted} precision="4"/>

View File

@@ -137,14 +137,14 @@ onMount(() => {
<td>Storage charge</td>
<td>
<Euro amount={transactions.total_storage_charge} precision="4"/>
(used {formatDataVolume(transactions.total_storage_used, 3)})
({formatDataVolume(transactions.total_storage_used, 3)} above your plan)
</td>
</tr>
<tr>
<td>Bandwidth charge</td>
<td>
<Euro amount={transactions.total_bandwidth_charge} precision="4"/>
(used {formatDataVolume(transactions.total_bandwidth_used, 3)})
({formatDataVolume(transactions.total_bandwidth_used, 3)} above your plan)
</td>
</tr>
<tr>

View File

@@ -1,7 +1,33 @@
<script lang="ts">
import { days_per_month, subscription_periods } from "lib/NovaAPI";
import { user } from "lib/UserStore";
import Euro from "util/Euro.svelte";
import { formatDataVolume } from "util/Formatting";
import { formatDataVolume, formatDate, formatEuros } from "util/Formatting";
// What the account costs per day at its current usage, the same sum the server
// charges every night. Data transfer above the plan is left out, it can't be
// predicted
let daily_cost = $derived.by(() => {
if ($user === null || $user.subscription.type !== "subscription") {
return 0
}
const sub = $user.subscription
const discount = subscription_periods.find(
p => p.months === $user.subscription_months,
)?.discount ?? 1
const extra_storage = $user.usage_based_billing ?
Math.max(0, $user.storage_space_used - sub.storage_limit) *
sub.price_per_tb_storage / 1e12 : 0
return (sub.monthly_fee * discount + extra_storage) / days_per_month
})
let credit_until = $derived.by(() => {
const date = new Date()
date.setDate(date.getDate() + Math.floor($user.balance_micro_eur / daily_cost))
return formatDate(date, false, false, false)
})
</script>
{#if $user !== null}
@@ -16,6 +42,18 @@ import { formatDataVolume } from "util/Formatting";
<li>
Current account balance: <Euro amount={$user.balance_micro_eur}></Euro><br/>
{#if daily_cost > 0}
Your plan costs {formatEuros(daily_cost, 4)} per day at your
current usage.
{#if $user.balance_micro_eur > 0}
Your credit lasts until about {credit_until}.
{/if}
{#if $user.usage_based_billing}
Data transfer above your plan is not included.
{/if}
<br/>
{/if}
<i class="icon">account_balance_wallet</i>
<a href="/user/subscription/manage">Deposit credit</a><br/>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { get_endpoint, get_user, type User } from "lib/NovaAPI";
import { bills_extra, get_endpoint, get_user, type User } from "lib/NovaAPI";
import { onMount } from "svelte";
import HotlinkProgressBar from "user_home/HotlinkProgressBar.svelte";
import StorageProgressBar from "user_home/StorageProgressBar.svelte";
@@ -37,7 +37,7 @@ onMount(async () => {
// The lowest limit which blocks downloads. With usage based billing the
// plan's limit is billed instead, only the bill shock limit blocks
let caps = [user.monthly_transfer_cap]
if (!user.usage_based_billing || user.subscription.price_per_tb_bandwidth <= 0) {
if (!bills_extra(user, user.subscription.price_per_tb_bandwidth)) {
caps.push(user.subscription.monthly_transfer_cap)
}
caps = caps.filter(cap => cap > 0)