-
+
-
@@ -31,14 +84,24 @@ const style = computed(() => ({ background: props.background }))
+
\ No newline at end of file
diff --git a/src/components/BuyTokens.vue b/src/components/BuyTokens.vue
new file mode 100644
index 0000000000..3962e02662
--- /dev/null
+++ b/src/components/BuyTokens.vue
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+ {{ t('buy') }} {{ !custom ? amount : t('custom').toLocaleLowerCase() }}
+
+
+
+
+
+ {{ t('for') }} {{ price }}$
+
+
+
+
+
+
+
+
+
diff --git a/src/layouts/settings.vue b/src/layouts/settings.vue
index 0071d3d002..bd9192a49a 100644
--- a/src/layouts/settings.vue
+++ b/src/layouts/settings.vue
@@ -1,9 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t(`manage-your`) }} {{ t(`capgo-tokens-${pageType}`) }} {{ t(`here`) }}
+
+
+
+
+
+
+
+
+
+ {{ t(`tokens-key-${pageType}`) }} {{ t(`history`) }}
+
+
+
{ if (!historyExpanded) historyExpanded = !historyExpanded }">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('buy') }} {{ pageType === 'mau' ? t(`tokens-key-${pageType}`) : t(`tokens-key-${pageType}`).toLowerCase() }}
+
+
+
{ if (!buyTokensExpanded) buyTokensExpanded = !buyTokensExpanded }">
+
+
+
+
+
+
+
+ { buyTokens(stepsPerTokenType[pageType][0].ammount) }" />
+
+
+ { buyTokens(stepsPerTokenType[pageType][2].ammount) }" />
+
+
+
+
+
+
+ { buyTokens(stepsPerTokenType[pageType][1].ammount) }" />
+
+
+ { calculatorOpen = true }" />
+
+
+
+
+
+
+
+
+ {{ t('buy-any-amount') }}
+
+
+
+
+
+
+ {{ t('how-many-tokens') }}
+
+
+
+
+
+
+
+ {{ t('tokens-cost') }} {{ pageType === 'mau' ? computedPriceUp : computedPrice }}$
+
+
+
+ {{ t(`it-will-increase-your-${pageType}-by`) }} {{ pageType === 'mau' ? computeTokens(computedPriceUp) : computeTokens(computedPrice) }}{{ pageType !== 'mau' ? t('GB') : '' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+ {{ t('thank-you-for-money') }}
+
🎉
+
+ {{ t('use-capgo') }} 🚀
+
+
+
+
+
+
+
+
+
+ {{ t(`see-your`) }} {{ pageType === 'mau' ? t(`tokens-key-${pageType}`) : t(`tokens-key-${pageType}`).toLowerCase() }} {{ t(`history`) }}
+
+
+
+
+ -
+
{
+ if (expandedHashset.has(token.id.toString())) { expandedHashset.delete(token.id.toString()) }
+ else { expandedHashset.add(token.id.toString()) }
+ }"
+ >
+
+
+
+
+ {{ token.sum > 0 ? '+' : '' }}{{ formatTokens(token.sum) }}
+
+
+
+
+ {{ token.reason }}
+
+
+
+
+ {{ dayjs(token.created_at).format('DD/MM/YYYY') }}
+
+
+
+
+
+
+ {{ t('total-tokens') }}: {{ formatTokens(token.running_total) }}
+
+
+
+
+
+
+
+
+
+ {{ t('no-tokens-history') }}
+
+
+
+
+
+
+
+
+
+
+meta:
+ layout: settings
+
diff --git a/src/services/stripe.ts b/src/services/stripe.ts
index 3bf4887eba..8c0e19a1f0 100644
--- a/src/services/stripe.ts
+++ b/src/services/stripe.ts
@@ -77,7 +77,7 @@ export async function openPortal(orgId: string, t: ComposerTranslation) {
}
export async function openCheckout(priceId: string, successUrl: string, cancelUrl: string, isYear: boolean, orgId: string) {
-// console.log('openCheckout')
+ // console.log('openCheckout')
const supabase = useSupabase()
const session = await supabase.auth.getSession()
if (!session)
@@ -100,3 +100,30 @@ export async function openCheckout(priceId: string, successUrl: string, cancelUr
toast.error('Cannot get your checkout')
}
}
+
+export async function openCheckoutForOneOff(priceId: string, successUrl: string, cancelUrl: string, orgId: string, howMany: number, type: string) {
+ // console.log('openCheckout')
+ const supabase = useSupabase()
+ const session = await supabase.auth.getSession()
+ if (!session)
+ return
+ try {
+ const resp = await supabase.functions.invoke('private/stripe_checkout', {
+ body: JSON.stringify({
+ priceId,
+ successUrl,
+ cancelUrl,
+ reccurence: 'one_off',
+ orgId,
+ howMany,
+ type,
+ }),
+ })
+ if (!resp.error && resp.data && resp.data.url)
+ openBlank(resp.data.url)
+ }
+ catch (error) {
+ console.error(error)
+ toast.error('Cannot get your checkout')
+ }
+}
diff --git a/src/typed-router.d.ts b/src/typed-router.d.ts
index ca9a32a07b..077745ef58 100644
--- a/src/typed-router.d.ts
+++ b/src/typed-router.d.ts
@@ -43,6 +43,7 @@ declare module 'vue-router/auto-routes' {
'/settings/organization/': RouteRecordInfo<'/settings/organization/', '/settings/organization', Record
, Record>,
'/settings/organization/Members': RouteRecordInfo<'/settings/organization/Members', '/settings/organization/Members', Record, Record>,
'/settings/organization/Plans': RouteRecordInfo<'/settings/organization/Plans', '/settings/organization/Plans', Record, Record>,
+ '/settings/organization/Tokens': RouteRecordInfo<'/settings/organization/Tokens', '/settings/organization/Tokens', Record, Record>,
'/settings/organization/Usage': RouteRecordInfo<'/settings/organization/Usage', '/settings/organization/Usage', Record, Record>,
}
}
diff --git a/src/types/supabase.types.ts b/src/types/supabase.types.ts
index 2bb4681554..88c1f967c8 100644
--- a/src/types/supabase.types.ts
+++ b/src/types/supabase.types.ts
@@ -7,6 +7,31 @@ export type Json =
| Json[]
export type Database = {
+ graphql_public: {
+ Tables: {
+ [_ in never]: never
+ }
+ Views: {
+ [_ in never]: never
+ }
+ Functions: {
+ graphql: {
+ Args: {
+ operationName?: string
+ query?: string
+ variables?: Json
+ extensions?: Json
+ }
+ Returns: Json
+ }
+ }
+ Enums: {
+ [_ in never]: never
+ }
+ CompositeTypes: {
+ [_ in never]: never
+ }
+ }
public: {
Tables: {
apikeys: {
@@ -285,6 +310,71 @@ export type Database = {
}
Relationships: []
}
+ capgo_tokens_history: {
+ Row: {
+ created_at: string
+ id: number
+ org_id: string
+ reason: string
+ sum: number
+ updated_at: string
+ }
+ Insert: {
+ created_at?: string
+ id?: number
+ org_id: string
+ reason: string
+ sum: number
+ updated_at?: string
+ }
+ Update: {
+ created_at?: string
+ id?: number
+ org_id?: string
+ reason?: string
+ sum?: number
+ updated_at?: string
+ }
+ Relationships: [
+ {
+ foreignKeyName: "capgo_tokens_history_org_id_fkey"
+ columns: ["org_id"]
+ isOneToOne: false
+ referencedRelation: "orgs"
+ referencedColumns: ["id"]
+ },
+ ]
+ }
+ capgo_tokens_steps: {
+ Row: {
+ created_at: string
+ id: number
+ price_per_unit: number
+ step_max: number
+ step_min: number
+ type: string
+ updated_at: string
+ }
+ Insert: {
+ created_at?: string
+ id?: number
+ price_per_unit: number
+ step_max: number
+ step_min: number
+ type: string
+ updated_at?: string
+ }
+ Update: {
+ created_at?: string
+ id?: number
+ price_per_unit?: number
+ step_max?: number
+ step_min?: number
+ type?: string
+ updated_at?: string
+ }
+ Relationships: []
+ }
channel_devices: {
Row: {
app_id: string
@@ -517,7 +607,7 @@ export type Database = {
}
Insert: {
created_at?: string | null
- email?: string
+ email: string
id?: string
}
Update: {
@@ -944,6 +1034,7 @@ export type Database = {
storage_unit: number | null
stripe_id: string
updated_at: string
+ version: number
}
Insert: {
bandwidth: number
@@ -966,6 +1057,7 @@ export type Database = {
storage_unit?: number | null
stripe_id?: string
updated_at?: string
+ version?: number
}
Update: {
bandwidth?: number
@@ -988,6 +1080,7 @@ export type Database = {
storage_unit?: number | null
stripe_id?: string
updated_at?: string
+ version?: number
}
Relationships: []
}
@@ -1220,10 +1313,6 @@ export type Database = {
Args: { org_id: string }
Returns: string
}
- calculate_daily_app_usage: {
- Args: Record
- Returns: undefined
- }
check_min_rights: {
Args:
| {
@@ -1285,10 +1374,6 @@ export type Database = {
Args: Record
Returns: number
}
- count_all_paying: {
- Args: Record
- Returns: number
- }
count_all_plans_v2: {
Args: Record
Returns: {
@@ -1334,7 +1419,6 @@ export type Database = {
Args:
| { org_id: string }
| { org_id: string; start_date: string; end_date: string }
- | { p_org_id: string; p_start_date: string; p_end_date: string }
Returns: {
app_id: string
date: string
@@ -1382,23 +1466,14 @@ export type Database = {
Args: Record
Returns: string
}
- get_daily_version: {
- Args: {
- app_id_param: string
- start_date_param?: string
- end_date_param?: string
- }
- Returns: {
- date: string
- app_id: string
- version_id: number
- percent: number
- }[]
- }
get_db_url: {
Args: Record
Returns: string
}
+ get_extra_mau_for_org: {
+ Args: { orgid: string }
+ Returns: number
+ }
get_global_metrics: {
Args:
| { org_id: string }
@@ -1446,14 +1521,6 @@ export type Database = {
}
Returns: string
}
- get_infos: {
- Args: { appid: string; deviceid: string; versionname: string }
- Returns: {
- current_version_id: number
- versiondata: Json
- channel: Json
- }[]
- }
get_metered_usage: {
Args: Record | { orgid: string }
Returns: Database["public"]["CompositeTypes"]["stats_table"]
@@ -1543,10 +1610,24 @@ export type Database = {
next_run: string
}[]
}
+ get_tokens_history: {
+ Args: { orgid: string }
+ Returns: {
+ id: number
+ sum: number
+ reason: string
+ created_at: string
+ running_total: number
+ }[]
+ }
get_total_app_storage_size_orgs: {
Args: { org_id: string; app_id: string }
Returns: number
}
+ get_total_mau_tokens: {
+ Args: { orgid: string }
+ Returns: number
+ }
get_total_metrics: {
Args:
| { org_id: string }
@@ -1561,10 +1642,6 @@ export type Database = {
uninstall: number
}[]
}
- get_total_storage_size: {
- Args: { appid: string } | { userid: string; appid: string }
- Returns: number
- }
get_total_storage_size_org: {
Args: { org_id: string }
Returns: number
@@ -1851,12 +1928,21 @@ export type Database = {
Args: Record
Returns: undefined
}
+ replicate_to_d1: {
+ Args: {
+ record: Json
+ old_record: Json
+ operation: string
+ table_name: string
+ }
+ Returns: undefined
+ }
reset_and_seed_app_data: {
- Args: { p_app_id: string } | { p_app_id: string }
+ Args: { p_app_id: string }
Returns: undefined
}
reset_and_seed_app_stats_data: {
- Args: { p_app_id: string } | { p_app_id: string }
+ Args: { p_app_id: string }
Returns: undefined
}
reset_and_seed_data: {
@@ -1891,18 +1977,6 @@ export type Database = {
Args: { p_app_id: string; p_new_org_id: string }
Returns: undefined
}
- update_app_usage: {
- Args: Record | { minutes_interval: number }
- Returns: undefined
- }
- update_notification: {
- Args: { p_event: string; p_uniq_id: string; p_owner_org: string }
- Returns: undefined
- }
- upsert_notification: {
- Args: { p_event: string; p_uniq_id: string; p_owner_org: string }
- Returns: undefined
- }
verify_mfa: {
Args: Record
Returns: boolean
@@ -1910,10 +1984,8 @@ export type Database = {
}
Enums: {
action_type: "mau" | "storage" | "bandwidth"
- app_mode: "prod" | "dev" | "livereload"
disable_update: "major" | "minor" | "patch" | "version_number" | "none"
key_mode: "read" | "write" | "all" | "upload"
- pay_as_you_go_type: "base" | "units"
platform_os: "ios" | "android"
stats_action:
| "delete"
@@ -1969,7 +2041,7 @@ export type Database = {
| "failed"
| "deleted"
| "canceled"
- usage_mode: "5min" | "day" | "month" | "cycle" | "last_saved"
+ usage_mode: "last_saved" | "5min" | "day" | "cycle"
user_min_right:
| "invite_read"
| "invite_upload"
@@ -1990,9 +2062,6 @@ export type Database = {
s3_path: string | null
file_hash: string | null
}
- match_plan: {
- name: string | null
- }
orgs_table: {
id: string | null
created_by: string | null
@@ -2123,13 +2192,14 @@ export type CompositeTypes<
: never
export const Constants = {
+ graphql_public: {
+ Enums: {},
+ },
public: {
Enums: {
action_type: ["mau", "storage", "bandwidth"],
- app_mode: ["prod", "dev", "livereload"],
disable_update: ["major", "minor", "patch", "version_number", "none"],
key_mode: ["read", "write", "all", "upload"],
- pay_as_you_go_type: ["base", "units"],
platform_os: ["ios", "android"],
stats_action: [
"delete",
@@ -2187,7 +2257,7 @@ export const Constants = {
"deleted",
"canceled",
],
- usage_mode: ["5min", "day", "month", "cycle", "last_saved"],
+ usage_mode: ["last_saved", "5min", "day", "cycle"],
user_min_right: [
"invite_read",
"invite_upload",
@@ -2205,3 +2275,4 @@ export const Constants = {
},
},
} as const
+
diff --git a/supabase/functions/_backend/private/stripe_checkout.ts b/supabase/functions/_backend/private/stripe_checkout.ts
index d101480618..1f104c54e3 100644
--- a/supabase/functions/_backend/private/stripe_checkout.ts
+++ b/supabase/functions/_backend/private/stripe_checkout.ts
@@ -2,18 +2,20 @@ import type { MiddlewareKeyVariables } from '../utils/hono.ts'
import { Hono } from 'hono/tiny'
import { HTTPError } from 'ky'
import { middlewareAuth, useCors } from '../utils/hono.ts'
+import { createCheckout, createCheckoutForOneOff } from '../utils/stripe.ts'
import { cloudlog, cloudlogErr } from '../utils/loggin.ts'
-import { createCheckout } from '../utils/stripe.ts'
import { hasOrgRight, supabaseAdmin } from '../utils/supabase.ts'
import { getEnv } from '../utils/utils.ts'
interface PortalData {
priceId: string
clientReferenceId?: string
- reccurence: 'month' | 'year'
+ reccurence: 'month' | 'year' | 'one_off'
successUrl: string
cancelUrl: string
+ howMany: number
orgId: string
+ type: 'mau' | 'storage' | 'bandwidth'
}
export const app = new Hono()
@@ -49,8 +51,10 @@ app.post('/', middlewareAuth, async (c) => {
if (!await hasOrgRight(c as any, body.orgId, auth.user.id, 'super_admin'))
return c.json({ status: 'not authorize (orgs right)' }, 400)
- cloudlog({ requestId: c.get('requestId'), message: 'user', org })
- const checkout = await createCheckout(c as any, org.customer_id, body.reccurence || 'month', body.priceId || 'price_1KkINoGH46eYKnWwwEi97h1B', body.successUrl || `${getEnv(c as any, 'WEBAPP_URL')}/app/usage`, body.cancelUrl || `${getEnv(c as any, 'WEBAPP_URL')}/app/usage`, body.clientReferenceId)
+ cloudlog({ requestId: c.get('requestId'), context: 'user', org })
+ const checkout = !body.howMany
+ ? await createCheckout(c as any, org.customer_id, body.reccurence || 'month', body.priceId || 'price_1KkINoGH46eYKnWwwEi97h1B', body.successUrl || `${getEnv(c as any, 'WEBAPP_URL')}/app/usage`, body.cancelUrl || `${getEnv(c as any, 'WEBAPP_URL')}/app/usage`, body.clientReferenceId)
+ : await createCheckoutForOneOff(c as any, org.customer_id, body.successUrl || `${getEnv(c as any, 'WEBAPP_URL')}/dashboard/settings/organization/tokens?thankYou=true`, body.cancelUrl || `${getEnv(c as any, 'WEBAPP_URL')}/app/usage`, body.howMany, body.type)
return c.json({ url: checkout.url })
}
catch (error) {
diff --git a/supabase/functions/_backend/triggers/stripe_event.ts b/supabase/functions/_backend/triggers/stripe_event.ts
index 70dfef24a3..7d14b8357c 100644
--- a/supabase/functions/_backend/triggers/stripe_event.ts
+++ b/supabase/functions/_backend/triggers/stripe_event.ts
@@ -1,3 +1,4 @@
+import type Stripe from 'stripe'
import type { MiddlewareKeyVariables } from '../utils/hono.ts'
import { Hono } from 'hono/tiny'
import { addTagBento, trackBentoEvent } from '../utils/bento.ts'
@@ -25,6 +26,62 @@ app.post('/', async (c) => {
// event.headers
const body = await c.req.text()
const stripeEvent = await parseStripeEvent(c as any, body, signature!)
+
+ // Special handling for one-off charges
+ if (stripeEvent.type === 'payment_intent.succeeded') {
+ const paymentIntent = stripeEvent.data.object as Stripe.PaymentIntent
+ if (paymentIntent.object === 'payment_intent') {
+ // Get the customer details
+ const customerId = typeof paymentIntent.customer === 'string' ? paymentIntent.customer : paymentIntent.customer?.id
+ const { data: org, error: orgError } = await supabaseAdmin(c as any)
+ .from('orgs')
+ .select('id')
+ .eq('customer_id', customerId ?? '')
+ .single()
+
+ if (!org || orgError) {
+ console.log({ requestId: c.get('requestId'), context: 'no org found for payment intent', paymentIntent })
+ return c.json({ received: false })
+ }
+
+ const howMany = paymentIntent.metadata?.howMany
+ const parsedHowMany = Number.parseInt(howMany ?? '0')
+ if (!howMany || Number.isNaN(parsedHowMany)) {
+ console.log({ requestId: c.get('requestId'), context: 'no howMany found for payment intent', paymentIntent })
+ return c.json({ received: false })
+ }
+
+ const { error: dbError } = await supabaseAdmin(c as any)
+ .from('capgo_tokens_history')
+ .insert({
+ sum: Number(parsedHowMany),
+ reason: 'MAU purchase',
+ org_id: org.id,
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ })
+ if (dbError) {
+ console.log({ requestId: c.get('requestId'), context: 'error inserting capgo_tokens_history', dbError })
+ return c.json({ received: false })
+ }
+
+ // Log the purchase
+ await LogSnag.track({
+ channel: 'usage',
+ event: 'One-off Purchase',
+ icon: '💰',
+ user_id: org.id,
+ notify: true,
+ tags: {
+ amount: `${paymentIntent.amount / 100} ${paymentIntent.currency.toUpperCase()}`,
+ payment_intent: paymentIntent.id,
+ },
+ }).catch()
+
+ return c.json({ received: true })
+ }
+ }
+
const stripeDataEvent = extractDataEvent(c as any, stripeEvent)
const stripeData = stripeDataEvent.data
if (stripeData.customer_id === '') {
diff --git a/supabase/functions/_backend/utils/stripe.ts b/supabase/functions/_backend/utils/stripe.ts
index ae8d55d947..f9a4e06763 100644
--- a/supabase/functions/_backend/utils/stripe.ts
+++ b/supabase/functions/_backend/utils/stripe.ts
@@ -272,7 +272,7 @@ export async function createCheckout(c: Context, customerId: string, reccurence:
line_items: [
{
price: prices.priceId,
- quantity: 1,
+ quantity: howMany ?? 1,
},
...prices.meteredIds.map(priceId => ({
price: priceId,
@@ -281,6 +281,88 @@ export async function createCheckout(c: Context, customerId: string, reccurence:
})
return { url: session.url }
}
+function toMilion(price: number) {
+ return `${price / 1000000}M`
+}
+
+export async function createCheckoutForOneOff(c: Context, customerId: string, successUrl: string, cancelUrl: string, howMany: number, type: 'mau' | 'storage' | 'bandwidth') {
+ if (!existInEnv(c, 'STRIPE_SECRET_KEY'))
+ return { url: '' }
+ const { data: tokensSteps, error } = await supabaseAdmin(c).from('capgo_tokens_steps').select('*').order('step_min', { ascending: true })
+ if (error)
+ return { url: '' }
+
+ let i = 0
+ const prices = []
+
+ while (true) {
+ const step = tokensSteps[i]
+ const howManyInStep = Math.min(howMany, step.step_max) - Math.max(0, step.step_min)
+
+ const description = type === 'mau' ? (
+ step.step_min > 0
+ ? step.step_max !== 9223372036854775807
+ ? `Price per token: ${step.price_per_unit} between ${toMilion(step.step_min)} and ${toMilion(step.step_max)}`
+ : `Price per token: ${step.price_per_unit} after ${toMilion(step.step_min)}`
+ : `Price per token: ${step.price_per_unit} up to ${toMilion(step.step_max)}`)
+ : (
+ step.step_min > 0
+ ? step.step_max !== 9223372036854775807
+ ? `Price per GB: ${step.price_per_unit} between ${step.step_min} GB and ${step.step_max} GB`
+ : `Price per GB: ${step.price_per_unit} after ${step.step_min} GB`
+ : `Price per GB: ${step.price_per_unit} up to ${step.step_max} GB`)
+
+ const formatedAmmount =
+ howManyInStep % 1000000 === 0 && howManyInStep > 0 ?
+ toMilion(howManyInStep) :
+ howManyInStep % 1000 === 0 && howManyInStep > 0 ?
+ `${howManyInStep / 1000}K` :
+ `${howManyInStep}`
+
+ prices.push({
+ quantity: 1,
+ price_data: {
+ unit_amount: Math.ceil(step.price_per_unit * howManyInStep * 100),
+ currency: 'usd',
+ product_data: {
+ name: `${formatedAmmount}${type === 'mau' ? ' tokens' : (type === 'storage' ? ' storage GB' : ' bandwidth GB')}`,
+ description,
+ },
+ },
+ })
+ if (howMany >= step.step_min && howMany <= step.step_max) {
+ break
+ }
+ i++
+ }
+
+ const totalPrice = prices.reduce((acc, price) => acc + price.price_data.unit_amount, 0)
+ cloudlog({ requestId: c.get('requestId'), context: 'totalPrice', totalPrice })
+ if (type === 'mau' && totalPrice % 100 !== 0) {
+ cloudlog({ requestId: c.get('requestId'), context: 'totalPrice', error: `totalPrice (${totalPrice}) is not divisible by 100` })
+ return { url: '' }
+ }
+
+ console.log({ requestId: c.get('requestId'), context: 'prices', prices })
+
+ const req = {
+ customer: customerId,
+ success_url: successUrl,
+ mode: 'payment' as const,
+ cancel_url: cancelUrl,
+ automatic_tax: { enabled: true },
+ payment_method_types: ['card' as const],
+ payment_intent_data: {
+ metadata: {
+ howMany,
+ },
+ },
+ line_items: prices,
+ }
+ console.log({ requestId: c.get('requestId'), context: 'req', req })
+ const session = await getStripe(c).checkout.sessions.create(req)
+ return { url: session.url }
+}
export interface StripeCustomer {
id: string
diff --git a/supabase/functions/_backend/utils/supabase.types.ts b/supabase/functions/_backend/utils/supabase.types.ts
index 2bb4681554..87f746cdea 100644
--- a/supabase/functions/_backend/utils/supabase.types.ts
+++ b/supabase/functions/_backend/utils/supabase.types.ts
@@ -285,6 +285,71 @@ export type Database = {
}
Relationships: []
}
+ capgo_tokens_history: {
+ Row: {
+ created_at: string
+ id: number
+ org_id: string
+ reason: string
+ sum: number
+ updated_at: string
+ }
+ Insert: {
+ created_at?: string
+ id?: number
+ org_id: string
+ reason: string
+ sum: number
+ updated_at?: string
+ }
+ Update: {
+ created_at?: string
+ id?: number
+ org_id?: string
+ reason?: string
+ sum?: number
+ updated_at?: string
+ }
+ Relationships: [
+ {
+ foreignKeyName: "capgo_tokens_history_org_id_fkey"
+ columns: ["org_id"]
+ isOneToOne: false
+ referencedRelation: "orgs"
+ referencedColumns: ["id"]
+ },
+ ]
+ }
+ capgo_tokens_steps: {
+ Row: {
+ created_at: string
+ id: number
+ price_id: string
+ price_per_unit: number
+ step_max: number
+ step_min: number
+ updated_at: string
+ }
+ Insert: {
+ created_at?: string
+ id?: number
+ price_id: string
+ price_per_unit: number
+ step_max: number
+ step_min: number
+ updated_at?: string
+ }
+ Update: {
+ created_at?: string
+ id?: number
+ price_id?: string
+ price_per_unit?: number
+ step_max?: number
+ step_min?: number
+ updated_at?: string
+ }
+ Relationships: []
+ }
channel_devices: {
Row: {
app_id: string
@@ -1543,6 +1608,18 @@ export type Database = {
next_run: string
}[]
}
+ get_tokens_history: {
+ Args: {
+ orgid: string
+ }
+ Returns: {
+ id: number
+ sum: number
+ reason: string
+ created_at: string
+ running_total: number
+ }[]
+ }
get_total_app_storage_size_orgs: {
Args: { org_id: string; app_id: string }
Returns: number
diff --git a/supabase/migrations/20250131091941_capgo_tokens.sql b/supabase/migrations/20250131091941_capgo_tokens.sql
new file mode 100644
index 0000000000..80b477b697
--- /dev/null
+++ b/supabase/migrations/20250131091941_capgo_tokens.sql
@@ -0,0 +1,191 @@
+-- Create the capgo_tokens_history table
+CREATE TABLE IF NOT EXISTS capgo_tokens_history (
+ id BIGSERIAL PRIMARY KEY,
+ sum INTEGER NOT NULL,
+ reason TEXT NOT NULL,
+ org_id UUID NOT NULL REFERENCES orgs(id),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+-- Add a comment to the table
+COMMENT ON TABLE capgo_tokens_history IS 'Table to track token transactions for organizations';
+
+-- Add comments to the columns
+COMMENT ON COLUMN capgo_tokens_history.id IS 'The unique identifier for the token transaction';
+COMMENT ON COLUMN capgo_tokens_history.sum IS 'The amount of tokens (positive for additions, negative for deductions)';
+COMMENT ON COLUMN capgo_tokens_history.reason IS 'The reason for the token transaction';
+COMMENT ON COLUMN capgo_tokens_history.org_id IS 'Reference to the organization this transaction belongs to';
+COMMENT ON COLUMN capgo_tokens_history.created_at IS 'Timestamp when the token transaction was created';
+COMMENT ON COLUMN capgo_tokens_history.updated_at IS 'Timestamp when the token transaction was last updated';
+
+-- Create an index on org_id for faster lookups
+CREATE INDEX capgo_tokens_history_org_id_idx ON capgo_tokens_history(org_id);
+
+-- Create an index on created_at for time-based queries
+CREATE INDEX capgo_tokens_history_created_at_idx ON capgo_tokens_history(created_at DESC);
+
+-- Create trigger for updating updated_at column
+CREATE TRIGGER handle_updated_at
+ BEFORE UPDATE ON capgo_tokens_history
+ FOR EACH ROW
+ EXECUTE FUNCTION extensions.moddatetime('updated_at');
+
+ALTER TABLE capgo_tokens_history ENABLE ROW LEVEL SECURITY;
+
+-- Function to get token history for an organization
+CREATE OR REPLACE FUNCTION get_tokens_history(orgid UUID)
+RETURNS TABLE (
+ id BIGINT,
+ sum INTEGER,
+ reason TEXT,
+ created_at TIMESTAMPTZ,
+ running_total BIGINT
+)
+LANGUAGE plpgsql
+SECURITY DEFINER
+AS $$
+DECLARE
+ has_admin_access BOOLEAN;
+BEGIN
+ -- Check if user has admin access to the organization
+ SELECT check_min_rights('admin'::user_min_right, get_identity_org_allowed('{write,all,upload,read}'::key_mode[], orgid), orgid, NULL::character varying, NULL::bigint)
+ INTO has_admin_access;
+
+ -- If no admin access, raise exception
+ IF NOT has_admin_access THEN
+ RAISE EXCEPTION 'Insufficient permissions to view token history';
+ END IF;
+
+ RETURN QUERY
+ WITH running_totals AS (
+ SELECT
+ ct.id,
+ ct.sum,
+ ct.reason,
+ ct.created_at,
+ SUM(ct.sum) OVER (ORDER BY ct.created_at, ct.id) as running_total
+ FROM capgo_tokens_history ct
+ WHERE ct.org_id = orgid
+ AND ct.created_at >= NOW() - INTERVAL '1 year'
+ )
+ SELECT
+ rt.id,
+ rt.sum,
+ rt.reason,
+ rt.created_at,
+ rt.running_total
+ FROM running_totals rt
+ ORDER BY rt.created_at ASC, rt.id ASC;
+END;
+$$;
+
+-- Create the capgo_tokens_steps table
+CREATE TABLE IF NOT EXISTS capgo_tokens_steps (
+ id BIGSERIAL PRIMARY KEY,
+ step_min bigint NOT NULL,
+ step_max bigint NOT NULL,
+ price_per_unit FLOAT NOT NULL,
+ type TEXT NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ CONSTRAINT step_range_check CHECK (step_min < step_max)
+);
+
+-- Add a comment to the table
+COMMENT ON TABLE capgo_tokens_steps IS 'Table to store token pricing tiers';
+
+-- Add comments to the columns
+COMMENT ON COLUMN capgo_tokens_steps.id IS 'The unique identifier for the pricing tier';
+COMMENT ON COLUMN capgo_tokens_steps.step_min IS 'The minimum number of tokens for this tier';
+COMMENT ON COLUMN capgo_tokens_steps.step_max IS 'The maximum number of tokens for this tier';
+COMMENT ON COLUMN capgo_tokens_steps.price_per_unit IS 'The price per token in this tier';
+COMMENT ON COLUMN capgo_tokens_steps.created_at IS 'Timestamp when the tier was created';
+COMMENT ON COLUMN capgo_tokens_steps.updated_at IS 'Timestamp when the tier was last updated';
+
+-- Create trigger for updating updated_at column
+CREATE TRIGGER handle_updated_at
+ BEFORE UPDATE ON capgo_tokens_steps
+ FOR EACH ROW
+ EXECUTE FUNCTION extensions.moddatetime('updated_at');
+
+-- Create an index on step ranges for faster lookups
+CREATE INDEX capgo_tokens_steps_range_idx ON capgo_tokens_steps(step_min, step_max);
+
+ALTER TABLE capgo_tokens_steps ENABLE ROW LEVEL SECURITY;
+-- Allow anyone to read capgo_tokens_steps
+CREATE POLICY "Anyone can read capgo_tokens_steps" ON capgo_tokens_steps
+ FOR SELECT
+ TO public
+ USING (true);
+
+-- Function to get total extra MAU tokens for an organization within their current billing cycle
+CREATE OR REPLACE FUNCTION get_extra_mau_for_org(orgid UUID)
+RETURNS INTEGER
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ cycle_info RECORD;
+ total_extra INTEGER;
+BEGIN
+ -- Get the current billing cycle information
+ SELECT * INTO cycle_info FROM get_cycle_info_org(orgid);
+
+ -- Sum up all MAU limit increase tokens within the billing cycle and make it positive
+ SELECT COALESCE(ABS(SUM(sum)), 0)
+ INTO total_extra
+ FROM capgo_tokens_history
+ WHERE org_id = orgid
+ AND reason = 'MAU limit increased'
+ AND created_at >= cycle_info.subscription_anchor_start
+ AND created_at < cycle_info.subscription_anchor_end;
+
+ RETURN total_extra;
+END;
+$$;
+
+-- Add a comment to the function
+COMMENT ON FUNCTION get_extra_mau_for_org IS 'Returns the absolute total sum of extra MAU tokens purchased within the current billing cycle for an organization. Only callable by service role.';
+
+-- Revoke from public and grant to service_role
+REVOKE ALL PRIVILEGES ON FUNCTION get_extra_mau_for_org(UUID) FROM PUBLIC;
+REVOKE ALL PRIVILEGES ON FUNCTION get_extra_mau_for_org(UUID) FROM authenticated;
+REVOKE ALL PRIVILEGES ON FUNCTION get_extra_mau_for_org(UUID) FROM anon;
+GRANT EXECUTE ON FUNCTION get_extra_mau_for_org(UUID) TO service_role;
+
+-- Function to get total MAU tokens for an organization for the past year
+CREATE OR REPLACE FUNCTION get_total_mau_tokens(orgid UUID)
+RETURNS INTEGER
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ total_tokens INTEGER;
+BEGIN
+ -- Sum up all MAU tokens within the past year
+ SELECT COALESCE(ABS(SUM(sum)), 0)
+ INTO total_tokens
+ FROM capgo_tokens_history
+ WHERE org_id = orgid
+ AND reason = 'MAU limit increased'
+ AND created_at >= NOW() - INTERVAL '1 year'
+ AND created_at < NOW();
+
+ RETURN total_tokens;
+END;
+$$;
+
+-- Add a comment to the function
+COMMENT ON FUNCTION get_total_mau_tokens IS 'Returns the absolute total sum of MAU tokens for the past year for an organization. Only callable by service role.';
+
+-- Remove all existing grants
+REVOKE ALL PRIVILEGES ON FUNCTION get_total_mau_tokens(UUID) FROM PUBLIC;
+REVOKE ALL PRIVILEGES ON FUNCTION get_total_mau_tokens(UUID) FROM authenticated;
+REVOKE ALL PRIVILEGES ON FUNCTION get_total_mau_tokens(UUID) FROM anon;
+
+-- Grant only to service_role
+GRANT EXECUTE ON FUNCTION get_total_mau_tokens(UUID) TO service_role;
+
diff --git a/supabase/seed.sql b/supabase/seed.sql
index fa4ef3664e..b00ee33231 100644
--- a/supabase/seed.sql
+++ b/supabase/seed.sql
@@ -532,6 +532,37 @@ BEGIN
END IF;
END $$;
+-- Seed the capgo_tokens_steps table with initial pricing tiers
+-- INSERT INTO capgo_tokens_steps (step_min, step_max, price_per_unit, price_id) VALUES
+-- (0, 100, 1.00, 'price_1QnHc0GH46eYKnWweRZjvNWL'),
+-- (100, 250, 0.80, 'price_1QnID2GH46eYKnWweJ1VAw0h'),
+-- (250, 2147483647, 0.50, 'price_1QnIDGGH46eYKnWwRc1HMo6h');
+INSERT INTO capgo_tokens_steps (type, step_min, step_max, price_per_unit) VALUES
+ ('mau', 0, 1000000, 0.003),
+ ('mau', 1000000, 3000000, 0.0022),
+ ('mau', 3000000, 10000000, 0.0016),
+ ('mau', 10000000, 15000000, 0.0014),
+ ('mau', 15000000, 25000000, 0.00115),
+ ('mau', 25000000, 40000000, 0.001),
+ ('mau', 40000000, 100000000, 0.0009),
+ ('mau', 100000000, 9223372036854775807, 0.0007),
+ ('bandwidth', 0, 10240, 0.12), -- 0–10 TB
+ ('bandwidth', 10240, 20480, 0.10), -- 10–20 TB
+ ('bandwidth', 20480, 51200, 0.085), -- 20–50 TB
+ ('bandwidth', 51200, 102400, 0.07), -- 50–100 TB
+ ('bandwidth', 102400, 204800, 0.055), -- 100–200 TB
+ ('bandwidth', 204800, 512000, 0.04), -- 200–500 TB
+ ('bandwidth', 512000, 1024000, 0.03), -- 500–1000 TB
+ ('bandwidth', 1024000, 9223372036854775807, 0.02), -- 1000+ TB
+ ('storage', 0, 10, 0.09), -- 0–10 GB
+ ('storage', 10, 50, 0.08), -- 10–50 GB
+ ('storage', 50, 200, 0.065), -- 50–200 GB
+ ('storage', 200, 500, 0.05), -- 200–500 GB
+ ('storage', 500, 2000, 0.04), -- 500–2000 GB
+ ('storage', 2000, 5000, 0.03), -- 2–5 TB
+ ('storage', 5000, 10000, 0.025), -- 5–10 TB
+ ('storage', 10000, 9223372036854775807, 0.021); -- 10+ TB
+
-- Seed data
DO $$
BEGIN
diff --git a/vite.config.mts b/vite.config.mts
index 817456432e..c159fb04d7 100644
--- a/vite.config.mts
+++ b/vite.config.mts
@@ -8,6 +8,7 @@ import formkit from 'unplugin-formkit/vite'
import IconsResolver from 'unplugin-icons/resolver'
import Icons from 'unplugin-icons/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
+import svgLoader from 'vite-svg-loader'
import devtoolsJson from 'vite-plugin-devtools-json';
import Components from 'unplugin-vue-components/vite'
import VueMacros from 'unplugin-vue-macros/vite'
@@ -48,6 +49,7 @@ export default defineConfig({
},
},
plugins: [
+ svgLoader(),
tailwindcss(),
formkit({}),
devtoolsJson(),
@@ -56,7 +58,6 @@ export default defineConfig({
vue: Vue({
include: [/\.vue$/, /\.md$/],
}),
-
},
}),
Components({