team plans billing threshold alert (#5078)

* team plans billing threshold alert

* nits

* tmp ee ref

* update ee ref
This commit is contained in:
HugoCasa
2025-01-17 14:06:47 +01:00
committed by GitHub
parent b7763cff8a
commit 65f4c2ec96
9 changed files with 331 additions and 44 deletions
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO cloud_workspace_settings (workspace_id, threshold_alert_amount) VALUES ($1, $2) ON CONFLICT (workspace_id) DO UPDATE SET threshold_alert_amount = $2, last_alert_sent = NULL",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Int4"
]
},
"nullable": []
},
"hash": "c4e0f3eab227a798d9cd7478db50a7c1a588e69c2c8d9a1def9276ba326453d2"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT threshold_alert_amount, last_alert_sent FROM cloud_workspace_settings WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "threshold_alert_amount",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "last_alert_sent",
"type_info": "Timestamp"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true,
true
]
},
"hash": "f74589aaa72d0eb9f44157cc60eb121e63a7f31ed849834bb8ac9e8c726e81f1"
}
@@ -1,30 +1,35 @@
{
"db_name": "PostgreSQL",
"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",
"query": "SELECT owner, 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": [
{
"ordinal": 0,
"name": "owner",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "premium",
"type_info": "Bool"
},
{
"ordinal": 1,
"ordinal": 2,
"name": "usage?",
"type_info": "Int4"
},
{
"ordinal": 2,
"ordinal": 3,
"name": "customer_id",
"type_info": "Varchar"
},
{
"ordinal": 3,
"ordinal": 4,
"name": "plan",
"type_info": "Varchar"
},
{
"ordinal": 4,
"ordinal": 5,
"name": "automatic_billing",
"type_info": "Bool"
}
@@ -35,6 +40,7 @@
]
},
"nullable": [
false,
false,
false,
true,
@@ -42,5 +48,5 @@
false
]
},
"hash": "49a8fe5c538d8fc26f6d8ece5b30dd02a128f571039714e61525e2bb86591d93"
"hash": "f8f893fb4f6f8c16bffb233a585df0b8c007c24993979fdc013aa57583905f50"
}
+1 -1
View File
@@ -1 +1 @@
fb5e285c316f10fa2188318585573c7e85999784
ddcfdfc18a9833a5fc4e62ad62a265ef1e06a0aa
@@ -0,0 +1 @@
DROP TABLE IF EXISTS cloud_workspace_settings;
@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS cloud_workspace_settings (
workspace_id VARCHAR(50) NOT NULL,
threshold_alert_amount INT NULL,
last_alert_sent TIMESTAMP NULL,
PRIMARY KEY (workspace_id),
FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE
)
+52 -1
View File
@@ -1812,10 +1812,12 @@ paths:
type: number
automatic_billing:
type: boolean
owner:
type: string
required:
- premium
- automatic_billing
- owner
/w/{workspace}/workspaces/set_automatic_billing:
post:
summary: set automatic billing
@@ -1847,6 +1849,55 @@ paths:
schema:
type: string
/w/{workspace}/workspaces/threshold_alert:
get:
summary: get threshold alert info
operationId: getThresholdAlert
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: status
content:
application/json:
schema:
type: object
properties:
threshold_alert_amount:
type: number
last_alert_sent:
type: string
format: date-time
post:
summary: set threshold alert info
operationId: setThresholdAlert
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
description: threshold alert info
required: true
content:
application/json:
schema:
type: object
properties:
threshold_alert_amount:
type: number
responses:
"200":
description: status
content:
text/plain:
schema:
type: string
/w/{workspace}/workspaces/edit_slack_command:
post:
summary: edit slack command
+5 -5
View File
@@ -7,6 +7,8 @@
export let value = typeof initialValue === 'string' ? parseInt(initialValue) : initialValue
export let disabled: boolean = false
export let defaultValue: number | undefined = undefined
export let format: (value: number) => string = (v) => `${v}`
export let hideInput: boolean = false
let step: number = 1
@@ -23,10 +25,6 @@
$: axisStep = calculateAxisStep(min, max)
const format = (v, i, p) => {
return `${v}`
}
function handleKeyDown(event: KeyboardEvent) {
if (disabled) return
@@ -85,7 +83,9 @@
{/if}
</div>
<input bind:value type="number" class="!w-16 h-8 !text-xs mb-6" {max} {min} {disabled} />
{#if !hideInput}
<input bind:value type="number" class="!w-16 h-8 !text-xs mb-6" {max} {min} {disabled} />
{/if}
</div>
<style>
@@ -1,15 +1,20 @@
<script lang="ts">
import { base } from '$lib/base'
import { capitalize, sendUserToast } from '$lib/utils'
import { capitalize, pluralize, 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, Loader2 } from 'lucide-svelte'
import { ExternalLink, Loader2, Pen, X } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import Toggle from '../Toggle.svelte'
import Section from '../Section.svelte'
import Range from '../Range.svelte'
import Label from '../Label.svelte'
import Modal from '../common/modal/Modal.svelte'
import { slide } from 'svelte/transition'
export let plan: string | undefined
export let customer_id: string | undefined
@@ -24,9 +29,10 @@
developerNb: number
operatorNb: number
seatsFromUsers: number
seatsFromComps: number
seatsFromExtraComps: number
usedSeats: number
automatic_billing: boolean
owner: string
}
| undefined = undefined
const plans = {
@@ -54,6 +60,7 @@
if ($workspaceStore) {
loadPremiumInfo()
listUsers()
getThresholdAlert()
}
}
@@ -66,16 +73,17 @@
const developerNb = users?.filter((x) => !x.operator)?.length ?? 0
const operatorNb = users?.filter((x) => x.operator)?.length ?? 0
const seatsFromUsers = Math.ceil(developerNb + operatorNb / 2)
const seatsFromComps = Math.ceil((info.usage ?? 0) / 10000)
const usedSeats = Math.max(seatsFromUsers, seatsFromComps)
const seatsFromExtraComps = Math.max(Math.ceil((info.usage ?? 0) / 10000) - seatsFromUsers, 0)
const usedSeats = seatsFromUsers + seatsFromExtraComps
premiumInfo = {
...info,
usage: info.usage ?? 0,
seats: info.seats ?? 1,
owner: info.owner,
developerNb,
operatorNb,
seatsFromUsers,
seatsFromComps,
seatsFromExtraComps,
usedSeats
}
}
@@ -84,7 +92,6 @@
async function setAutomaticBilling(ev) {
try {
billingModeLoading = true
console.log('toggle check', ev.detail)
await WorkspaceService.setAutomaticBilling({
workspace: $workspaceStore!,
requestBody: {
@@ -99,8 +106,69 @@
billingModeLoading = false
}
}
let thresholdAlert:
| {
threshold_alert_amount?: number
last_alert_sent?: string
}
| undefined = undefined
let newThresholdAlertAmount: number | undefined = undefined
let thresholdAlertOpen = false
async function getThresholdAlert() {
thresholdAlert = await WorkspaceService.getThresholdAlert({ workspace: $workspaceStore! })
}
async function setThresholdAlert() {
if (thresholdAlert) {
await WorkspaceService.setThresholdAlert({
workspace: $workspaceStore!,
requestBody: {
threshold_alert_amount: newThresholdAlertAmount
}
})
await getThresholdAlert()
sendUserToast('Threshold alert updated')
}
}
let estimatedDevs = 1
let estimatedOps = 0
$: estimatedSeats = estimatedDevs + Math.ceil(estimatedOps / 2)
let estimatedExecs = 1
function updateExecs() {
if (estimatedExecs < estimatedSeats) {
estimatedExecs = estimatedSeats
}
}
$: estimatedSeats && updateExecs()
</script>
<Modal bind:open={thresholdAlertOpen} title="Threshold alert">
<div class="flex flex-col gap-4">
<label class="block">
<span class="text-secondary text-sm">Threshold amount in $</span>
<input type="number" bind:value={newThresholdAlertAmount} />
</label>
</div>
<svelte:fragment slot="actions">
<Button
size="sm"
on:click={() => {
setThresholdAlert()
thresholdAlertOpen = false
}}
>
Save
</Button>
</svelte:fragment>
</Modal>
<div class="flex flex-col gap-4 my-8">
<div class="flex flex-col gap-1">
<div class=" text-primary text-lg font-semibold">Plans</div>
@@ -121,9 +189,9 @@
<div class="text-xs my-4">
{#if premiumInfo?.premium}
<div class="flex flex-col gap-0.5">
<div class="flex flex-col">
{#if plan}
<div class="text-base inline font-bold leading-8 mb-2">
<div class="text-base inline font-bold leading-8">
Current plan: {capitalize(plan)} plan{plan === 'team' && premiumInfo.automatic_billing
? ' (usage-based)'
: plan === 'team'
@@ -131,20 +199,22 @@
: ''}
</div>
{#if plan === 'team'}
<div class="flex flex-row items-center gap-2 mb-4">
<Toggle
checked={premiumInfo?.automatic_billing}
options={{
left: 'Static number of seats',
leftTooltip:
'You will be billed for a fixed number of seats, you have to manually adapt the number of seats in the customer portal based on your usage.',
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}
/>
<div class="flex flex-row items-center gap-2 mt-2">
{#if !premiumInfo?.automatic_billing}
<Toggle
checked={premiumInfo?.automatic_billing}
options={{
left: 'Static number of seats',
leftTooltip:
'You will be billed for a fixed number of seats, you have to manually adapt the number of seats in the customer portal based on your usage.',
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}
{#if billingModeLoading}
<Loader2 class="animate-spin" />
{/if}
@@ -155,6 +225,54 @@
{/if}
{#if plan}
{#if premiumInfo?.automatic_billing}
<div class="mb-4 flex flex-col gap-1.5">
<p class="font-semibold text-sm">Billing threshold email alert</p>
<div class="flex flex-row gap-0.5 items-center">
<p class="text-base text-secondary mr-0.5"
>{thresholdAlert?.threshold_alert_amount
? thresholdAlert?.threshold_alert_amount + '$'
: 'Not set'}</p
>
<Button
on:click={() => {
newThresholdAlertAmount = thresholdAlert?.threshold_alert_amount ?? 10
thresholdAlertOpen = true
}}
size="xs"
spacingSize="xs2"
variant="border"
color="light"
iconOnly
startIcon={{
icon: Pen
}}
/>
{#if thresholdAlert?.threshold_alert_amount}
<Button
on:click={() => {
if (thresholdAlert) {
newThresholdAlertAmount = undefined
setThresholdAlert()
}
}}
variant="border"
size="xs"
spacingSize="xs2"
color="light"
iconOnly
startIcon={{
icon: X
}}
/>
{/if}
</div>
<p class="italic text-xs"
>An email notification will be sent to {premiumInfo.owner} if the specified threshold amount
is reached during a given month.</p
>
</div>
{/if}
<div class="w-full">
<DataTable>
<tbody class="divide-y">
@@ -187,9 +305,7 @@
</Cell>
</tr>
<tr>
<Cell first
><div class="font-semibold">Minimum number of seats needed for users</div></Cell
>
<Cell first><div class="font-semibold">Number of seats for users</div></Cell>
<Cell last numeric>
<div class="text-base font-bold">
u = ceil({premiumInfo.developerNb} + {premiumInfo.operatorNb}/2) = {premiumInfo.seatsFromUsers}
@@ -204,14 +320,22 @@
</div>
</Cell>
</tr>
<tr>
<Cell first>Included computations with users (10k per user seat)</Cell>
<Cell last numeric>
<div class="text-base">
- {premiumInfo.seatsFromUsers * 10000}
</div>
</Cell>
</tr>
<tr>
<Cell first
><div class="font-semibold">Minimum number of seats needed for computations</div
></Cell
><div class="font-semibold">Number of seats for extra computations</div></Cell
>
<Cell last numeric>
<div class="text-base font-bold">
c = ceil({premiumInfo.usage} / 10 000) = {premiumInfo.seatsFromComps}
c = ceil({Math.max(0, premiumInfo.usage - premiumInfo.seatsFromUsers * 10000)} /
10 000) = {premiumInfo.seatsFromExtraComps}
</div>
</Cell>
</tr>
@@ -246,8 +370,7 @@
: ''
)}
>
max(u, c) = max({premiumInfo.seatsFromUsers}, {premiumInfo.seatsFromComps}) = {premiumInfo.usedSeats}{plan ===
'team' &&
u + c = {premiumInfo.usedSeats}{plan === 'team' &&
premiumInfo.usedSeats > premiumInfo.seats &&
!premiumInfo.automatic_billing
? ` > ${premiumInfo.seats}`
@@ -273,6 +396,62 @@
{/if}
</div>
<Section collapsable label="Cost estimator">
<div class="border p-4 mb-4 rounded-md" transition:slide>
<Label label="Number of developers">
<Range min={1} max={20} bind:value={estimatedDevs} hideInput />
</Label>
<Label label="Number of operators">
<Range min={0} max={20} bind:value={estimatedOps} hideInput />
</Label>
<Label label="Number of executions">
<Range
min={estimatedSeats}
max={30}
bind:value={estimatedExecs}
format={(v) => `${v * 10}k`}
hideInput
/>
</Label>
<div class="mt-4 text-base">
<div class="flex flex-row justify-between">
<div>Seats for users = {estimatedDevs} devs + {estimatedOps} ops / 2</div>
<div>{pluralize(estimatedSeats, 'seat')}</div>
</div>
<div class="flex flex-row justify-between">
<div
>Extra computations = {estimatedExecs * 10}k execs - {estimatedSeats * 10}k included with
users</div
>
<div
>{(estimatedExecs - estimatedSeats) * 10}k extra execs = {pluralize(
estimatedExecs - estimatedSeats,
'seat'
)}</div
>
</div>
<div class="flex flex-row justify-between font-medium items-center">
<div>Total</div>
<div class="flex flex-col items-end">
<div>
{pluralize(estimatedSeats + (estimatedExecs - estimatedSeats), 'seat')} = {(estimatedSeats +
(estimatedExecs - estimatedSeats)) *
10}$ / month
</div>
<button
class="text-xs text-blue-500 underline"
on:click={() => {
newThresholdAlertAmount = (estimatedSeats + (estimatedExecs - estimatedSeats)) * 10
thresholdAlertOpen = true
}}
>Setup threshold email alert
</button>
</div>
</div>
</div>
</div>
</Section>
<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">