feat: usage-based billing (#3247)

* feat: usage-based billing

* fix: missing field

* fix: sqlx build
This commit is contained in:
HugoCasa
2024-02-19 21:09:59 +01:00
committed by GitHub
parent 37e80ca1a4
commit 4679d74370
10 changed files with 175 additions and 30 deletions

View File

@@ -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"

View 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"
}

View 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"
}

View File

@@ -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"
}

View File

@@ -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"

View File

@@ -0,0 +1 @@
-- Add down migration script here

View File

@@ -0,0 +1,3 @@
-- Add up migration script here
ALTER TABLE workspace_settings
ADD COLUMN automatic_billing BOOLEAN NOT NULL DEFAULT FALSE;

View File

@@ -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

View File

@@ -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)]

View File

@@ -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>