Skip to content

Commit 148be05

Browse files
[feat] organization billing (#164)
Co-authored-by: Codebuff <noreply@codebuff.com>
1 parent 81a10c5 commit 148be05

File tree

112 files changed

+16940
-682
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

112 files changed

+16940
-682
lines changed

.env.example

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,11 @@ AWS_ACCESS_KEY_ID='your-aws-key-here'
1414
AWS_SECRET_ACCESS_KEY='your-aws-secret-access-key-here'
1515

1616
# Stripe
17-
STRIPE_SECRET_KEY='your stripe secret key'
18-
STRIPE_WEBHOOK_SECRET_KEY='your webhook secret key'
19-
STRIPE_PRO_PRICE_ID='price_ for pro base subscription'
20-
STRIPE_PRO_OVERAGE_PRICE_ID='price_ for pro overage'
21-
STRIPE_MOAR_PRO_PRICE_ID='price_for moar pro base subscription'
22-
STRIPE_MOAR_PRO_OVERAGE_PRICE_ID='price_for moar pro overage'
23-
STRIPE_USAGE_PRICE_ID='price_for default subscription'
17+
STRIPE_SECRET_KEY='your stripe secret key'
18+
STRIPE_WEBHOOK_SECRET_KEY='your webhook secret key'
19+
STRIPE_TEAM_USAGE_PRICE_ID='price_...'
20+
STRIPE_TEAM_FEE_PRICE_ID='price_...'
21+
STRIPE_USAGE_PRICE_ID='price_...'
2422
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY='your publishable key'
2523
NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL="https://billing.stripe.com/..."
2624

backend/src/__tests__/auto-topup.test.ts

Lines changed: 81 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,76 +1,65 @@
11
import type { CreditBalance } from '@codebuff/billing'
22
import { checkAndTriggerAutoTopup } from '@codebuff/billing'
33
import * as billing from '@codebuff/billing'
4-
import { beforeEach, describe, expect, it, mock, afterEach, spyOn } from 'bun:test'
4+
import {
5+
beforeEach,
6+
describe,
7+
expect,
8+
it,
9+
mock,
10+
afterEach,
11+
spyOn,
12+
} from 'bun:test'
513

614
describe('Auto Top-up System', () => {
715
describe('checkAndTriggerAutoTopup', () => {
816
// Create fresh mocks for each test
917
let dbMock: ReturnType<typeof mock>
1018
let balanceMock: ReturnType<typeof mock>
11-
let paymentMethodsMock: ReturnType<typeof mock>
12-
let paymentIntentMock: ReturnType<typeof mock>
19+
let validateAutoTopupMock: ReturnType<typeof mock>
1320
let grantCreditsMock: ReturnType<typeof mock>
1421

1522
beforeEach(() => {
16-
// Clear previous mocks and set up logger again
17-
mock.restore()
18-
19-
mock.module('../util/logger', () => ({
20-
logger: {
21-
debug: () => {},
22-
error: () => {},
23-
info: () => {},
24-
warn: () => {},
25-
},
26-
withLoggerContext: async (context: any, fn: () => Promise<any>) => fn(),
27-
}))
28-
29-
dbMock = mock(() => ({
30-
id: 'test-user',
31-
stripe_customer_id: 'cus_123',
32-
auto_topup_enabled: true,
33-
auto_topup_threshold: 100,
34-
auto_topup_amount: 500,
35-
next_quota_reset: new Date(),
36-
}))
23+
// Set up default mocks
24+
dbMock = mock(() =>
25+
Promise.resolve({
26+
auto_topup_enabled: true,
27+
auto_topup_threshold: 100,
28+
auto_topup_amount: 500,
29+
stripe_customer_id: 'cus_123',
30+
next_quota_reset: new Date(),
31+
})
32+
)
3733

3834
balanceMock = mock(() =>
3935
Promise.resolve({
4036
usageThisCycle: 0,
4137
balance: {
42-
totalRemaining: 50, // Below threshold by default
38+
totalRemaining: 50, // Below threshold
4339
totalDebt: 0,
4440
netBalance: 50,
4541
breakdown: {},
46-
} as CreditBalance,
42+
},
4743
})
4844
)
4945

50-
paymentMethodsMock = mock(() =>
46+
validateAutoTopupMock = mock(() =>
5147
Promise.resolve({
52-
data: [
53-
{
54-
id: 'pm_123',
55-
card: {
56-
exp_year: 2025,
57-
exp_month: 12,
58-
},
48+
blockedReason: null,
49+
validPaymentMethod: {
50+
id: 'pm_123',
51+
type: 'card',
52+
card: {
53+
exp_year: 2030,
54+
exp_month: 12,
5955
},
60-
],
61-
})
62-
)
63-
64-
paymentIntentMock = mock(() =>
65-
Promise.resolve({
66-
status: 'succeeded',
67-
id: 'pi_123',
56+
},
6857
})
6958
)
7059

7160
grantCreditsMock = mock(() => Promise.resolve())
7261

73-
// Set up module mocks with fresh mocks
62+
// Mock the database
7463
mock.module('common/db', () => ({
7564
default: {
7665
query: {
@@ -87,15 +76,23 @@ describe('Auto Top-up System', () => {
8776
}))
8877

8978
spyOn(billing, 'calculateUsageAndBalance').mockImplementation(balanceMock)
90-
spyOn(billing, 'processAndGrantCredit').mockImplementation(grantCreditsMock)
79+
spyOn(billing, 'validateAutoTopupStatus').mockImplementation(
80+
validateAutoTopupMock
81+
)
82+
spyOn(billing, 'processAndGrantCredit').mockImplementation(
83+
grantCreditsMock
84+
)
9185

86+
// Mock Stripe payment intent creation
9287
mock.module('common/src/util/stripe', () => ({
9388
stripeServer: {
94-
paymentMethods: {
95-
list: paymentMethodsMock,
96-
},
9789
paymentIntents: {
98-
create: paymentIntentMock,
90+
create: mock(() =>
91+
Promise.resolve({
92+
status: 'succeeded',
93+
id: 'pi_123',
94+
})
95+
),
9996
},
10097
},
10198
}))
@@ -110,8 +107,8 @@ describe('Auto Top-up System', () => {
110107
// Should check balance
111108
expect(balanceMock).toHaveBeenCalled()
112109

113-
// Should create payment intent
114-
expect(paymentIntentMock).toHaveBeenCalled()
110+
// Should validate auto top-up status
111+
expect(validateAutoTopupMock).toHaveBeenCalled()
115112

116113
// Should grant credits
117114
expect(grantCreditsMock).toHaveBeenCalled()
@@ -133,16 +130,23 @@ describe('Auto Top-up System', () => {
133130

134131
// Update the spies with the new mock implementations
135132
spyOn(billing, 'calculateUsageAndBalance').mockImplementation(balanceMock)
136-
spyOn(billing, 'processAndGrantCredit').mockImplementation(grantCreditsMock)
133+
spyOn(billing, 'validateAutoTopupStatus').mockImplementation(
134+
validateAutoTopupMock
135+
)
136+
spyOn(billing, 'processAndGrantCredit').mockImplementation(
137+
grantCreditsMock
138+
)
137139

138140
await checkAndTriggerAutoTopup('test-user')
139141

140142
// Should still check settings and balance
141143
expect(dbMock).toHaveBeenCalled()
142144
expect(balanceMock).toHaveBeenCalled()
143145

144-
// But should not create payment or grant credits
145-
expect(paymentIntentMock.mock.calls.length).toBe(0)
146+
// Should still validate auto top-up (this happens before balance check)
147+
expect(validateAutoTopupMock.mock.calls.length).toBe(0)
148+
149+
// But should not grant credits
146150
expect(grantCreditsMock.mock.calls.length).toBe(0)
147151
})
148152

@@ -162,7 +166,12 @@ describe('Auto Top-up System', () => {
162166

163167
// Update the spies with the new mock implementations
164168
spyOn(billing, 'calculateUsageAndBalance').mockImplementation(balanceMock)
165-
spyOn(billing, 'processAndGrantCredit').mockImplementation(grantCreditsMock)
169+
spyOn(billing, 'validateAutoTopupStatus').mockImplementation(
170+
validateAutoTopupMock
171+
)
172+
spyOn(billing, 'processAndGrantCredit').mockImplementation(
173+
grantCreditsMock
174+
)
166175

167176
await checkAndTriggerAutoTopup('test-user')
168177

@@ -172,27 +181,29 @@ describe('Auto Top-up System', () => {
172181
expect(grantCreditsMock.mock.calls[0]?.[1]).toBe(600)
173182
})
174183

175-
it('should disable auto-topup when payment fails', async () => {
176-
// Set up payment failure mock
177-
paymentIntentMock = mock(() =>
184+
it('should disable auto-topup when validation fails', async () => {
185+
// Set up validation failure mock
186+
validateAutoTopupMock = mock(() =>
178187
Promise.resolve({
179-
status: 'requires_payment_method',
188+
blockedReason: 'No valid payment method found',
189+
validPaymentMethod: null,
180190
})
181191
)
182192

183-
// Update the module mock
184-
mock.module('common/src/util/stripe', () => ({
185-
stripeServer: {
186-
paymentMethods: {
187-
list: paymentMethodsMock,
188-
},
189-
paymentIntents: {
190-
create: paymentIntentMock,
191-
},
192-
},
193-
}))
193+
// Update the spy with the new mock implementation
194+
spyOn(billing, 'validateAutoTopupStatus').mockImplementation(
195+
validateAutoTopupMock
196+
)
194197

195-
await expect(checkAndTriggerAutoTopup('test-user')).rejects.toThrow()
198+
await expect(checkAndTriggerAutoTopup('test-user')).rejects.toThrow(
199+
'No valid payment method found'
200+
)
201+
202+
// Should have called validation
203+
expect(validateAutoTopupMock).toHaveBeenCalled()
204+
205+
// Should not grant credits
206+
expect(grantCreditsMock.mock.calls.length).toBe(0)
196207
})
197208

198209
afterEach(() => {

backend/src/__tests__/usage-calculation.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ describe('Usage Calculation System', () => {
118118
purchase: 0,
119119
referral: 0,
120120
admin: 0,
121+
organization: 0,
121122
})
122123
expect(usageThisCycle).toBe(200) // 500 - 300 = 200 used
123124
})
@@ -163,6 +164,7 @@ describe('Usage Calculation System', () => {
163164
purchase: 0,
164165
referral: 0,
165166
admin: 0,
167+
organization: 0,
166168
}) // No positive balances
167169
})
168170

@@ -220,6 +222,7 @@ describe('Usage Calculation System', () => {
220222
purchase: 0, // No positive balance for purchase grant
221223
referral: 0,
222224
admin: 0,
225+
organization: 0,
223226
})
224227

225228
// Principals show original grant amounts
@@ -228,6 +231,7 @@ describe('Usage Calculation System', () => {
228231
purchase: 100,
229232
referral: 0,
230233
admin: 0,
234+
organization: 0,
231235
})
232236

233237
// Usage calculation: (200-100) + (100-(-50)) = 100 + 150 = 250

backend/src/api/org.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import {
2+
Request as ExpressRequest,
3+
Response as ExpressResponse,
4+
NextFunction,
5+
} from 'express'
6+
import { z } from 'zod'
7+
import { findOrganizationForRepository } from '@codebuff/billing'
8+
9+
import { getUserIdFromAuthToken } from '../websockets/websocket-action'
10+
import { logger } from '@/util/logger'
11+
12+
const isRepoCoveredRequestSchema = z.object({
13+
owner: z.string(),
14+
repo: z.string(),
15+
remoteUrl: z.string(),
16+
})
17+
18+
async function isRepoCoveredHandler(
19+
req: ExpressRequest,
20+
res: ExpressResponse,
21+
next: NextFunction
22+
): Promise<void | ExpressResponse> {
23+
try {
24+
const { owner, repo, remoteUrl } = isRepoCoveredRequestSchema.parse(req.body)
25+
26+
// Get user ID from Authorization header
27+
const authHeader = req.headers.authorization
28+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
29+
return res.status(401).json({ error: 'Missing or invalid authorization header' })
30+
}
31+
32+
const authToken = authHeader.substring(7) // Remove 'Bearer ' prefix
33+
const userId = await getUserIdFromAuthToken(authToken)
34+
35+
if (!userId) {
36+
return res.status(401).json({ error: 'Invalid authentication token' })
37+
}
38+
39+
// Check if repository is covered by an organization
40+
const orgLookup = await findOrganizationForRepository(userId, remoteUrl)
41+
42+
return res.status(200).json({
43+
isCovered: orgLookup.found,
44+
organizationName: orgLookup.organizationName,
45+
organizationId: orgLookup.organizationId, // Keep organizationId for now, might be used elsewhere
46+
organizationSlug: orgLookup.organizationSlug, // Add organizationSlug
47+
})
48+
} catch (error) {
49+
logger.error({ error }, 'Error handling /api/orgs/is-repo-covered request')
50+
if (error instanceof z.ZodError) {
51+
return res
52+
.status(400)
53+
.json({ error: 'Invalid request body', issues: error.errors })
54+
}
55+
next(error)
56+
return
57+
}
58+
}
59+
60+
export { isRepoCoveredHandler }

backend/src/api/usage.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ import { eq } from 'drizzle-orm'
1010

1111
import { checkAuth } from '../util/check-auth'
1212
import { genUsageResponse } from '../websockets/websocket-action'
13+
import { getOrganizationUsageResponse } from '@codebuff/billing'
1314
import { logger } from '@/util/logger'
1415

1516
const usageRequestSchema = z.object({
1617
fingerprintId: z.string(),
1718
authToken: z.string().optional(),
19+
orgId: z.string().optional(),
1820
})
1921

2022
async function getUserIdFromAuthToken(
@@ -35,7 +37,7 @@ async function usageHandler(
3537
next: NextFunction
3638
): Promise<void | ExpressResponse> {
3739
try {
38-
const { fingerprintId, authToken } = usageRequestSchema.parse(req.body)
40+
const { fingerprintId, authToken, orgId } = usageRequestSchema.parse(req.body)
3941
const clientSessionId = `api-${fingerprintId}-${Date.now()}`
4042

4143
const authResult = await checkAuth({
@@ -59,6 +61,19 @@ async function usageHandler(
5961
return res.status(401).json({ message: 'Authentication failed' })
6062
}
6163

64+
// If orgId is provided, return organization usage data
65+
if (orgId) {
66+
try {
67+
const orgUsageResponse = await getOrganizationUsageResponse(orgId, userId)
68+
return res.status(200).json(orgUsageResponse)
69+
} catch (error) {
70+
logger.error({ error, orgId, userId }, 'Error fetching organization usage')
71+
// If organization usage fails, fall back to personal usage
72+
logger.info({ orgId, userId }, 'Falling back to personal usage due to organization error')
73+
}
74+
}
75+
76+
// Return personal usage data (default behavior)
6277
const usageResponse = await genUsageResponse(
6378
fingerprintId,
6479
userId,

0 commit comments

Comments
 (0)