feat: usage-based billing (#3247)
* feat: usage-based billing * fix: missing field * fix: sqlx build
This commit is contained in:
@@ -102,6 +102,11 @@
|
||||
"ordinal": 19,
|
||||
"name": "auto_add",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 20,
|
||||
"name": "automatic_billing",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -129,7 +134,8 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "1730f39fd1793d45fbb41b21389c61296a3ff7489ae12f52a19f9543173ac597"
|
||||
|
||||
14
backend/.sqlx/query-1e659916501e668913e591eb7282d3881fa44468ed5330a501daa0d61c84cb71.json
generated
Normal file
14
backend/.sqlx/query-1e659916501e668913e591eb7282d3881fa44468ed5330a501daa0d61c84cb71.json
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_settings SET automatic_billing = false WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "1e659916501e668913e591eb7282d3881fa44468ed5330a501daa0d61c84cb71"
|
||||
}
|
||||
14
backend/.sqlx/query-2d4d4564108376ab310cb4a50fa2fa84fefbb3df8bb1bc9996c40a86d464b8a1.json
generated
Normal file
14
backend/.sqlx/query-2d4d4564108376ab310cb4a50fa2fa84fefbb3df8bb1bc9996c40a86d464b8a1.json
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE workspace_settings SET automatic_billing = TRUE WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "2d4d4564108376ab310cb4a50fa2fa84fefbb3df8bb1bc9996c40a86d464b8a1"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT premium, usage.usage as \"usage?\", workspace_settings.customer_id, workspace_settings.plan FROM workspace LEFT JOIN workspace_settings ON workspace_settings.workspace_id = $1 LEFT JOIN usage ON usage.id = $1 AND month_ = EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date) AND usage.is_workspace IS true WHERE workspace.id = $1",
|
||||
"query": "SELECT premium, usage.usage as \"usage?\", workspace_settings.customer_id, workspace_settings.plan, workspace_settings.automatic_billing FROM workspace LEFT JOIN workspace_settings ON workspace_settings.workspace_id = $1 LEFT JOIN usage ON usage.id = $1 AND month_ = EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date) AND usage.is_workspace IS true WHERE workspace.id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -22,6 +22,11 @@
|
||||
"ordinal": 3,
|
||||
"name": "plan",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "automatic_billing",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -33,8 +38,9 @@
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "4999995f4459da32a0e2fc7115eae97fb49b0080075fb01e0863d164ba9a5f13"
|
||||
"hash": "49a8fe5c538d8fc26f6d8ece5b30dd02a128f571039714e61525e2bb86591d93"
|
||||
}
|
||||
@@ -102,6 +102,11 @@
|
||||
"ordinal": 19,
|
||||
"name": "auto_add",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 20,
|
||||
"name": "automatic_billing",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -129,7 +134,8 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "5445083864b2b092b012e894bff7630a1d7b9deb8d33e9f909061f351f96844e"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
-- Add down migration script here
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Add up migration script here
|
||||
ALTER TABLE workspace_settings
|
||||
ADD COLUMN automatic_billing BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -1197,6 +1197,8 @@ paths:
|
||||
type: boolean
|
||||
plan:
|
||||
type: string
|
||||
automatic_billing:
|
||||
type: boolean
|
||||
customer_id:
|
||||
type: string
|
||||
webhook:
|
||||
@@ -1221,6 +1223,7 @@ paths:
|
||||
type: string
|
||||
required:
|
||||
- code_completion_enabled
|
||||
- automatic_billing
|
||||
|
||||
/w/{workspace}/workspaces/get_deploy_to:
|
||||
get:
|
||||
@@ -1282,8 +1285,42 @@ paths:
|
||||
type: number
|
||||
seats:
|
||||
type: number
|
||||
automatic_billing:
|
||||
type: boolean
|
||||
required:
|
||||
- premium
|
||||
- automatic_billing
|
||||
|
||||
/w/{workspace}/workspaces/set_automatic_billing:
|
||||
post:
|
||||
summary: set automatic billing
|
||||
operationId: setAutomaticBilling
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
description: automatic billing
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
automatic_billing:
|
||||
type: boolean
|
||||
seats:
|
||||
type: number
|
||||
required:
|
||||
- automatic_billing
|
||||
|
||||
responses:
|
||||
"200":
|
||||
description: status
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/edit_slack_command:
|
||||
post:
|
||||
@@ -5175,7 +5212,6 @@ paths:
|
||||
application/json:
|
||||
schema: {}
|
||||
|
||||
|
||||
/w/{workspace}/jobs_u/completed/get/{id}:
|
||||
get:
|
||||
summary: get completed job
|
||||
|
||||
@@ -158,6 +158,7 @@ pub struct WorkspaceSettings {
|
||||
pub large_file_storage: Option<serde_json::Value>, // effectively: DatasetsStorage
|
||||
pub git_sync: Option<serde_json::Value>, // effectively: WorkspaceGitSyncSettings
|
||||
pub default_app: Option<String>,
|
||||
pub automatic_billing: bool,
|
||||
}
|
||||
|
||||
#[derive(FromRow, Serialize, Debug)]
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { capitalize } from '$lib/utils'
|
||||
import { capitalize, sendUserToast } from '$lib/utils'
|
||||
import DataTable from '$lib/components/table/DataTable.svelte'
|
||||
import Cell from '$lib/components/table/Cell.svelte'
|
||||
import { WorkspaceService, type User, UserService } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { Button } from '../common'
|
||||
import Tooltip from '../Tooltip.svelte'
|
||||
import { ExternalLink } from 'lucide-svelte'
|
||||
import { ExternalLink, Loader2 } from 'lucide-svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import Toggle from '../Toggle.svelte'
|
||||
|
||||
export let plan: string | undefined
|
||||
export let customer_id: string | undefined
|
||||
@@ -24,6 +25,7 @@
|
||||
seatsFromUsers: number
|
||||
seatsFromComps: number
|
||||
usedSeats: number
|
||||
automatic_billing: boolean
|
||||
}
|
||||
| undefined = undefined
|
||||
const plans = {
|
||||
@@ -75,6 +77,26 @@
|
||||
usedSeats
|
||||
}
|
||||
}
|
||||
|
||||
let billingModeLoading = false
|
||||
async function setAutomaticBilling(ev) {
|
||||
try {
|
||||
billingModeLoading = true
|
||||
console.log('toggle check', ev.detail)
|
||||
await WorkspaceService.setAutomaticBilling({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
automatic_billing: ev.detail,
|
||||
seats: premiumInfo?.usedSeats
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
sendUserToast("Couldn't update billing mode: " + err, true)
|
||||
} finally {
|
||||
await loadPremiumInfo()
|
||||
billingModeLoading = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="my-8" />
|
||||
@@ -98,10 +120,29 @@
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{#if plan}
|
||||
<div class="text-base inline font-bold leading-8 mb-2">
|
||||
Current plan: {capitalize(plan)} plan{plan === 'team'
|
||||
Current plan: {capitalize(plan)} plan{plan === 'team' && premiumInfo.automatic_billing
|
||||
? ' (usage-based)'
|
||||
: plan === 'team'
|
||||
? ` (${premiumInfo.seats} seat${premiumInfo.seats > 1 ? 's' : ''})`
|
||||
: ''}
|
||||
</div>
|
||||
{#if plan === 'team'}
|
||||
<div class="flex flex-row items-center gap-2 mb-4">
|
||||
<Toggle
|
||||
checked={premiumInfo?.automatic_billing}
|
||||
options={{
|
||||
right: 'Automatic billing based on usage',
|
||||
rightTooltip:
|
||||
'You will be billed for the maximum number of seats used in a given billing period.'
|
||||
}}
|
||||
on:change={setAutomaticBilling}
|
||||
disabled={billingModeLoading}
|
||||
/>
|
||||
{#if billingModeLoading}
|
||||
<Loader2 class="animate-spin" />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="inline text-lg font-bold">Current plan: Free plan</div>
|
||||
{/if}
|
||||
@@ -172,13 +213,17 @@
|
||||
><div
|
||||
class={twMerge(
|
||||
'font-semibold',
|
||||
plan === 'team' && premiumInfo.usedSeats > premiumInfo.seats
|
||||
plan === 'team' &&
|
||||
premiumInfo.usedSeats > premiumInfo.seats &&
|
||||
!premiumInfo.automatic_billing
|
||||
? 'text-red-500'
|
||||
: ''
|
||||
)}
|
||||
>Used seats <Tooltip
|
||||
>Highest between seats from developers + operators and seats from computations
|
||||
</Tooltip>{plan === 'team' && premiumInfo.usedSeats > premiumInfo.seats
|
||||
</Tooltip>{plan === 'team' &&
|
||||
premiumInfo.usedSeats > premiumInfo.seats &&
|
||||
!premiumInfo.automatic_billing
|
||||
? ' > Paid seats'
|
||||
: ''}</div
|
||||
></Cell
|
||||
@@ -187,13 +232,17 @@
|
||||
<div
|
||||
class={twMerge(
|
||||
'text-base font-bold',
|
||||
plan === 'team' && premiumInfo.usedSeats > premiumInfo.seats
|
||||
plan === 'team' &&
|
||||
premiumInfo.usedSeats > premiumInfo.seats &&
|
||||
!premiumInfo.automatic_billing
|
||||
? 'text-red-500'
|
||||
: ''
|
||||
)}
|
||||
>
|
||||
max(u, c) = max({premiumInfo.seatsFromUsers}, {premiumInfo.seatsFromComps}) = {premiumInfo.usedSeats}{premiumInfo.usedSeats >
|
||||
premiumInfo.seats
|
||||
max(u, c) = max({premiumInfo.seatsFromUsers}, {premiumInfo.seatsFromComps}) = {premiumInfo.usedSeats}{plan ===
|
||||
'team' &&
|
||||
premiumInfo.usedSeats > premiumInfo.seats &&
|
||||
!premiumInfo.automatic_billing
|
||||
? ` > ${premiumInfo.seats}`
|
||||
: ''}
|
||||
</div>
|
||||
@@ -202,7 +251,7 @@
|
||||
</tbody>
|
||||
</DataTable>
|
||||
|
||||
{#if plan === 'team' && premiumInfo.usedSeats > premiumInfo.seats}
|
||||
{#if plan === 'team' && premiumInfo.usedSeats > premiumInfo.seats && !premiumInfo.automatic_billing}
|
||||
<p class="text-red-500 mt-2 text-right text-base"
|
||||
>You have exceeded your allowed number of seats, please update your plan in the
|
||||
customer portal.
|
||||
@@ -220,16 +269,22 @@
|
||||
<div class="text-base font-bold leading-8 mb-2 pt-8"> All plans </div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{#each Object.entries(plans) as [planTitle, planDesc]}
|
||||
<div class="box p-4 text-xs flex flex-col h-full overflow-hidden prose-sm rounded-md">
|
||||
<h2 class="mb-4 {planTitle === 'Team' ? 'text-blue-500' : planTitle === 'Enterprise' ? 'text-teal-600' : ''}">
|
||||
{planTitle}
|
||||
</h2>
|
||||
<ul class="list-disc text-sm p-4">
|
||||
{#each planDesc as item}
|
||||
<li class="mt-2">{@html item}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#each Object.entries(plans) as [planTitle, planDesc]}
|
||||
<div class="box p-4 text-xs flex flex-col h-full overflow-hidden prose-sm rounded-md">
|
||||
<h2
|
||||
class="mb-4 {planTitle === 'Team'
|
||||
? 'text-blue-500'
|
||||
: planTitle === 'Enterprise'
|
||||
? 'text-teal-600'
|
||||
: ''}"
|
||||
>
|
||||
{planTitle}
|
||||
</h2>
|
||||
<ul class="list-disc text-sm p-4">
|
||||
{#each planDesc as item}
|
||||
<li class="mt-2">{@html item}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<div class="grow" />
|
||||
{#if planTitle == 'Team'}
|
||||
@@ -257,7 +312,12 @@
|
||||
{:else if planTitle == 'Enterprise'}
|
||||
{#if plan != 'enterprise'}
|
||||
<div class="mt-4 mx-auto">
|
||||
<Button size="xs" color="bg-teal-600 text-white" href="https://www.windmill.dev/pricing" target="_blank">
|
||||
<Button
|
||||
size="xs"
|
||||
color="bg-teal-600 text-white"
|
||||
href="https://www.windmill.dev/pricing"
|
||||
target="_blank"
|
||||
>
|
||||
See more
|
||||
</Button>
|
||||
</div>
|
||||
@@ -270,16 +330,14 @@
|
||||
Cancel your plan in the customer portal to downgrade to the free plan
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mx-auto font-semibold text-center">
|
||||
Workspace is on the free plan
|
||||
</div>
|
||||
<div class="mx-auto font-semibold text-center"> Workspace is on the free plan </div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 my-8 w-full items-center">
|
||||
<div class="text-primary text-md font-semibold"> Frequently asked questions </div><br/>
|
||||
<div class="text-primary text-md font-semibold"> Frequently asked questions </div><br />
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<div class="text-sm mb-1 text-secondary font-medium"> What is an execution? </div>
|
||||
|
||||
Reference in New Issue
Block a user