diff --git a/cloud/api/organizations.handlers.ts b/cloud/api/organizations.handlers.ts
index b1744e56c4..7340660757 100644
--- a/cloud/api/organizations.handlers.ts
+++ b/cloud/api/organizations.handlers.ts
@@ -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;
@@ -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 };
});
diff --git a/cloud/api/organizations.schemas.ts b/cloud/api/organizations.schemas.ts
index d02fd79212..bb5dfe8b70 100644
--- a/cloud/api/organizations.schemas.ts
+++ b/cloud/api/organizations.schemas.ts
@@ -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;
@@ -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(
@@ -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 })
diff --git a/cloud/api/organizations.test.ts b/cloud/api/organizations.test.ts
index 0447a88964..4d3dd9166a 100644
--- a/cloud/api/organizations.test.ts
+++ b/cloud/api/organizations.test.ts
@@ -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", () =>
diff --git a/cloud/api/router.ts b/cloud/api/router.ts
index 74bc3d4552..11c350d32b 100644
--- a/cloud/api/router.ts
+++ b/cloud/api/router.ts
@@ -9,7 +9,7 @@ import {
getOrganizationHandler,
updateOrganizationHandler,
deleteOrganizationHandler,
- getOrganizationCreditsHandler,
+ getOrganizationRouterBalanceHandler,
} from "@/api/organizations.handlers";
import {
listProjectsHandler,
@@ -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(
diff --git a/cloud/app/api/organizations.ts b/cloud/app/api/organizations.ts
index 2b8fdce32c..ba5f8e8945 100644
--- a/cloud/app/api/organizations.ts
+++ b/cloud/app/api/organizations.ts
@@ -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! },
});
}),
diff --git a/cloud/app/lib/effect.ts b/cloud/app/lib/effect.ts
index 7d6493a74c..01f2066709 100644
--- a/cloud/app/lib/effect.ts
+++ b/cloud/app/lib/effect.ts
@@ -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()),
);
diff --git a/cloud/app/routes/api.v0.$.tsx b/cloud/app/routes/api.v0.$.tsx
index 68b29ef88a..bf6e68a9f8 100644
--- a/cloud/app/routes/api.v0.$.tsx
+++ b/cloud/app/routes/api.v0.$.tsx
@@ -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 || "",
},
}),
),
diff --git a/cloud/app/routes/dashboard/index.tsx b/cloud/app/routes/dashboard/index.tsx
index 8bae2d8d45..1957224fe7 100644
--- a/cloud/app/routes/dashboard/index.tsx
+++ b/cloud/app/routes/dashboard/index.tsx
@@ -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,
@@ -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 (
@@ -37,10 +37,10 @@ function DashboardContent() {
Router Credits
- {creditsLoading ? (
+ {routerBalanceLoading ? (
Loading...
- ) : credits ? (
- `$${credits.balance.toFixed(2)}`
+ ) : routerBalance ? (
+ `$${centicentsToDollars(routerBalance.balance).toFixed(2)}`
) : (
—
)}
diff --git a/cloud/app/routes/router.v0.$provider.$.tsx b/cloud/app/routes/router.v0.$provider.$.tsx
index 5ae784d404..ee294cde8b 100644
--- a/cloud/app/routes/router.v0.$provider.$.tsx
+++ b/cloud/app/routes/router.v0.$provider.$.tsx
@@ -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 || "",
},
}),
),
diff --git a/cloud/bun.lock b/cloud/bun.lock
index 4d75033f7d..069db6154c 100644
--- a/cloud/bun.lock
+++ b/cloud/bun.lock
@@ -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",
diff --git a/cloud/db/database.test.ts b/cloud/db/database.test.ts
index f5fd3461d1..e6f69abcf7 100644
--- a/cloud/db/database.test.ts
+++ b/cloud/db/database.test.ts
@@ -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
diff --git a/cloud/db/database.ts b/cloud/db/database.ts
index 911e43e5b7..57cb6d2f0e 100644
--- a/cloud/db/database.ts
+++ b/cloud/db/database.ts
@@ -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(
diff --git a/cloud/db/organizations.test.ts b/cloud/db/organizations.test.ts
index b463dc14ea..8640843f8e 100644
--- a/cloud/db/organizations.test.ts
+++ b/cloud/db/organizations.test.ts
@@ -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 {
diff --git a/cloud/errors.ts b/cloud/errors.ts
index cbdc434ddf..bc58e5832d 100644
--- a/cloud/errors.ts
+++ b/cloud/errors.ts
@@ -157,6 +157,70 @@ export class StripeError extends Schema.TaggedError()(
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",
+ {
+ 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",
+ {
+ message: Schema.String,
+ reservationId: Schema.String,
+ },
+) {
+ static readonly status = 500 as const;
+}
+
// =============================================================================
// Proxy Errors
// =============================================================================
diff --git a/cloud/package.json b/cloud/package.json
index d94a071887..ef2ce7259e 100644
--- a/cloud/package.json
+++ b/cloud/package.json
@@ -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",
diff --git a/cloud/payments/client.test.ts b/cloud/payments/client.test.ts
index cacb4b0e81..03aa07cde6 100644
--- a/cloud/payments/client.test.ts
+++ b/cloud/payments/client.test.ts
@@ -86,6 +86,7 @@ describe("Stripe", () => {
const layer = Stripe.layer({
apiKey: "sk_test_123",
routerPriceId: "price_test",
+ routerMeterId: "meter_test",
apiVersion: "2023-10-16",
});
@@ -97,6 +98,7 @@ describe("Stripe", () => {
const layer = Stripe.layer({
apiKey: "sk_test_123",
routerPriceId: "price_test",
+ routerMeterId: "meter_test",
});
expect(layer).toBeDefined();
diff --git a/cloud/payments/client.ts b/cloud/payments/client.ts
index 1325da3ff4..5d1eab6dee 100644
--- a/cloud/payments/client.ts
+++ b/cloud/payments/client.ts
@@ -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;
}
diff --git a/cloud/payments/customers.test.ts b/cloud/payments/customers.test.ts
index 30bec7f667..a69a71ea4b 100644
--- a/cloud/payments/customers.test.ts
+++ b/cloud/payments/customers.test.ts
@@ -58,6 +58,7 @@ describe("customers", () => {
config: {
apiKey: "sk_test_mock",
routerPriceId: "price_test",
+ routerMeterId: "meter_test",
},
} as unknown as Context.Tag.Service),
),
@@ -110,6 +111,7 @@ describe("customers", () => {
config: {
apiKey: "sk_test_mock",
routerPriceId: "price_test",
+ routerMeterId: "meter_test",
},
} as unknown as Context.Tag.Service),
),
@@ -151,6 +153,7 @@ describe("customers", () => {
config: {
apiKey: "sk_test_mock",
routerPriceId: "price_test",
+ routerMeterId: "meter_test",
},
} as unknown as Context.Tag.Service),
),
@@ -187,6 +190,7 @@ describe("customers", () => {
config: {
apiKey: "sk_test_mock",
routerPriceId: "price_test",
+ routerMeterId: "meter_test",
},
} as unknown as Context.Tag.Service),
),
@@ -276,6 +280,7 @@ describe("customers", () => {
config: {
apiKey: "sk_test_mock",
routerPriceId: "price_test",
+ routerMeterId: "meter_test",
},
} as unknown as Context.Tag.Service),
),
@@ -336,6 +341,7 @@ describe("customers", () => {
config: {
apiKey: "sk_test_mock",
routerPriceId: "price_test",
+ routerMeterId: "meter_test",
},
} as unknown as Context.Tag.Service),
),
@@ -376,6 +382,7 @@ describe("customers", () => {
config: {
apiKey: "sk_test_mock",
routerPriceId: "price_test",
+ routerMeterId: "meter_test",
},
} as unknown as Context.Tag.Service),
),
@@ -435,6 +442,7 @@ describe("customers", () => {
config: {
apiKey: "sk_test_mock",
routerPriceId: "price_test",
+ routerMeterId: "meter_test",
},
} as unknown as Context.Tag.Service),
),
@@ -485,6 +493,7 @@ describe("customers", () => {
config: {
apiKey: "sk_test_mock",
routerPriceId: "price_test",
+ routerMeterId: "meter_test",
},
} as unknown as Context.Tag.Service),
),
@@ -536,112 +545,7 @@ describe("customers", () => {
config: {
apiKey: "sk_test_mock",
routerPriceId: "price_test",
- },
- } as unknown as Context.Tag.Service),
- ),
- ),
- ),
- ),
- );
- });
-
- describe("getBalance", () => {
- it.effect(
- "correctly filters and sums credits from various grant types",
- () =>
- Effect.gen(function* () {
- const payments = yield* Payments;
-
- // MockStripe includes:
- // - Valid: $7 USD with router price ✓
- // - Valid: $11 USD with router price ✓
- // - Invalid: €5 EUR with router price (wrong currency) ✗
- // - Invalid: no monetary amount ✗
- // - Invalid: $19 USD with different price (wrong price) ✗
- // - Invalid: $23 USD with no scope (no explicit scope) ✗
- // Expected total: $7 + $11 = $18
- // No other combination of grants equals $18
- const balance = yield* payments.customers.getBalance("cus_123");
-
- expect(balance).toBe(18);
- }).pipe(Effect.provide(DefaultMockPayments)),
- );
-
- it.effect("returns 0 for customer with no credit grants", () =>
- Effect.gen(function* () {
- const payments = yield* Payments;
-
- const balance = yield* payments.customers.getBalance("cus_123");
-
- expect(balance).toBe(0);
- }).pipe(
- Effect.provide(
- Payments.Default.pipe(
- Layer.provide(
- Layer.succeed(Stripe, {
- customers: {
- create: () => Effect.void,
- del: () => Effect.void,
- },
- subscriptions: {
- create: () => Effect.void,
- },
- billing: {
- creditGrants: {
- list: () =>
- Effect.succeed({
- object: "list" as const,
- data: [],
- has_more: false,
- }),
- },
- },
- config: {
- apiKey: "sk_test_mock",
- routerPriceId: "price_test_mock_for_testing",
- },
- } as unknown as Context.Tag.Service),
- ),
- ),
- ),
- ),
- );
-
- it.effect("returns StripeError when API call fails", () =>
- Effect.gen(function* () {
- const payments = yield* Payments;
-
- const result = yield* payments.customers
- .getBalance("cus_123")
- .pipe(Effect.flip);
-
- expect(result).toBeInstanceOf(StripeError);
- expect(result.message).toBe("Failed to fetch credit grants");
- }).pipe(
- Effect.provide(
- Payments.Default.pipe(
- Layer.provide(
- Layer.succeed(Stripe, {
- customers: {
- create: () => Effect.void,
- del: () => Effect.void,
- },
- subscriptions: {
- create: () => Effect.void,
- },
- billing: {
- creditGrants: {
- list: () =>
- Effect.fail(
- new StripeError({
- message: "Failed to fetch credit grants",
- }),
- ),
- },
- },
- config: {
- apiKey: "sk_test_mock",
- routerPriceId: "price_test_mock_for_testing",
+ routerMeterId: "meter_test",
},
} as unknown as Context.Tag.Service),
),
diff --git a/cloud/payments/customers.ts b/cloud/payments/customers.ts
index aa47bcde1a..8fcbeac32e 100644
--- a/cloud/payments/customers.ts
+++ b/cloud/payments/customers.ts
@@ -223,48 +223,4 @@ export class Customers {
yield* stripe.customers.del(customerId);
});
}
-
- /**
- * Gets the router credit balance for a Stripe customer from billing credit grants.
- *
- * Fetches all credit grants for the customer and filters for those that are
- * applicable to the router price (metered usage-based billing). Only counts
- * grants that explicitly include the router price ID in their applicability
- * scope.
- *
- * @param customerId - The Stripe customer ID
- * @returns The router credit balance in dollars (e.g., 10.00 for $10 credit)
- * @throws StripeError - If API call fails
- */
- getBalance(customerId: string): Effect.Effect {
- return Effect.gen(function* () {
- const stripe = yield* Stripe;
-
- const credit_grants = yield* stripe.billing.creditGrants.list({
- customer: customerId,
- });
-
- let totalCredits = 0;
-
- for (const grant of credit_grants.data) {
- if (!grant.amount.monetary) continue;
-
- const config = grant.applicability_config;
- if (
- !config.scope ||
- !config.scope.prices?.some(
- (price) => price.id === stripe.config.routerPriceId,
- )
- )
- continue;
-
- const { value, currency } = grant.amount.monetary;
- if (currency === "usd") {
- totalCredits += value / 100;
- }
- }
-
- return totalCredits;
- });
- }
}
diff --git a/cloud/payments/products/router.test.ts b/cloud/payments/products/router.test.ts
new file mode 100644
index 0000000000..ef1b58d215
--- /dev/null
+++ b/cloud/payments/products/router.test.ts
@@ -0,0 +1,992 @@
+import { describe, it, expect } from "@effect/vitest";
+import { Context, Effect, Layer } from "effect";
+import { Stripe } from "@/payments/client";
+import { Payments } from "@/payments/service";
+import {
+ DatabaseError,
+ InsufficientFundsError,
+ ReservationStateError,
+ StripeError,
+} from "@/errors";
+import { MockDrizzleORMLayer } from "@/tests/mock-drizzle";
+import { assert } from "@/tests/db";
+import { DrizzleORM } from "@/db/client";
+
+describe("Router Product", () => {
+ describe("getUsageMeterBalance", () => {
+ it.effect("returns 0n when no router subscription exists", () =>
+ Effect.gen(function* () {
+ const payments = yield* Payments;
+
+ const balance =
+ yield* payments.products.router.getUsageMeterBalance("cus_123");
+
+ expect(balance).toBe(0n);
+ }).pipe(
+ Effect.provide(
+ Payments.Default.pipe(
+ Layer.provide(
+ Layer.merge(
+ Layer.succeed(Stripe, {
+ subscriptions: {
+ list: () =>
+ Effect.succeed({
+ object: "list" as const,
+ data: [],
+ has_more: false,
+ }),
+ },
+ config: {
+ apiKey: "sk_test_mock",
+ routerPriceId: "price_test_mock",
+ routerMeterId: "meter_test_mock",
+ },
+ } as unknown as Context.Tag.Service),
+ MockDrizzleORMLayer,
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ it.effect("calculates balance from meter event summaries", () =>
+ Effect.gen(function* () {
+ const payments = yield* Payments;
+
+ const balance =
+ yield* payments.products.router.getUsageMeterBalance("cus_123");
+
+ // 1000 units = 1000 centi-cents (1 meter unit = 1 centi-cent)
+ expect(balance).toBe(1000n);
+ }).pipe(
+ Effect.provide(
+ Payments.Default.pipe(
+ Layer.provide(
+ Layer.merge(
+ Layer.succeed(Stripe, {
+ subscriptions: {
+ list: () =>
+ Effect.succeed({
+ object: "list" as const,
+ data: [
+ {
+ id: "sub_123",
+ customer: "cus_123",
+ current_period_start: 1000000,
+ current_period_end: 2000000,
+ items: {
+ data: [{ price: { id: "price_test_mock" } }],
+ },
+ },
+ ],
+ has_more: false,
+ }),
+ },
+ billing: {
+ meters: {
+ listEventSummaries: () =>
+ Effect.succeed({
+ object: "list" as const,
+ data: [{ aggregated_value: 1000 }],
+ has_more: false,
+ }),
+ },
+ },
+ config: {
+ apiKey: "sk_test_mock",
+ routerPriceId: "price_test_mock",
+ routerMeterId: "meter_test_mock",
+ },
+ } as unknown as Context.Tag.Service),
+ MockDrizzleORMLayer,
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ });
+
+ describe("getBalanceInfo", () => {
+ it.effect("returns comprehensive balance information", () =>
+ Effect.gen(function* () {
+ const payments = yield* Payments;
+
+ // Get balance info
+ const balanceInfo =
+ yield* payments.products.router.getBalanceInfo("cus_123");
+
+ // Credit grants: 1000 cents * 100 = 100000 centi-cents ($10)
+ expect(balanceInfo.creditBalance).toBe(100000n);
+ // Meter usage: 1000 units = 1000 centi-cents ($0.10)
+ expect(balanceInfo.meterUsage).toBe(1000n);
+ // Active reservations: 5000 centi-cents ($0.50)
+ expect(balanceInfo.activeReservations).toBe(5000n);
+ // Available: 100000 - 1000 = 99000 centi-cents ($9.90)
+ expect(balanceInfo.availableBalance).toBe(99000n);
+ }).pipe(
+ Effect.provide(Payments.Default),
+ Effect.provide(
+ Layer.succeed(DrizzleORM, {
+ select: () => ({
+ from: () => ({
+ where: () => Effect.succeed([{ sum: "5000" }]), // Mock active reservations
+ }),
+ }),
+ } as unknown as Context.Tag.Service),
+ ),
+ Effect.provide(
+ Layer.succeed(Stripe, {
+ billing: {
+ creditGrants: {
+ list: () =>
+ Effect.succeed({
+ object: "list" as const,
+ data: [
+ {
+ amount: {
+ monetary: { value: 1000, currency: "usd" },
+ },
+ applicability_config: {
+ scope: {
+ prices: [{ id: "price_test_mock" }],
+ },
+ },
+ },
+ ],
+ has_more: false,
+ }),
+ },
+ meters: {
+ listEventSummaries: () =>
+ Effect.succeed({
+ object: "list" as const,
+ data: [{ aggregated_value: 1000 }],
+ has_more: false,
+ }),
+ },
+ },
+ subscriptions: {
+ list: () =>
+ Effect.succeed({
+ object: "list" as const,
+ data: [
+ {
+ id: "sub_123",
+ customer: "cus_123",
+ current_period_start: 1000000,
+ current_period_end: 2000000,
+ items: {
+ data: [{ price: { id: "price_test_mock" } }],
+ },
+ },
+ ],
+ has_more: false,
+ }),
+ },
+ config: {
+ apiKey: "sk_test_mock",
+ routerPriceId: "price_test_mock",
+ routerMeterId: "meter_test_mock",
+ },
+ } as unknown as Context.Tag.Service),
+ ),
+ ),
+ );
+ });
+
+ describe("chargeUsageMeter", () => {
+ it.effect("charges meter with gas fee applied", () => {
+ let capturedValue: string | undefined;
+
+ return Effect.gen(function* () {
+ const payments = yield* Payments;
+
+ // Charge 10000 centi-cents ($1.00)
+ yield* payments.products.router.chargeUsageMeter("cus_123", 10000n);
+
+ // 10000 * 1.05 (gas fee) = 10500 centi-cents
+ // 1 meter unit = 1 centi-cent, so meter value = 10500
+ expect(capturedValue).toBe("10500");
+ }).pipe(
+ Effect.provide(
+ Payments.Default.pipe(
+ Layer.provide(
+ Layer.merge(
+ Layer.succeed(Stripe, {
+ billing: {
+ meterEvents: {
+ create: (params: {
+ event_name: string;
+ payload: { stripe_customer_id: string; value: string };
+ timestamp: number;
+ }) =>
+ Effect.sync(() => {
+ capturedValue = params.payload.value;
+ }),
+ },
+ },
+ config: {
+ apiKey: "sk_test_mock",
+ routerPriceId: "price_test_mock",
+ routerMeterId: "meter_test_mock",
+ },
+ } as unknown as Context.Tag.Service),
+ MockDrizzleORMLayer,
+ ),
+ ),
+ ),
+ ),
+ );
+ });
+
+ it.effect("handles meter event creation failure", () =>
+ Effect.gen(function* () {
+ const payments = yield* Payments;
+
+ const result = yield* payments.products.router
+ .chargeUsageMeter("cus_123", 10000n)
+ .pipe(Effect.flip);
+
+ expect(result).toBeInstanceOf(StripeError);
+ expect(result.message).toBe("Failed to create meter event");
+ }).pipe(
+ Effect.provide(
+ Payments.Default.pipe(
+ Layer.provide(
+ Layer.merge(
+ Layer.succeed(Stripe, {
+ billing: {
+ meterEvents: {
+ create: () =>
+ Effect.fail(
+ new StripeError({
+ message: "Failed to create meter event",
+ }),
+ ),
+ },
+ },
+ config: {
+ apiKey: "sk_test_mock",
+ routerPriceId: "price_test_mock",
+ routerMeterId: "meter_test_mock",
+ },
+ } as unknown as Context.Tag.Service),
+ MockDrizzleORMLayer,
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ });
+
+ describe("reserveFunds", () => {
+ it.effect(
+ "returns DatabaseError when calculating active reservations fails",
+ () =>
+ Effect.gen(function* () {
+ const payments = yield* Payments;
+
+ const result = yield* payments.products.router
+ .reserveFunds({
+ stripeCustomerId: "cus_123",
+ estimatedCostCenticents: 500n, // $0.05
+ routerRequestId: "request_test",
+ })
+ .pipe(Effect.flip);
+
+ assert(result instanceof DatabaseError);
+ expect(result.message).toBe(
+ "Failed to calculate active reservations",
+ );
+ }).pipe(
+ Effect.provide(Payments.Default),
+ Effect.provide(
+ Layer.succeed(DrizzleORM, {
+ select: () => ({
+ from: () => ({
+ where: () => Effect.fail(new Error("Database query failed")),
+ }),
+ }),
+ } as unknown as Context.Tag.Service),
+ ),
+ Effect.provide(
+ Layer.succeed(Stripe, {
+ billing: {
+ creditGrants: {
+ list: () =>
+ Effect.succeed({
+ object: "list" as const,
+ data: [
+ {
+ amount: {
+ monetary: { value: 1000, currency: "usd" },
+ },
+ applicability_config: {
+ scope: {
+ prices: [{ id: "price_test_mock" }],
+ },
+ },
+ },
+ ],
+ has_more: false,
+ }),
+ },
+ meters: {
+ listEventSummaries: () =>
+ Effect.succeed({
+ object: "list" as const,
+ data: [{ aggregated_value: 0 }],
+ has_more: false,
+ }),
+ },
+ },
+ subscriptions: {
+ list: () =>
+ Effect.succeed({
+ object: "list" as const,
+ data: [
+ {
+ id: "sub_123",
+ customer: "cus_123",
+ current_period_start: 1000000,
+ current_period_end: 2000000,
+ items: {
+ data: [{ price: { id: "price_test_mock" } }],
+ },
+ },
+ ],
+ has_more: false,
+ }),
+ },
+ config: {
+ apiKey: "sk_test_mock",
+ routerPriceId: "price_test_mock",
+ routerMeterId: "meter_test_mock",
+ },
+ } as unknown as Context.Tag.Service),
+ ),
+ ),
+ );
+
+ it.effect("returns DatabaseError when creating reservation fails", () =>
+ Effect.gen(function* () {
+ const payments = yield* Payments;
+
+ const result = yield* payments.products.router
+ .reserveFunds({
+ stripeCustomerId: "cus_123",
+ estimatedCostCenticents: 500n, // $0.05
+ routerRequestId: "request_test",
+ })
+ .pipe(Effect.flip);
+
+ assert(result instanceof DatabaseError);
+ expect(result.message).toBe("Failed to create credit reservation");
+ }).pipe(
+ Effect.provide(Payments.Default),
+ Effect.provide(
+ Layer.succeed(DrizzleORM, {
+ select: () => ({
+ from: () => ({
+ where: () => Effect.succeed([{ sum: "0" }]),
+ }),
+ }),
+ insert: () => ({
+ values: () => ({
+ returning: () =>
+ Effect.fail(new Error("Database insert failed")),
+ }),
+ }),
+ } as unknown as Context.Tag.Service),
+ ),
+ Effect.provide(
+ Layer.succeed(Stripe, {
+ billing: {
+ creditGrants: {
+ list: () =>
+ Effect.succeed({
+ object: "list" as const,
+ data: [
+ {
+ amount: {
+ monetary: { value: 1000, currency: "usd" },
+ },
+ applicability_config: {
+ scope: {
+ prices: [{ id: "price_test_mock" }],
+ },
+ },
+ },
+ ],
+ has_more: false,
+ }),
+ },
+ meters: {
+ listEventSummaries: () =>
+ Effect.succeed({
+ object: "list" as const,
+ data: [{ aggregated_value: 0 }],
+ has_more: false,
+ }),
+ },
+ },
+ subscriptions: {
+ list: () =>
+ Effect.succeed({
+ object: "list" as const,
+ data: [
+ {
+ id: "sub_123",
+ customer: "cus_123",
+ current_period_start: 1000000,
+ current_period_end: 2000000,
+ items: {
+ data: [{ price: { id: "price_test_mock" } }],
+ },
+ },
+ ],
+ has_more: false,
+ }),
+ },
+ config: {
+ apiKey: "sk_test_mock",
+ routerPriceId: "price_test_mock",
+ routerMeterId: "meter_test_mock",
+ },
+ } as unknown as Context.Tag.Service),
+ ),
+ ),
+ );
+
+ it.effect("successfully reserves funds when no active reservations", () =>
+ Effect.gen(function* () {
+ const payments = yield* Payments;
+
+ const reservationId = yield* payments.products.router.reserveFunds({
+ stripeCustomerId: "cus_123",
+ estimatedCostCenticents: 500n, // $0.05
+ routerRequestId: "request_test",
+ });
+
+ expect(reservationId).toContain("mock_");
+ }).pipe(
+ Effect.provide(Payments.Default),
+ Effect.provide(
+ Layer.succeed(DrizzleORM, {
+ select: () => ({
+ from: () => ({
+ where: () => Effect.succeed([]), // Empty array, triggers ?? "0" fallback
+ }),
+ }),
+ insert: () => ({
+ values: () => ({
+ returning: () =>
+ Effect.succeed([{ id: `mock_${crypto.randomUUID()}` }]),
+ }),
+ }),
+ } as unknown as Context.Tag.Service),
+ ),
+ Effect.provide(
+ Layer.succeed(Stripe, {
+ billing: {
+ creditGrants: {
+ list: () =>
+ Effect.succeed({
+ object: "list" as const,
+ data: [
+ {
+ amount: {
+ monetary: { value: 1000, currency: "usd" },
+ },
+ applicability_config: {
+ scope: {
+ prices: [{ id: "price_test_mock" }],
+ },
+ },
+ },
+ ],
+ has_more: false,
+ }),
+ },
+ meters: {
+ listEventSummaries: () =>
+ Effect.succeed({
+ object: "list" as const,
+ data: [{ aggregated_value: 0 }],
+ has_more: false,
+ }),
+ },
+ },
+ subscriptions: {
+ list: () =>
+ Effect.succeed({
+ object: "list" as const,
+ data: [
+ {
+ id: "sub_123",
+ customer: "cus_123",
+ current_period_start: 1000000,
+ current_period_end: 2000000,
+ items: {
+ data: [{ price: { id: "price_test_mock" } }],
+ },
+ },
+ ],
+ has_more: false,
+ }),
+ },
+ config: {
+ apiKey: "sk_test_mock",
+ routerPriceId: "price_test_mock",
+ routerMeterId: "meter_test_mock",
+ },
+ } as unknown as Context.Tag.Service),
+ ),
+ ),
+ );
+
+ it.effect("fails with InsufficientFundsError when balance too low", () =>
+ Effect.gen(function* () {
+ const payments = yield* Payments;
+
+ const result = yield* payments.products.router
+ .reserveFunds({
+ stripeCustomerId: "cus_123",
+ estimatedCostCenticents: 100500n, // $10.05 - More than available balance ($10)
+ routerRequestId: "request_test",
+ })
+ .pipe(Effect.flip);
+
+ assert(result instanceof InsufficientFundsError);
+ expect(result.required).toBe(10.05);
+ expect(result.available).toBeLessThan(10.05);
+ }).pipe(
+ Effect.provide(Payments.Default),
+ Effect.provide(MockDrizzleORMLayer),
+ Effect.provide(
+ Layer.succeed(Stripe, {
+ billing: {
+ creditGrants: {
+ list: () =>
+ Effect.succeed({
+ object: "list" as const,
+ data: [
+ {
+ amount: {
+ monetary: { value: 1000, currency: "usd" },
+ },
+ applicability_config: {
+ scope: {
+ prices: [{ id: "price_test_mock" }],
+ },
+ },
+ },
+ ],
+ has_more: false,
+ }),
+ },
+ meters: {
+ listEventSummaries: () =>
+ Effect.succeed({
+ object: "list" as const,
+ data: [{ aggregated_value: 0 }],
+ has_more: false,
+ }),
+ },
+ },
+ subscriptions: {
+ list: () =>
+ Effect.succeed({
+ object: "list" as const,
+ data: [
+ {
+ id: "sub_123",
+ customer: "cus_123",
+ current_period_start: 1000000,
+ current_period_end: 2000000,
+ items: {
+ data: [{ price: { id: "price_test_mock" } }],
+ },
+ },
+ ],
+ has_more: false,
+ }),
+ },
+ config: {
+ apiKey: "sk_test_mock",
+ routerPriceId: "price_test_mock",
+ routerMeterId: "meter_test_mock",
+ },
+ } as unknown as Context.Tag.Service),
+ ),
+ ),
+ );
+ });
+
+ describe("releaseFunds", () => {
+ it.effect("successfully releases funds", () =>
+ Effect.gen(function* () {
+ const payments = yield* Payments;
+
+ yield* payments.products.router.releaseFunds("reservation_123");
+
+ // Test passes if no errors thrown
+ }).pipe(
+ Effect.provide(Payments.Default),
+ Effect.provide(
+ Layer.succeed(DrizzleORM, {
+ update: () => ({
+ set: () => ({
+ where: () => ({
+ returning: () => Effect.succeed([{ id: "reservation_123" }]),
+ }),
+ }),
+ }),
+ } as unknown as Context.Tag.Service),
+ ),
+ Effect.provide(
+ Layer.succeed(Stripe, {
+ config: {
+ apiKey: "sk_test_mock",
+ routerPriceId: "price_test_mock",
+ routerMeterId: "meter_test_mock",
+ },
+ } as unknown as Context.Tag.Service),
+ ),
+ ),
+ );
+
+ it.effect(
+ "returns ReservationStateError when reservation not found (using mock DB)",
+ () =>
+ Effect.gen(function* () {
+ const payments = yield* Payments;
+
+ // Custom mock that returns empty array to simulate "not found"
+ const result = yield* payments.products.router
+ .releaseFunds("nonexistent_id")
+ .pipe(Effect.flip);
+
+ assert(result instanceof ReservationStateError);
+ expect(result.reservationId).toBe("nonexistent_id");
+ }).pipe(
+ Effect.provide(Payments.Default),
+ Effect.provide(
+ Layer.succeed(DrizzleORM, {
+ update: () => ({
+ set: () => ({
+ where: () => ({
+ returning: () => ({
+ pipe: () => Effect.succeed([]), // Empty array = not found
+ }),
+ }),
+ }),
+ }),
+ } as unknown as Context.Tag.Service),
+ ),
+ Effect.provide(
+ Layer.succeed(Stripe, {
+ config: {
+ apiKey: "sk_test_mock",
+ routerPriceId: "price_test_mock",
+ routerMeterId: "meter_test_mock",
+ },
+ } as unknown as Context.Tag.Service),
+ ),
+ ),
+ );
+
+ it.effect("returns DatabaseError when database operation fails", () =>
+ Effect.gen(function* () {
+ const payments = yield* Payments;
+
+ const result = yield* payments.products.router
+ .releaseFunds("some_id")
+ .pipe(Effect.flip);
+
+ assert(result instanceof DatabaseError);
+ expect(result.message).toBe("Failed to release credit reservation");
+ }).pipe(
+ Effect.provide(Payments.Default),
+ Effect.provide(
+ Layer.succeed(DrizzleORM, {
+ update: () => ({
+ set: () => ({
+ where: () => ({
+ returning: () =>
+ Effect.fail(new Error("Database connection lost")),
+ }),
+ }),
+ }),
+ } as unknown as Context.Tag.Service),
+ ),
+ Effect.provide(
+ Layer.succeed(Stripe, {
+ config: {
+ apiKey: "sk_test_mock",
+ routerPriceId: "price_test_mock",
+ routerMeterId: "meter_test_mock",
+ },
+ } as unknown as Context.Tag.Service),
+ ),
+ ),
+ );
+
+ it.effect(
+ "successfully releases funds (already linked during reserve)",
+ () =>
+ Effect.gen(function* () {
+ const payments = yield* Payments;
+
+ yield* payments.products.router.releaseFunds("reservation_123");
+
+ // Test passes if no errors thrown
+ }).pipe(
+ Effect.provide(Payments.Default),
+ Effect.provide(
+ Layer.succeed(DrizzleORM, {
+ update: () => ({
+ set: () => ({
+ where: () => ({
+ returning: () =>
+ Effect.succeed([{ id: "reservation_123" }]),
+ }),
+ }),
+ }),
+ } as unknown as Context.Tag.Service),
+ ),
+ Effect.provide(
+ Layer.succeed(Stripe, {
+ config: {
+ apiKey: "sk_test_mock",
+ routerPriceId: "price_test_mock",
+ routerMeterId: "meter_test_mock",
+ },
+ } as unknown as Context.Tag.Service),
+ ),
+ ),
+ );
+ });
+
+ describe("settleFunds", () => {
+ it.effect("successfully settles funds and charges meter", () => {
+ let chargedAmount: string | undefined;
+
+ return Effect.gen(function* () {
+ const payments = yield* Payments;
+
+ yield* payments.products.router.settleFunds(
+ "reservation_123",
+ 500n, // $0.05
+ );
+
+ // Should charge with gas fee: 500 centi-cents * 1.05 = 525 centi-cents
+ // Meter value = 525 (since 1 meter unit = 1 centi-cent)
+ expect(chargedAmount).toBe("525");
+ }).pipe(
+ Effect.provide(Payments.Default),
+ Effect.provide(
+ Layer.succeed(DrizzleORM, {
+ select: () => ({
+ from: () => ({
+ where: () => Effect.succeed([{ customerId: "cus_123" }]),
+ }),
+ }),
+ update: () => ({
+ set: () => ({
+ where: () => ({
+ returning: () => Effect.succeed([{ id: "reservation_123" }]),
+ }),
+ }),
+ }),
+ } as unknown as Context.Tag.Service),
+ ),
+ Effect.provide(
+ Layer.succeed(Stripe, {
+ billing: {
+ meterEvents: {
+ create: (params: {
+ event_name: string;
+ payload: { stripe_customer_id: string; value: string };
+ timestamp: number;
+ }) =>
+ Effect.sync(() => {
+ chargedAmount = params.payload.value;
+ }),
+ },
+ },
+ config: {
+ apiKey: "sk_test_mock",
+ routerPriceId: "price_test_mock",
+ routerMeterId: "meter_test_mock",
+ },
+ } as unknown as Context.Tag.Service),
+ ),
+ );
+ });
+
+ it.effect(
+ "returns ReservationStateError when reservation not found during fetch",
+ () =>
+ Effect.gen(function* () {
+ const payments = yield* Payments;
+
+ const result = yield* payments.products.router
+ .settleFunds("nonexistent_id", 450n)
+ .pipe(Effect.flip);
+
+ assert(result instanceof ReservationStateError);
+ expect(result.reservationId).toBe("nonexistent_id");
+ expect(result.message).toBe("Reservation not found");
+ }).pipe(
+ Effect.provide(Payments.Default),
+ Effect.provide(
+ Layer.succeed(DrizzleORM, {
+ select: () => ({
+ from: () => ({
+ where: () => Effect.succeed([]), // Empty array = not found
+ }),
+ }),
+ } as unknown as Context.Tag.Service),
+ ),
+ Effect.provide(
+ Layer.succeed(Stripe, {
+ config: {
+ apiKey: "sk_test_mock",
+ routerPriceId: "price_test_mock",
+ routerMeterId: "meter_test_mock",
+ },
+ } as unknown as Context.Tag.Service),
+ ),
+ ),
+ );
+
+ it.effect("returns DatabaseError when fetching reservation fails", () =>
+ Effect.gen(function* () {
+ const payments = yield* Payments;
+
+ const result = yield* payments.products.router
+ .settleFunds("some_id", 450n)
+ .pipe(Effect.flip);
+
+ assert(result instanceof DatabaseError);
+ expect(result.message).toBe("Failed to fetch reservation");
+ }).pipe(
+ Effect.provide(Payments.Default),
+ Effect.provide(
+ Layer.succeed(DrizzleORM, {
+ select: () => ({
+ from: () => ({
+ where: () => Effect.fail(new Error("Database connection lost")),
+ }),
+ }),
+ } as unknown as Context.Tag.Service),
+ ),
+ Effect.provide(
+ Layer.succeed(Stripe, {
+ config: {
+ apiKey: "sk_test_mock",
+ routerPriceId: "price_test_mock",
+ routerMeterId: "meter_test_mock",
+ },
+ } as unknown as Context.Tag.Service),
+ ),
+ ),
+ );
+
+ it.effect(
+ "returns ReservationStateError when release fails (reservation not found)",
+ () =>
+ Effect.gen(function* () {
+ const payments = yield* Payments;
+
+ const result = yield* payments.products.router
+ .settleFunds("reservation_123", 450n)
+ .pipe(Effect.flip);
+
+ assert(result instanceof ReservationStateError);
+ }).pipe(
+ Effect.provide(Payments.Default),
+ Effect.provide(
+ Layer.succeed(DrizzleORM, {
+ select: () => ({
+ from: () => ({
+ where: () => Effect.succeed([{ customerId: "cus_123" }]),
+ }),
+ }),
+ update: () => ({
+ set: () => ({
+ where: () => ({
+ returning: () => ({
+ pipe: () => Effect.succeed([]), // Empty array = not found
+ }),
+ }),
+ }),
+ }),
+ } as unknown as Context.Tag.Service),
+ ),
+ Effect.provide(
+ Layer.succeed(Stripe, {
+ config: {
+ apiKey: "sk_test_mock",
+ routerPriceId: "price_test_mock",
+ routerMeterId: "meter_test_mock",
+ },
+ } as unknown as Context.Tag.Service),
+ ),
+ ),
+ );
+
+ it.effect("returns StripeError when meter charging fails", () =>
+ Effect.gen(function* () {
+ const payments = yield* Payments;
+
+ const result = yield* payments.products.router
+ .settleFunds("reservation_123", 450n)
+ .pipe(Effect.flip);
+
+ assert(result instanceof StripeError);
+ }).pipe(
+ Effect.provide(Payments.Default),
+ Effect.provide(
+ Layer.succeed(DrizzleORM, {
+ select: () => ({
+ from: () => ({
+ where: () => Effect.succeed([{ customerId: "cus_123" }]),
+ }),
+ }),
+ update: () => ({
+ set: () => ({
+ where: () => ({
+ returning: () => Effect.succeed([{ id: "reservation_123" }]),
+ }),
+ }),
+ }),
+ } as unknown as Context.Tag.Service),
+ ),
+ Effect.provide(
+ Layer.succeed(Stripe, {
+ billing: {
+ meterEvents: {
+ create: () =>
+ Effect.fail(
+ new StripeError({
+ message: "Stripe API error",
+ }),
+ ),
+ },
+ },
+ config: {
+ apiKey: "sk_test_mock",
+ routerPriceId: "price_test_mock",
+ routerMeterId: "meter_test_mock",
+ },
+ } as unknown as Context.Tag.Service),
+ ),
+ ),
+ );
+ });
+});
diff --git a/cloud/payments/products/router.ts b/cloud/payments/products/router.ts
new file mode 100644
index 0000000000..fb99f155c6
--- /dev/null
+++ b/cloud/payments/products/router.ts
@@ -0,0 +1,500 @@
+/**
+ * @fileoverview Router product billing service.
+ *
+ * Provides Router-specific billing operations including usage metering,
+ * fund reservations, and credit management. This service implements the
+ * two-phase reservation pattern to prevent overdraft in concurrent scenarios.
+ */
+
+import { Effect } from "effect";
+import { Stripe } from "@/payments/client";
+import { DrizzleORM } from "@/db/client";
+import {
+ StripeError,
+ DatabaseError,
+ InsufficientFundsError,
+ ReservationStateError,
+} from "@/errors";
+import Decimal from "decimal.js";
+import { eq, and, sql } from "drizzle-orm";
+import { creditReservations } from "@/db/schema";
+import {
+ type CostInCenticents,
+ centicentsToDollars,
+} from "@/api/router/cost-utils";
+
+/**
+ * Gas fee percentage applied to router usage charges.
+ * A value of 0.05 represents a 5% fee.
+ */
+const GAS_FEE_PERCENTAGE = 0.05;
+
+/**
+ * Comprehensive balance information for router billing.
+ *
+ * Contains all balance components needed for fund reservation checks:
+ * - creditBalance: Total credit grants from Stripe
+ * - meterUsage: Accumulated usage that will be invoiced
+ * - activeReservations: Sum of pending reservations
+ * - availableBalance: creditBalance - meterUsage (what can be reserved)
+ */
+export interface RouterBalanceInfo {
+ /** Total credit grants in centi-cents */
+ creditBalance: CostInCenticents;
+ /** Accumulated meter usage in centi-cents */
+ meterUsage: CostInCenticents;
+ /** Sum of active reservations in centi-cents */
+ activeReservations: CostInCenticents;
+ /** Available balance (creditBalance - meterUsage) in centi-cents */
+ availableBalance: CostInCenticents;
+}
+
+/**
+ * Router product billing service.
+ *
+ * Handles all Router-specific billing operations including:
+ * - Usage metering and balance tracking
+ * - Fund reservations (two-phase pattern for concurrency safety)
+ * - Credit charging with gas fees
+ *
+ * @example
+ * ```ts
+ * const program = Effect.gen(function* () {
+ * const payments = yield* Payments;
+ * const db = yield* DrizzleORM;
+ *
+ * // 1. Create router request in "pending" state (MUST be done first)
+ * const [routerRequest] = yield* db.insert(routerRequests).values({
+ * provider: "openai",
+ * model: "gpt-4",
+ * status: "pending",
+ * organizationId: "...",
+ * projectId: "...",
+ * environmentId: "...",
+ * }).returning({ id: routerRequests.id });
+ *
+ * // 2. Reserve funds (links to router request)
+ * const reservationId = yield* payments.products.router.reserveFunds({
+ * stripeCustomerId: "cus_123",
+ * estimatedCostCenticents: 500n, // $0.05
+ * routerRequestId: routerRequest.id,
+ * });
+ *
+ * // 3. Make the actual provider request...
+ *
+ * // 4. After successful request: settle funds (releases, charges meter)
+ * yield* payments.products.router.settleFunds(reservationId, 450n);
+ * });
+ * ```
+ */
+export class Router {
+ /**
+ * Gets the accumulated meter usage balance for a Stripe customer.
+ *
+ * Fetches meter event summaries for the current billing period and returns
+ * the total usage in centi-cents. This represents how much the customer has used
+ * in the current billing cycle but not yet been invoiced for.
+ *
+ * Since 1 meter unit = 1 centi-cent, we directly sum the aggregated values.
+ *
+ * @param stripeCustomerId - The Stripe customer ID
+ * @returns The accumulated meter usage in centi-cents (e.g., 50000n for $5 usage)
+ * @throws StripeError - If API call fails
+ */
+ getUsageMeterBalance(
+ stripeCustomerId: string,
+ ): Effect.Effect {
+ return Effect.gen(this, function* () {
+ const stripe = yield* Stripe;
+
+ // Get active subscription with router price to find current billing period
+ const subscriptions = yield* stripe.subscriptions.list({
+ customer: stripeCustomerId,
+ status: "active",
+ });
+
+ // Find subscription that includes the router price
+ const routerSubscription = subscriptions.data.find((sub) =>
+ sub.items.data.some(
+ (item) => item.price.id === stripe.config.routerPriceId,
+ ),
+ );
+
+ // If no router subscription, no meter usage
+ if (!routerSubscription) {
+ return 0n;
+ }
+
+ const currentPeriodStart = routerSubscription.current_period_start;
+ const currentPeriodEnd = routerSubscription.current_period_end;
+
+ // Fetch meter event summaries for current billing period
+ const summaries = yield* stripe.billing.meters.listEventSummaries(
+ stripe.config.routerMeterId,
+ {
+ customer: stripeCustomerId,
+ start_time: currentPeriodStart,
+ end_time: currentPeriodEnd,
+ },
+ );
+
+ // Sum up aggregated values (meter units = centi-cents)
+ let totalCenticents = 0;
+ for (const summary of summaries.data) {
+ totalCenticents += summary.aggregated_value;
+ }
+
+ return BigInt(totalCenticents);
+ });
+ }
+
+ /**
+ * Charges the usage meter for a Stripe customer for Router.
+ *
+ * Records a meter event for the customer's usage, applying a gas fee.
+ * The actual amount charged to the meter is `centicents * (1 + GAS_FEE_PERCENTAGE)`.
+ *
+ * Since 1 meter unit = 1 centi-cent, we directly use the centi-cent value as the meter value.
+ *
+ * @param stripeCustomerId - The Stripe customer ID
+ * @param centicents - The base usage amount in centi-cents (e.g., 10000n for $1)
+ * @returns Effect that succeeds when meter is charged
+ * @throws StripeError - If meter event creation fails
+ */
+ chargeUsageMeter(
+ stripeCustomerId: string,
+ centicents: CostInCenticents,
+ ): Effect.Effect {
+ return Effect.gen(this, function* () {
+ const stripe = yield* Stripe;
+
+ // Apply gas fee using Decimal for precision
+ const chargedCenticents = new Decimal(centicents.toString()).mul(
+ new Decimal(1).plus(GAS_FEE_PERCENTAGE),
+ );
+
+ // Meter value = charged centi-cents (since 1 meter unit = 1 centi-cent)
+ const meterValue = chargedCenticents.round().toNumber();
+
+ // Create meter event
+ yield* stripe.billing.meterEvents.create({
+ event_name: "use_credits",
+ payload: {
+ stripe_customer_id: stripeCustomerId,
+ value: Math.max(meterValue, 1).toString(),
+ },
+ timestamp: Math.floor(Date.now() / 1000),
+ });
+ });
+ }
+
+ /**
+ * Gets the router credit balance from Stripe credit grants.
+ *
+ * Fetches all credit grants for the customer and filters for those that are
+ * applicable to the router price (metered usage-based billing).
+ *
+ * @param stripeCustomerId - The Stripe customer ID
+ * @returns The total credit grants in centi-cents (e.g., 100000n for $10)
+ * @throws StripeError - If API call fails
+ */
+ getCreditBalance(
+ stripeCustomerId: string,
+ ): Effect.Effect {
+ return Effect.gen(function* () {
+ const stripe = yield* Stripe;
+
+ const credit_grants = yield* stripe.billing.creditGrants.list({
+ customer: stripeCustomerId,
+ });
+
+ // Filter grants applicable to router price and sum their values
+ const totalCenticents = credit_grants.data
+ .filter((grant) => {
+ // Must have monetary amount
+ if (!grant.amount.monetary) return false;
+
+ // Must be applicable to router price
+ const config = grant.applicability_config;
+ return (
+ config.scope &&
+ config.scope.prices?.some(
+ (price) => price.id === stripe.config.routerPriceId,
+ )
+ );
+ })
+ .reduce((sum, grant) => {
+ const { value, currency } = grant.amount.monetary!;
+ // Convert cents to centi-cents: 1 cent = 100 centi-cents
+ return currency === "usd" ? sum + BigInt(value) * 100n : sum;
+ }, 0n);
+
+ return totalCenticents;
+ });
+ }
+
+ /**
+ * Gets comprehensive balance information for a Stripe customer.
+ *
+ * Returns all balance components:
+ * - creditBalance: Total credit grants
+ * - meterUsage: Accumulated usage
+ * - activeReservations: Sum of pending reservations
+ * - availableBalance: creditBalance - meterUsage (what can be reserved)
+ *
+ * This is the single source of truth for fund reservation checks.
+ *
+ * @param stripeCustomerId - The Stripe customer ID
+ * @returns Complete balance information in centi-cents
+ * @throws StripeError - If Stripe API calls fail
+ * @throws DatabaseError - If database query fails
+ */
+ getBalanceInfo(
+ stripeCustomerId: string,
+ ): Effect.Effect<
+ RouterBalanceInfo,
+ StripeError | DatabaseError,
+ Stripe | DrizzleORM
+ > {
+ return Effect.gen(this, function* () {
+ const db = yield* DrizzleORM;
+
+ // Fetch credit balance and meter usage from Stripe
+ const creditBalance = yield* this.getCreditBalance(stripeCustomerId);
+ const meterUsage = yield* this.getUsageMeterBalance(stripeCustomerId);
+
+ // Calculate active reservations from database
+ const activeReservationsResult = yield* db
+ .select({
+ sum: sql`COALESCE(SUM(${creditReservations.estimatedCostCenticents}), 0)`,
+ })
+ .from(creditReservations)
+ .where(
+ and(
+ eq(creditReservations.stripeCustomerId, stripeCustomerId),
+ eq(creditReservations.status, "active"),
+ ),
+ )
+ .pipe(
+ Effect.mapError(
+ (error) =>
+ new DatabaseError({
+ message: "Failed to calculate active reservations",
+ cause: error,
+ }),
+ ),
+ );
+
+ const activeReservations = BigInt(
+ activeReservationsResult[0]?.sum ?? "0",
+ );
+
+ // Available balance = credit - meter usage (NOT subtracting reservations here)
+ const availableBalance = creditBalance - meterUsage;
+
+ return {
+ creditBalance,
+ meterUsage,
+ activeReservations,
+ availableBalance,
+ };
+ });
+ }
+
+ /**
+ * Reserves funds for a router request to prevent overdraft in concurrent scenarios.
+ *
+ * This method implements the reservation phase of the two-phase pattern:
+ * 1. Check if customer has sufficient available balance (total balance - active reservations)
+ * 2. If sufficient, create a reservation record atomically linked to the router request
+ * 3. Return reservation ID for later release
+ *
+ * The reservation "locks" the estimated cost so other concurrent requests can't use it.
+ * This prevents race conditions where multiple requests could overdraft the account.
+ *
+ * **Important**: The router request must be created FIRST in "pending" state, then passed
+ * to this method. This ensures every reservation is always linked to a request.
+ *
+ * After the request completes, caller MUST call `releaseFunds()` or `settleFunds()` to
+ * release the reservation.
+ *
+ * @param stripeCustomerId - Stripe customer ID
+ * @param estimatedCostCenticents - Estimated cost in centi-cents (e.g., 500n for $0.05)
+ * @param routerRequestId - ID of the router request (must be created in "pending" state first)
+ * @returns Reservation ID to use for release
+ * @throws DatabaseError - If reservation creation fails
+ * @throws InsufficientFundsError - If customer has insufficient available funds
+ */
+ reserveFunds({
+ stripeCustomerId,
+ estimatedCostCenticents,
+ routerRequestId,
+ }: {
+ stripeCustomerId: string;
+ estimatedCostCenticents: CostInCenticents;
+ routerRequestId: string;
+ }): Effect.Effect<
+ string,
+ DatabaseError | InsufficientFundsError | StripeError,
+ DrizzleORM | Stripe
+ > {
+ return Effect.gen(this, function* () {
+ const db = yield* DrizzleORM;
+
+ // Get comprehensive balance information
+ const balanceInfo = yield* this.getBalanceInfo(stripeCustomerId);
+
+ // Check if sufficient funds available (available balance - active reservations)
+ const netAvailableCenticents =
+ balanceInfo.availableBalance - balanceInfo.activeReservations;
+ if (netAvailableCenticents < estimatedCostCenticents) {
+ return yield* new InsufficientFundsError({
+ message: `Insufficient available funds. Required: ${estimatedCostCenticents} centi-cents ($${centicentsToDollars(estimatedCostCenticents).toFixed(4)}), Net Available: ${netAvailableCenticents} centi-cents ($${centicentsToDollars(netAvailableCenticents).toFixed(4)}) (Credit: ${balanceInfo.creditBalance}, Meter: ${balanceInfo.meterUsage}, Reserved: ${balanceInfo.activeReservations})`,
+ required: centicentsToDollars(estimatedCostCenticents),
+ available: centicentsToDollars(netAvailableCenticents),
+ });
+ }
+
+ const [reservation] = yield* db
+ .insert(creditReservations)
+ .values({
+ stripeCustomerId,
+ estimatedCostCenticents,
+ routerRequestId,
+ status: "active",
+ })
+ .returning({ id: creditReservations.id })
+ .pipe(
+ Effect.mapError(
+ (error) =>
+ new DatabaseError({
+ message: "Failed to create credit reservation",
+ cause: error,
+ }),
+ ),
+ );
+
+ return reservation.id;
+ });
+ }
+
+ /**
+ * Releases a reservation after request completion.
+ *
+ * This is a lower-level method for releasing reservations.
+ * Marks the reservation as released, freeing up the reserved funds for other requests.
+ *
+ * The reservation is already linked to a router request (via routerRequestId set during
+ * reserveFunds), so you don't need to pass it again.
+ *
+ * Use this directly when you want to release without charging the meter.
+ * For the common success case (release + charge), use `settleFunds` instead.
+ *
+ * @param reservationId - Reservation ID from reserveFunds()
+ * @returns Effect that succeeds when release is complete
+ * @throws DatabaseError - If database operation fails
+ * @throws ReservationStateError - If reservation not found or already released
+ */
+ releaseFunds(
+ reservationId: string,
+ ): Effect.Effect {
+ return Effect.gen(function* () {
+ const db = yield* DrizzleORM;
+
+ // Update reservation to released status
+ const result = yield* db
+ .update(creditReservations)
+ .set({
+ status: "released",
+ releasedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(creditReservations.id, reservationId),
+ eq(creditReservations.status, "active"),
+ ),
+ )
+ .returning({ id: creditReservations.id })
+ .pipe(
+ Effect.mapError(
+ (error) =>
+ new DatabaseError({
+ message: "Failed to release credit reservation",
+ cause: error,
+ }),
+ ),
+ );
+
+ if (result.length === 0) {
+ return yield* new ReservationStateError({
+ message: `Reservation not found or already released`,
+ reservationId,
+ });
+ }
+ });
+ }
+
+ /**
+ * Settles a reservation after successful request completion.
+ *
+ * This is a high-level convenience method that:
+ * 1. Releases the reservation
+ * 2. Charges the meter with actual cost
+ *
+ * Use this for the common case when a router request succeeds and you have actual cost.
+ * For failed requests where you don't want to charge, use `releaseFunds` directly.
+ *
+ * The reservation is already linked to the router request (via routerRequestId set during
+ * reserveFunds), so you don't need to pass it again.
+ *
+ * @param reservationId - Reservation ID from reserveFunds()
+ * @param actualCostCenticents - Actual cost in centi-cents to charge
+ * @returns Effect that succeeds when settlement is complete
+ * @throws DatabaseError - If database operation fails
+ * @throws ReservationStateError - If reservation not found or already released
+ * @throws StripeError - If meter charging fails
+ */
+ settleFunds(
+ reservationId: string,
+ actualCostCenticents: CostInCenticents,
+ ): Effect.Effect<
+ void,
+ DatabaseError | StripeError | ReservationStateError,
+ Stripe | DrizzleORM
+ > {
+ return Effect.gen(this, function* () {
+ // Get customer ID from reservation before releasing
+ const db = yield* DrizzleORM;
+
+ const [reservation] = yield* db
+ .select({ stripeCustomerId: creditReservations.stripeCustomerId })
+ .from(creditReservations)
+ .where(eq(creditReservations.id, reservationId))
+ .pipe(
+ Effect.mapError(
+ (error) =>
+ new DatabaseError({
+ message: "Failed to fetch reservation",
+ cause: error,
+ }),
+ ),
+ );
+
+ if (!reservation) {
+ return yield* new ReservationStateError({
+ message: `Reservation not found`,
+ reservationId,
+ });
+ }
+
+ // Release funds
+ yield* this.releaseFunds(reservationId);
+
+ // Charge the meter with actual cost
+ yield* this.chargeUsageMeter(
+ reservation.stripeCustomerId,
+ actualCostCenticents,
+ );
+ });
+ }
+}
diff --git a/cloud/payments/service.test.ts b/cloud/payments/service.test.ts
index bc62e97688..cdc9a822f9 100644
--- a/cloud/payments/service.test.ts
+++ b/cloud/payments/service.test.ts
@@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
import { Effect, Layer } from "effect";
import { Payments } from "@/payments/service";
import { MockStripe } from "@/tests/payments";
+import { MockDrizzleORMLayer } from "@/tests/mock-drizzle";
describe("Payments", () => {
describe("Default layer", () => {
@@ -18,11 +19,51 @@ describe("Payments", () => {
expect(typeof payments.customers.cancelSubscriptions).toBe(
"function",
);
- expect(typeof payments.customers.getBalance).toBe("function");
return true;
}).pipe(
- Effect.provide(Payments.Default.pipe(Layer.provide(MockStripe))),
+ Effect.provide(
+ Payments.Default.pipe(
+ Layer.provide(Layer.merge(MockStripe, MockDrizzleORMLayer)),
+ ),
+ ),
+ ),
+ );
+
+ expect(result).toBe(true);
+ });
+
+ it("creates a Payments service with products.router", async () => {
+ const result = await Effect.runPromise(
+ Effect.gen(function* () {
+ const payments = yield* Payments;
+
+ // Verify products.router service exists
+ expect(payments.products).toBeDefined();
+ expect(payments.products.router).toBeDefined();
+ expect(typeof payments.products.router.getUsageMeterBalance).toBe(
+ "function",
+ );
+ expect(typeof payments.products.router.getBalanceInfo).toBe(
+ "function",
+ );
+ expect(typeof payments.products.router.getCreditBalance).toBe(
+ "function",
+ );
+ expect(typeof payments.products.router.chargeUsageMeter).toBe(
+ "function",
+ );
+ expect(typeof payments.products.router.reserveFunds).toBe("function");
+ expect(typeof payments.products.router.settleFunds).toBe("function");
+ expect(typeof payments.products.router.releaseFunds).toBe("function");
+
+ return true;
+ }).pipe(
+ Effect.provide(
+ Payments.Default.pipe(
+ Layer.provide(Layer.merge(MockStripe, MockDrizzleORMLayer)),
+ ),
+ ),
),
);
@@ -40,18 +81,20 @@ describe("Payments", () => {
update,
delete: del,
cancelSubscriptions,
- getBalance,
} = payments.customers;
expect(typeof create).toBe("function");
expect(typeof update).toBe("function");
expect(typeof del).toBe("function");
expect(typeof cancelSubscriptions).toBe("function");
- expect(typeof getBalance).toBe("function");
return true;
}).pipe(
- Effect.provide(Payments.Default.pipe(Layer.provide(MockStripe))),
+ Effect.provide(
+ Payments.Default.pipe(
+ Layer.provide(Layer.merge(MockStripe, MockDrizzleORMLayer)),
+ ),
+ ),
),
);
@@ -71,11 +114,14 @@ describe("Payments", () => {
expect(keys).toContain("update");
expect(keys).toContain("delete");
expect(keys).toContain("cancelSubscriptions");
- expect(keys).toContain("getBalance");
return true;
}).pipe(
- Effect.provide(Payments.Default.pipe(Layer.provide(MockStripe))),
+ Effect.provide(
+ Payments.Default.pipe(
+ Layer.provide(Layer.merge(MockStripe, MockDrizzleORMLayer)),
+ ),
+ ),
),
);
@@ -101,7 +147,11 @@ describe("Payments", () => {
return true;
}).pipe(
- Effect.provide(Payments.Default.pipe(Layer.provide(MockStripe))),
+ Effect.provide(
+ Payments.Default.pipe(
+ Layer.provide(Layer.merge(MockStripe, MockDrizzleORMLayer)),
+ ),
+ ),
),
);
@@ -114,6 +164,7 @@ describe("Payments", () => {
const layer = Payments.Live({
apiKey: "sk_test_key",
routerPriceId: "price_test",
+ routerMeterId: "meter_test",
});
// Verify it returns a Layer
@@ -137,7 +188,11 @@ describe("Payments", () => {
return true;
}).pipe(
- Effect.provide(Payments.Default.pipe(Layer.provide(MockStripe))),
+ Effect.provide(
+ Payments.Default.pipe(
+ Layer.provide(Layer.merge(MockStripe, MockDrizzleORMLayer)),
+ ),
+ ),
),
);
@@ -159,7 +214,11 @@ describe("Payments", () => {
return true;
}).pipe(
- Effect.provide(Payments.Default.pipe(Layer.provide(MockStripe))),
+ Effect.provide(
+ Payments.Default.pipe(
+ Layer.provide(Layer.merge(MockStripe, MockDrizzleORMLayer)),
+ ),
+ ),
),
);
@@ -182,7 +241,11 @@ describe("Payments", () => {
return true;
}).pipe(
- Effect.provide(Payments.Default.pipe(Layer.provide(MockStripe))),
+ Effect.provide(
+ Payments.Default.pipe(
+ Layer.provide(Layer.merge(MockStripe, MockDrizzleORMLayer)),
+ ),
+ ),
),
);
diff --git a/cloud/payments/service.ts b/cloud/payments/service.ts
index ff5601dc71..fb85c40e93 100644
--- a/cloud/payments/service.ts
+++ b/cloud/payments/service.ts
@@ -33,7 +33,9 @@
*
* ```
* Payments (service layer)
- * └── customers: Ready
+ * ├── customers: Ready
+ * └── products
+ * └── router: Ready
*
* Each service uses `yield* Stripe` internally. The `makeReady` wrapper
* provides the Stripe client, so consumers see methods returning
@@ -44,6 +46,8 @@
import { Context, Layer, Effect } from "effect";
import { Stripe, type StripeConfig } from "@/payments/client";
import { Customers } from "@/payments/customers";
+import { Router } from "@/payments/products/router";
+import { DrizzleORM } from "@/db/client";
import { dependencyProvider, type Ready } from "@/utils";
/**
@@ -77,6 +81,9 @@ export class Payments extends Context.Tag("Payments")<
Payments,
{
readonly customers: Ready;
+ readonly products: {
+ readonly router: Ready;
+ };
}
>() {
/**
@@ -89,12 +96,17 @@ export class Payments extends Context.Tag("Payments")<
Payments,
Effect.gen(function* () {
const stripe = yield* Stripe;
+ const db = yield* DrizzleORM;
const provideDependencies = dependencyProvider([
{ tag: Stripe, instance: stripe },
+ { tag: DrizzleORM, instance: db },
]);
return {
customers: provideDependencies(new Customers()),
+ products: {
+ router: provideDependencies(new Router()),
+ },
};
}),
);
diff --git a/cloud/tests/api.ts b/cloud/tests/api.ts
index 5fe2904ba9..065f753795 100644
--- a/cloud/tests/api.ts
+++ b/cloud/tests/api.ts
@@ -23,7 +23,8 @@ import { Payments } from "@/payments";
import { AuthenticatedUser, Authentication } from "@/auth";
import type { AuthResult } from "@/auth/context";
import type { PublicUser, PublicOrganization, ApiKeyInfo } from "@/db/schema";
-import { TEST_DATABASE_URL, DefaultMockPayments } from "@/tests/db";
+import { TEST_DATABASE_URL } from "@/tests/db";
+import { DefaultMockPayments } from "@/tests/payments";
// Re-export expect from vitest
export { expect };
diff --git a/cloud/tests/db.ts b/cloud/tests/db.ts
index 1dfe44f8e1..6395dba790 100644
--- a/cloud/tests/db.ts
+++ b/cloud/tests/db.ts
@@ -6,8 +6,8 @@ import { Database } from "@/db";
import { PgClient } from "@effect/sql-pg";
import { SqlClient } from "@effect/sql";
import { CONNECTION_FILE } from "@/tests/global-setup";
-import { DefaultMockPayments } from "@/tests/payments";
import { Payments } from "@/payments";
+import { DefaultMockPayments } from "@/tests/payments";
import fs from "fs";
import assert from "node:assert";
@@ -44,9 +44,6 @@ const TestPgClient = PgClient.layerConfig({
export const TestDrizzleORM: Layer.Layer =
DrizzleORM.Default.pipe(Layer.provideMerge(TestPgClient), Layer.orDie);
-// Re-export MockPayments and DefaultMockPayments for convenience
-export { MockPayments, DefaultMockPayments } from "@/tests/payments";
-
/**
* A Layer that provides the Effect-native `Database`, `DrizzleORM`, and
* `SqlClient` services for tests.
@@ -58,10 +55,16 @@ export { MockPayments, DefaultMockPayments } from "@/tests/payments";
*/
export const TestDatabase: Layer.Layer<
Database | DrizzleORM | SqlClient.SqlClient
-> = Database.Default.pipe(
- Layer.provideMerge(TestDrizzleORM),
- Layer.provide(DefaultMockPayments),
-);
+> = Effect.gen(function* () {
+ // Lazy import to avoid circular dependency
+ const { DefaultMockPayments } = yield* Effect.promise(
+ () => import("@/tests/payments"),
+ );
+ return Database.Default.pipe(
+ Layer.provideMerge(TestDrizzleORM),
+ Layer.provide(DefaultMockPayments),
+ );
+}).pipe(Layer.unwrapEffect);
// =============================================================================
// Rollback transaction wrapper
diff --git a/cloud/tests/mock-drizzle.ts b/cloud/tests/mock-drizzle.ts
new file mode 100644
index 0000000000..c3cbb1da0a
--- /dev/null
+++ b/cloud/tests/mock-drizzle.ts
@@ -0,0 +1,59 @@
+/**
+ * Mock DrizzleORM layer for tests that don't need real database operations.
+ *
+ * This file is separate from tests/db.ts to avoid circular dependencies with tests/payments.ts
+ */
+
+import { Layer, Effect } from "effect";
+import { DrizzleORM, type DrizzleORMClient } from "@/db/client";
+
+/**
+ * Simple mock DrizzleORM layer for tests that don't need real database operations.
+ *
+ * This provides a minimal DrizzleORM implementation that:
+ * - Returns empty arrays for select queries (with sum: "0" for aggregations)
+ * - Returns mock IDs for insert operations
+ * - Returns mock IDs for update operations
+ * - Returns empty arrays for delete operations
+ * - No-ops transactions (just runs the effect directly)
+ *
+ * Use this when testing code that requires DrizzleORM but doesn't need actual
+ * database state (e.g., payment operations where you want to test business logic
+ * without hitting a real database).
+ *
+ * For tests that need actual database state and transactions, use TestDrizzleORM
+ * or TestDatabase instead.
+ */
+export const MockDrizzleORMLayer = Layer.succeed(DrizzleORM, {
+ select: () => ({
+ from: () => ({
+ where: () => ({
+ pipe: () => Effect.succeed([{ sum: "0" }]),
+ }),
+ }),
+ }),
+ insert: () => ({
+ values: () => ({
+ returning: () => ({
+ pipe: () => Effect.succeed([{ id: `mock_${crypto.randomUUID()}` }]),
+ }),
+ }),
+ }),
+ update: () => ({
+ set: () => ({
+ where: () => ({
+ returning: () => ({
+ pipe: () => Effect.succeed([{ id: `mock_${crypto.randomUUID()}` }]),
+ }),
+ }),
+ }),
+ }),
+ delete: () => ({
+ where: () => ({
+ returning: () => ({
+ pipe: () => Effect.succeed([]),
+ }),
+ }),
+ }),
+ withTransaction: (effect: Effect.Effect) => effect,
+} as unknown as DrizzleORMClient);
diff --git a/cloud/tests/payments.ts b/cloud/tests/payments.ts
index 23c6b87959..e4e94475d1 100644
--- a/cloud/tests/payments.ts
+++ b/cloud/tests/payments.ts
@@ -3,6 +3,7 @@ import { describe, expect } from "@effect/vitest";
import { createCustomIt } from "@/tests/shared";
import { Stripe } from "@/payments/client";
import { Payments } from "@/payments/service";
+import { MockDrizzleORMLayer } from "@/tests/mock-drizzle";
// Re-export describe and expect for convenience
export { describe, expect };
@@ -15,6 +16,7 @@ const getTestStripe = () =>
Stripe.layer({
apiKey: "sk_test_123",
routerPriceId: "price_test_mock",
+ routerMeterId: "meter_test_mock",
});
/**
@@ -486,10 +488,14 @@ export class MockPayments {
config: {
apiKey: "sk_test_mock",
routerPriceId: "price_test_mock_for_testing",
+ routerMeterId: "meter_test_mock",
},
} as unknown as Context.Tag.Service);
- return Payments.Default.pipe(Layer.provide(customStripe));
+ return Payments.Default.pipe(
+ Layer.provide(customStripe),
+ Layer.provide(MockDrizzleORMLayer),
+ );
}
}
@@ -593,6 +599,7 @@ export const MockStripe = Layer.succeed(Stripe, {
config: {
apiKey: "sk_test_mock",
routerPriceId: "price_test_mock_for_testing",
+ routerMeterId: "meter_test_mock",
},
} as unknown as Context.Tag.Service);
diff --git a/fern/openapi.json b/fern/openapi.json
index fd500c24f8..8caeb90dc4 100644
--- a/fern/openapi.json
+++ b/fern/openapi.json
@@ -1093,12 +1093,12 @@
}
}
},
- "/organizations/{id}/credits": {
+ "/organizations/{id}/router-balance": {
"get": {
"tags": [
"organizations"
],
- "operationId": "organizations.credits",
+ "operationId": "organizations.routerBalance",
"parameters": [
{
"name": "id",
@@ -1122,7 +1122,8 @@
],
"properties": {
"balance": {
- "type": "number"
+ "type": "string",
+ "description": "Balance in centi-cents (1/10000 of a dollar)"
}
},
"additionalProperties": false