Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions cloud/api/organizations.handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export const deleteOrganizationHandler = (organizationId: string) =>
yield* db.organizations.delete({ organizationId, userId: user.id });
});

export const getOrganizationCreditsHandler = (organizationId: string) =>
export const getOrganizationRouterBalanceHandler = (organizationId: string) =>
Effect.gen(function* () {
const db = yield* Database;
const user = yield* AuthenticatedUser;
Expand All @@ -68,10 +68,11 @@ export const getOrganizationCreditsHandler = (organizationId: string) =>
userId: user.id,
});

// Get the customer balance from Stripe
const balance = yield* payments.customers.getBalance(
// Get the router balance info from Stripe
const balanceInfo = yield* payments.products.router.getBalanceInfo(
organization.stripeCustomerId,
);

return { balance };
// Return available balance in centi-cents (client will convert to dollars for display)
return { balance: balanceInfo.availableBalance };
});
13 changes: 8 additions & 5 deletions cloud/api/organizations.schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,10 @@ export const UpdateOrganizationRequestSchema = Schema.Struct({
slug: Schema.optional(OrganizationSlugSchema),
});

export const OrganizationCreditsSchema = Schema.Struct({
balance: Schema.Number,
export const OrganizationRouterBalanceSchema = Schema.Struct({
balance: Schema.BigInt.annotations({
description: "Balance in centi-cents (1/10000 of a dollar)",
}),
});

export type Organization = typeof OrganizationSchema.Type;
Expand All @@ -62,7 +64,8 @@ export type CreateOrganizationRequest =
typeof CreateOrganizationRequestSchema.Type;
export type UpdateOrganizationRequest =
typeof UpdateOrganizationRequestSchema.Type;
export type OrganizationCredits = typeof OrganizationCreditsSchema.Type;
export type OrganizationRouterBalance =
typeof OrganizationRouterBalanceSchema.Type;

export class OrganizationsApi extends HttpApiGroup.make("organizations")
.add(
Expand Down Expand Up @@ -107,9 +110,9 @@ export class OrganizationsApi extends HttpApiGroup.make("organizations")
.addError(StripeError, { status: StripeError.status }),
)
.add(
HttpApiEndpoint.get("credits", "/organizations/:id/credits")
HttpApiEndpoint.get("routerBalance", "/organizations/:id/router-balance")
.setPath(Schema.Struct({ id: Schema.String }))
.addSuccess(OrganizationCreditsSchema)
.addSuccess(OrganizationRouterBalanceSchema)
.addError(NotFoundError, { status: NotFoundError.status })
.addError(PermissionDeniedError, { status: PermissionDeniedError.status })
.addError(StripeError, { status: StripeError.status })
Expand Down
27 changes: 15 additions & 12 deletions cloud/api/organizations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,18 +133,21 @@ describe.sequential("Organizations API", (it) => {
}),
);

it.effect("GET /organizations/:id/credits - get organization credits", () =>
Effect.gen(function* () {
const { client } = yield* TestApiContext;
const credits = yield* client.organizations.credits({
path: { id: org.id },
});

// MockStripe includes various grant types totaling $18
// (see tests/db.ts MockStripe for details)
expect(credits.balance).toBe(18);
expect(typeof credits.balance).toBe("number");
}),
it.effect(
"GET /organizations/:id/router-balance - get organization router balance",
() =>
Effect.gen(function* () {
const { client } = yield* TestApiContext;
const balance = yield* client.organizations.routerBalance({
path: { id: org.id },
});

// MockStripe includes various grant types totaling $18
// (see tests/db.ts MockStripe for details)
// Balance is now in centi-cents: $18 = 180000 centi-cents
expect(balance.balance).toBe(180000n);
expect(typeof balance.balance).toBe("bigint");
}),
);

it.effect("DELETE /organizations/:id - delete organization", () =>
Expand Down
6 changes: 4 additions & 2 deletions cloud/api/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
getOrganizationHandler,
updateOrganizationHandler,
deleteOrganizationHandler,
getOrganizationCreditsHandler,
getOrganizationRouterBalanceHandler,
} from "@/api/organizations.handlers";
import {
listProjectsHandler,
Expand Down Expand Up @@ -80,7 +80,9 @@ const OrganizationsHandlersLive = HttpApiBuilder.group(
updateOrganizationHandler(path.id, payload),
)
.handle("delete", ({ path }) => deleteOrganizationHandler(path.id))
.handle("credits", ({ path }) => getOrganizationCreditsHandler(path.id)),
.handle("routerBalance", ({ path }) =>
getOrganizationRouterBalanceHandler(path.id),
),
);

const ProjectsHandlersLive = HttpApiBuilder.group(
Expand Down
8 changes: 5 additions & 3 deletions cloud/app/api/organizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,16 @@ export const useDeleteOrganization = () => {
});
};

export const useOrganizationCredits = (organizationId: string | undefined) => {
export const useOrganizationRouterBalance = (
organizationId: string | undefined,
) => {
return useQuery({
...eq.queryOptions({
queryKey: ["organizations", organizationId, "credits"],
queryKey: ["organizations", organizationId, "router-balance"],
queryFn: () =>
Effect.gen(function* () {
const client = yield* ApiClient;
return yield* client.organizations.credits({
return yield* client.organizations.routerBalance({
path: { id: organizationId! },
});
}),
Expand Down
7 changes: 6 additions & 1 deletion cloud/app/lib/effect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,16 @@ function createAppServicesLayer(databaseUrl: string) {
throw new Error("STRIPE_ROUTER_PRICE_ID environment variable is not set");
}

const routerMeterId = process.env.STRIPE_ROUTER_METER_ID;
if (!routerMeterId) {
throw new Error("STRIPE_ROUTER_METER_ID environment variable is not set");
}

return Layer.mergeAll(
Layer.succeed(SettingsService, getSettings()),
Database.Live({
database: { connectionString: databaseUrl },
payments: { apiKey: stripeApiKey, routerPriceId },
payments: { apiKey: stripeApiKey, routerPriceId, routerMeterId },
}).pipe(Layer.orDie),
Layer.succeed(AuthService, createAuthService()),
);
Expand Down
1 change: 1 addition & 0 deletions cloud/app/routes/api.v0.$.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export const Route = createFileRoute("/api/v0/$")({
payments: {
apiKey: process.env.STRIPE_SECRET_KEY || "",
routerPriceId: process.env.STRIPE_ROUTER_PRICE_ID || "",
routerMeterId: process.env.STRIPE_ROUTER_METER_ID || "",
},
}),
),
Expand Down
14 changes: 7 additions & 7 deletions cloud/app/routes/dashboard/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { DashboardLayout } from "@/app/components/dashboard-layout";
import { useOrganization } from "@/app/contexts/organization";
import { useProject } from "@/app/contexts/project";
import { useEnvironment } from "@/app/contexts/environment";
import { useOrganizationCredits } from "@/app/api/organizations";
import { useOrganizationRouterBalance } from "@/app/api/organizations";
import { centicentsToDollars } from "@/api/router/cost-utils";

export const Route = createFileRoute("/dashboard/")({
component: DashboardPage,
Expand All @@ -15,9 +16,8 @@ function DashboardContent() {
const { selectedProject } = useProject();
const { selectedEnvironment } = useEnvironment();

const { data: credits, isLoading: creditsLoading } = useOrganizationCredits(
selectedOrganization?.id,
);
const { data: routerBalance, isLoading: routerBalanceLoading } =
useOrganizationRouterBalance(selectedOrganization?.id);

return (
<div className="p-6">
Expand All @@ -37,10 +37,10 @@ function DashboardContent() {
Router Credits
</h2>
<p className="text-lg font-semibold">
{creditsLoading ? (
{routerBalanceLoading ? (
<span className="text-muted-foreground">Loading...</span>
) : credits ? (
`$${credits.balance.toFixed(2)}`
) : routerBalance ? (
`$${centicentsToDollars(routerBalance.balance).toFixed(2)}`
) : (
<span className="text-muted-foreground">—</span>
)}
Expand Down
1 change: 1 addition & 0 deletions cloud/app/routes/router.v0.$provider.$.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ export const Route = createFileRoute("/router/v0/$provider/$")({
payments: {
apiKey: process.env.STRIPE_SECRET_KEY || "",
routerPriceId: process.env.STRIPE_ROUTER_PRICE_ID || "",
routerMeterId: process.env.STRIPE_ROUTER_METER_ID || "",
},
}),
),
Expand Down
1 change: 1 addition & 0 deletions cloud/bun.lock
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"decimal.js": "^10.6.0",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.45.1",
"effect": "^3.19.13",
Expand Down
6 changes: 5 additions & 1 deletion cloud/db/database.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ describe("Database", () => {
it("creates a Database layer from config", () => {
const layer = Database.Live({
database: { connectionString: "postgresql://test" },
payments: { apiKey: "sk_test_key", routerPriceId: "price_test" },
payments: {
apiKey: "sk_test_key",
routerPriceId: "price_test",
routerMeterId: "meter_test",
},
});

// Verify it returns a Layer
Expand Down
4 changes: 3 additions & 1 deletion cloud/db/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,8 +213,10 @@ export class Database extends Context.Tag("Database")<
database: DrizzleORMConfig;
payments: StripeConfig;
}) => {
const paymentsLayer = Payments.Live(config.payments);
const drizzleLayer = DrizzleORM.layer(config.database);
const paymentsLayer = Payments.Live(config.payments).pipe(
Layer.provide(drizzleLayer),
);

return Layer.mergeAll(
Database.Default.pipe(
Expand Down
2 changes: 1 addition & 1 deletion cloud/db/organizations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import {
TestOrganizationFixture,
TestDrizzleORM,
MockDrizzleORM,
MockPayments,
} from "@/tests/db";
import { MockPayments } from "@/tests/payments";
import { Database } from "@/db/database";
import { Effect, Layer } from "effect";
import {
Expand Down
64 changes: 64 additions & 0 deletions cloud/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,70 @@ export class StripeError extends Schema.TaggedError<StripeError>()(
static readonly status = 500 as const;
}

/**
* Error that occurs when attempting to reserve funds but insufficient balance is available.
*
* This error is returned when:
* - Available balance < estimated cost
* - Available = total balance - SUM(active reservations)
*
* The HTTP status code is 402 (Payment Required) to indicate that the user
* needs to add more credits before they can make the request.
*
* @example
* ```ts
* const reservation = yield* payments.customers.reserveFunds({ ... }).pipe(
* Effect.catchTag("InsufficientFundsError", (error) => {
* console.error(`Need $${error.required}, have $${error.available}`);
* return Effect.fail(new UnauthorizedError({ message: "Top up your credits" }));
* })
* );
* ```
*/
export class InsufficientFundsError extends Schema.TaggedError<InsufficientFundsError>()(
"InsufficientFundsError",
{
message: Schema.String,
required: Schema.Number,
available: Schema.Number,
},
) {
static readonly status = 402 as const;
}

/**
* Error that occurs when attempting to modify a credit reservation in an invalid state.
*
* This error is returned when trying to settle or release a reservation that:
* - Doesn't exist (may have been deleted or never created)
* - Is already settled (would cause double-charging)
* - Is already released (conflicting lifecycle transitions)
* - Is expired (should use CRON job to handle)
*
* The HTTP status code is 500 because this indicates a bug in our reservation
* lifecycle management that should never happen in normal operation.
*
* @example
* ```ts
* yield* payments.customers.settleFunds(reservationId, actualCost).pipe(
* Effect.catchTag("ReservationStateError", (error) => {
* console.error(`Reservation ${error.reservationId} in invalid state:`, error.message);
* // Log for investigation - this indicates a bug
* return Effect.fail(new InternalError({ message: "Reservation error" }));
* })
* );
* ```
*/
export class ReservationStateError extends Schema.TaggedError<ReservationStateError>()(
"ReservationStateError",
{
message: Schema.String,
reservationId: Schema.String,
},
) {
static readonly status = 500 as const;
}

// =============================================================================
// Proxy Errors
// =============================================================================
Expand Down
1 change: 1 addition & 0 deletions cloud/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"decimal.js": "^10.6.0",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.45.1",
"effect": "^3.19.13",
Expand Down
2 changes: 2 additions & 0 deletions cloud/payments/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ describe("Stripe", () => {
const layer = Stripe.layer({
apiKey: "sk_test_123",
routerPriceId: "price_test",
routerMeterId: "meter_test",
apiVersion: "2023-10-16",
});

Expand All @@ -97,6 +98,7 @@ describe("Stripe", () => {
const layer = Stripe.layer({
apiKey: "sk_test_123",
routerPriceId: "price_test",
routerMeterId: "meter_test",
});

expect(layer).toBeDefined();
Expand Down
2 changes: 2 additions & 0 deletions cloud/payments/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ export interface StripeConfig {
apiKey: string;
/** Stripe price ID for usage-based credits (metered billing) */
routerPriceId: string;
/** Stripe meter ID for tracking usage-based credits */
routerMeterId: string;
/** Optional API version (defaults to Stripe SDK default) */
apiVersion?: string;
}
Expand Down
Loading