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