Skip to content

Commit bd981f1

Browse files
Contentrainclaude
andcommitted
feat(billing): add overage billing system with usage dashboard
Implement usage-based overage billing that allows workspace owners to enable per-category overages (AI messages, form submissions, CDN bandwidth, media storage, API messages) instead of hard caps. When enabled, usage past the plan limit is allowed and charged via Stripe invoice items at the end of each billing period. - Add overage_settings JSONB column and overage_billing_log table (migration 020) - Add OVERAGE_PRICING constant with per-unit pricing for 5 metered categories - Implement soft cap mechanism via getEffectiveLimit() — RPC functions unchanged - Modify chat, form submission, and media upload endpoints to support soft caps - Add usage aggregation methods to DatabaseProvider (AI, API, CDN, storage) - Extend PaymentProvider with addInvoiceItem() for Stripe invoice line items - Handle invoice.creating webhook to calculate and bill overages at period end - Add overage-settings GET/PATCH API endpoints with validation and auth - Add usage dashboard API endpoint with projected costs and overage amounts - Add UsageMeter atom with color-coded progress bars (primary/warning/danger) - Add WorkspaceUsagePanel organism with per-category toggles and cost summary - Add WorkspaceOverageHistory organism for past billing log - Integrate usage dashboard into WorkspaceBillingPanel - Add 56 tests (25 unit + 31 integration) covering all overage logic Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c9fd060 commit bd981f1

31 files changed

Lines changed: 2065 additions & 8 deletions
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
<script setup lang="ts">
2+
const props = defineProps<{
3+
current: number
4+
limit: number
5+
unit: string
6+
overageEnabled?: boolean
7+
overageUnits?: number
8+
overageUnitPrice?: number
9+
}>()
10+
11+
const isUnlimited = computed(() => props.limit === -1)
12+
13+
const percentage = computed(() => {
14+
if (isUnlimited.value || props.limit === 0) return 0
15+
return Math.min((props.current / props.limit) * 100, 100)
16+
})
17+
18+
const overagePercentage = computed(() => {
19+
if (!props.overageEnabled || isUnlimited.value || props.limit === 0) return 0
20+
if (props.current <= props.limit) return 0
21+
// Scale overage visually: each 50% overage = 10% of bar width beyond 100%
22+
const overageRatio = (props.current - props.limit) / props.limit
23+
return Math.min(overageRatio * 20, 30) // Cap at 30% additional width
24+
})
25+
26+
const barColor = computed(() => {
27+
if (isUnlimited.value) return 'bg-primary-500'
28+
if (percentage.value >= 100) return 'bg-danger-500'
29+
if (percentage.value >= 80) return 'bg-warning-500'
30+
return 'bg-primary-500'
31+
})
32+
33+
function formatValue(value: number, unit: string): string {
34+
if (unit === 'GB' || unit === 'GB/month') {
35+
return value >= 1 ? `${value.toFixed(1)} GB` : `${Math.round(value * 1024)} MB`
36+
}
37+
return `${Math.round(value)}`
38+
}
39+
40+
function formatLimit(limit: number, unit: string): string {
41+
if (limit === -1) return 'Unlimited'
42+
if (unit === 'GB' || unit === 'GB/month') {
43+
return `${limit} GB`
44+
}
45+
return `${limit}`
46+
}
47+
</script>
48+
49+
<template>
50+
<div>
51+
<!-- Value display -->
52+
<div class="mb-1.5 flex items-baseline justify-between text-sm">
53+
<span class="tabular-nums text-heading dark:text-secondary-100">
54+
{{ formatValue(current, unit) }}
55+
</span>
56+
<span class="text-muted">
57+
/ {{ formatLimit(limit, unit) }} {{ !isUnlimited ? unit : '' }}
58+
</span>
59+
</div>
60+
61+
<!-- Progress bar -->
62+
<div class="relative h-2 w-full overflow-hidden rounded-full bg-secondary-200 dark:bg-secondary-700">
63+
<!-- Base usage bar -->
64+
<div
65+
class="h-full rounded-full transition-all duration-500 ease-out"
66+
:class="barColor"
67+
:style="{ width: `${isUnlimited ? 0 : percentage}%` }"
68+
/>
69+
70+
<!-- Overage extension (red zone past 100%) -->
71+
<div
72+
v-if="overagePercentage > 0"
73+
class="absolute top-0 h-full rounded-r-full bg-danger-400/60 transition-all duration-500"
74+
:style="{ left: '100%', width: `${overagePercentage}%`, marginLeft: '-1px' }"
75+
/>
76+
</div>
77+
78+
<!-- Percentage / overage info -->
79+
<div class="mt-1 flex items-center justify-between text-xs">
80+
<span v-if="!isUnlimited && limit > 0" class="text-muted tabular-nums">
81+
{{ Math.round((current / limit) * 100) }}%
82+
</span>
83+
<span v-if="(overageUnits ?? 0) > 0" class="text-danger-600 dark:text-danger-400 tabular-nums">
84+
+{{ formatValue(overageUnits ?? 0, unit) }} overage
85+
<span v-if="overageUnitPrice">({{ '$' + ((overageUnits ?? 0) * overageUnitPrice).toFixed(2) }})</span>
86+
</span>
87+
</div>
88+
</div>
89+
</template>

app/components/organisms/WorkspaceBillingPanel.vue

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,12 @@ async function handleManageSubscription() {
119119
</div>
120120
</div>
121121

122+
<!-- Usage dashboard -->
123+
<OrganismsWorkspaceUsagePanel
124+
v-if="hasSubscription || effectivePlan !== 'free'"
125+
:workspace-id="workspaceId"
126+
/>
127+
122128
<!-- Plan selection modal -->
123129
<OrganismsPlanSelectionModal
124130
:open="planModalOpen"
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<script setup lang="ts">
2+
const { t } = useContent()
3+
const { activeWorkspace } = useWorkspaces()
4+
5+
const entries = ref<Array<{
6+
id: string
7+
billing_period: string
8+
category: string
9+
units_billed: number
10+
unit_price: number
11+
total_amount: number
12+
created_at: string
13+
}>>([])
14+
const loading = ref(false)
15+
16+
async function fetchHistory() {
17+
const ws = activeWorkspace.value
18+
if (!ws) return
19+
loading.value = true
20+
try {
21+
const data = await $fetch<{ entries: typeof entries.value }>(`/api/workspaces/${ws.id}/overage-history`)
22+
entries.value = data.entries
23+
}
24+
finally {
25+
loading.value = false
26+
}
27+
}
28+
29+
onMounted(fetchHistory)
30+
31+
function formatCategory(key: string): string {
32+
const map: Record<string, string> = {
33+
ai_messages: 'AI Messages',
34+
api_messages: 'API Messages',
35+
cdn_bandwidth: 'CDN Bandwidth',
36+
form_submissions: 'Form Submissions',
37+
media_storage: 'Media Storage',
38+
}
39+
return map[key] ?? key
40+
}
41+
</script>
42+
43+
<template>
44+
<div v-if="entries.length > 0 || loading" class="space-y-3">
45+
<h4 class="text-sm font-medium text-heading dark:text-secondary-100">
46+
{{ t('billing.overage_history') }}
47+
</h4>
48+
49+
<div v-if="loading" class="flex items-center justify-center py-4">
50+
<span class="icon-[annon--loader] size-4 animate-spin text-muted" />
51+
</div>
52+
53+
<div v-else class="overflow-hidden rounded-lg border border-secondary-200 dark:border-secondary-800">
54+
<table class="w-full text-left text-sm">
55+
<thead class="bg-secondary-50 dark:bg-secondary-900">
56+
<tr>
57+
<th class="px-4 py-2 text-xs font-medium text-muted">
58+
{{ t('billing.period') }}
59+
</th>
60+
<th class="px-4 py-2 text-xs font-medium text-muted">
61+
{{ t('billing.category') }}
62+
</th>
63+
<th class="px-4 py-2 text-right text-xs font-medium text-muted">
64+
{{ t('billing.units') }}
65+
</th>
66+
<th class="px-4 py-2 text-right text-xs font-medium text-muted">
67+
{{ t('billing.amount') }}
68+
</th>
69+
</tr>
70+
</thead>
71+
<tbody class="divide-y divide-secondary-200 dark:divide-secondary-800">
72+
<tr v-for="entry in entries" :key="entry.id">
73+
<td class="px-4 py-2 text-muted tabular-nums">
74+
{{ entry.billing_period }}
75+
</td>
76+
<td class="px-4 py-2 text-heading dark:text-secondary-100">
77+
{{ formatCategory(entry.category) }}
78+
</td>
79+
<td class="px-4 py-2 text-right tabular-nums text-muted">
80+
{{ entry.units_billed }}
81+
</td>
82+
<td class="px-4 py-2 text-right font-medium tabular-nums text-heading dark:text-secondary-100">
83+
${{ entry.total_amount.toFixed(2) }}
84+
</td>
85+
</tr>
86+
</tbody>
87+
</table>
88+
</div>
89+
</div>
90+
</template>
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
<script setup lang="ts">
2+
const { t } = useContent()
3+
const { usage, loading, fetchUsage, toggleOverage } = useUsage()
4+
const { billingState } = useBilling()
5+
const config = useRuntimeConfig()
6+
const billingEnabled = computed(() => config.public.billingEnabled === 'true')
7+
const toast = useToast()
8+
9+
defineProps<{
10+
workspaceId: string
11+
}>()
12+
13+
const togglingKey = ref<string | null>(null)
14+
15+
const hasSubscription = computed(() =>
16+
['subscribed', 'trial_active', 'past_due', 'canceled'].includes(billingState.value),
17+
)
18+
19+
// Can toggle overages only with active subscription and billing enabled
20+
const canToggleOverage = computed(() => hasSubscription.value && billingEnabled.value)
21+
22+
onMounted(() => {
23+
fetchUsage()
24+
})
25+
26+
async function handleToggle(settingsKey: string, enabled: boolean) {
27+
togglingKey.value = settingsKey
28+
try {
29+
await toggleOverage(settingsKey, enabled)
30+
}
31+
catch (err: unknown) {
32+
toast.error(resolveApiError(err, t('common.server_error')))
33+
}
34+
finally {
35+
togglingKey.value = null
36+
}
37+
}
38+
39+
function formatMonth(period: string): string {
40+
const [year, month] = period.split('-')
41+
const date = new Date(Number(year), Number(month) - 1)
42+
return date.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
43+
}
44+
45+
/** Icon per category */
46+
function categoryIcon(key: string): string {
47+
switch (key) {
48+
case 'ai_messages': return 'icon-[annon--message-dots]'
49+
case 'form_submissions': return 'icon-[annon--file-text]'
50+
case 'cdn_bandwidth': return 'icon-[annon--globe]'
51+
case 'media_storage': return 'icon-[annon--image]'
52+
case 'api_messages': return 'icon-[annon--code]'
53+
default: return 'icon-[annon--chart-bar]'
54+
}
55+
}
56+
</script>
57+
58+
<template>
59+
<div class="space-y-4">
60+
<!-- Section header -->
61+
<div class="flex items-center justify-between">
62+
<h3 class="text-sm font-medium text-heading dark:text-secondary-100">
63+
{{ t('billing.usage_title') }}
64+
</h3>
65+
<span v-if="usage" class="text-xs text-muted">
66+
{{ formatMonth(usage.billingPeriod) }}
67+
</span>
68+
</div>
69+
70+
<!-- Loading state -->
71+
<div v-if="loading && !usage" class="flex items-center justify-center py-8">
72+
<span class="icon-[annon--loader] size-5 animate-spin text-muted" />
73+
</div>
74+
75+
<!-- Usage meters -->
76+
<div v-else-if="usage" class="space-y-3">
77+
<div
78+
v-for="category in usage.categories"
79+
:key="category.key"
80+
class="rounded-lg border border-secondary-200 p-4 dark:border-secondary-800"
81+
>
82+
<div class="mb-3 flex items-center justify-between">
83+
<div class="flex items-center gap-2">
84+
<span :class="categoryIcon(category.key)" class="size-4 text-muted" aria-hidden="true" />
85+
<span class="text-sm font-medium text-heading dark:text-secondary-100">
86+
{{ category.name }}
87+
</span>
88+
</div>
89+
90+
<!-- Overage toggle -->
91+
<div v-if="category.limit !== -1 && category.limit > 0">
92+
<AtomsFormSwitch
93+
:model-value="category.overageEnabled"
94+
:disabled="!canToggleOverage || togglingKey !== null"
95+
:label="t('billing.allow_overage')"
96+
@update:model-value="handleToggle(category.key, $event)"
97+
/>
98+
</div>
99+
<span v-else class="text-xs text-success-600 dark:text-success-400">
100+
{{ t('billing.usage_unlimited') }}
101+
</span>
102+
</div>
103+
104+
<AtomsUsageMeter
105+
:current="category.current"
106+
:limit="category.limit"
107+
:unit="category.unit"
108+
:overage-enabled="category.overageEnabled"
109+
:overage-units="category.overageUnits"
110+
:overage-unit-price="category.overageUnitPrice"
111+
/>
112+
113+
<!-- Limit reached warning (overage disabled) -->
114+
<div
115+
v-if="category.percentage >= 100 && !category.overageEnabled && category.limit > 0"
116+
class="mt-2 rounded-md bg-danger-50 px-3 py-2 dark:bg-danger-950/30"
117+
>
118+
<p class="text-xs text-danger-700 dark:text-danger-300">
119+
{{ t('billing.overage_disabled') }} — {{ category.name }} {{ t('billing.limit_reached') }}
120+
</p>
121+
</div>
122+
123+
<!-- Approaching limit warning -->
124+
<div
125+
v-else-if="category.percentage >= 80 && category.percentage < 100"
126+
class="mt-2 rounded-md bg-warning-50 px-3 py-2 dark:bg-warning-950/30"
127+
>
128+
<p class="text-xs text-warning-700 dark:text-warning-300">
129+
{{ category.percentage }}% {{ t('billing.usage_percentage') }}
130+
</p>
131+
</div>
132+
</div>
133+
</div>
134+
135+
<!-- Overage summary -->
136+
<div
137+
v-if="usage && (usage.totalOverageAmount > 0 || usage.projectedOverageAmount > 0)"
138+
class="rounded-lg border border-secondary-200 bg-secondary-50 p-4 dark:border-secondary-800 dark:bg-secondary-900"
139+
>
140+
<div class="space-y-2">
141+
<div v-if="usage.totalOverageAmount > 0" class="flex items-center justify-between text-sm">
142+
<span class="text-heading dark:text-secondary-100">
143+
{{ t('billing.total_overage') }}
144+
</span>
145+
<span class="font-medium tabular-nums text-danger-600 dark:text-danger-400">
146+
${{ usage.totalOverageAmount.toFixed(2) }}
147+
</span>
148+
</div>
149+
<div v-if="usage.projectedOverageAmount > 0" class="flex items-center justify-between text-sm">
150+
<span class="text-muted">
151+
{{ t('billing.projected_overage') }}
152+
</span>
153+
<span class="tabular-nums text-muted">
154+
~${{ usage.projectedOverageAmount.toFixed(2) }}
155+
</span>
156+
</div>
157+
</div>
158+
</div>
159+
160+
<!-- No overages -->
161+
<div
162+
v-else-if="usage"
163+
class="rounded-lg border border-dashed border-secondary-200 px-4 py-3 text-center dark:border-secondary-800"
164+
>
165+
<p class="text-xs text-muted">
166+
{{ t('billing.no_overage') }}
167+
</p>
168+
</div>
169+
</div>
170+
</template>

0 commit comments

Comments
 (0)