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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions cloud/api/organizations.handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Payments } from "@/payments";
import type {
CreateOrganizationRequest,
UpdateOrganizationRequest,
CreatePaymentIntentRequest,
} from "@/api/organizations.schemas";

export * from "@/api/organizations.schemas";
Expand Down Expand Up @@ -76,3 +77,34 @@ export const getOrganizationRouterBalanceHandler = (organizationId: string) =>
// Return available balance in centi-cents (client will convert to dollars for display)
return { balance: balanceInfo.availableBalance };
});

export const createPaymentIntentHandler = (
organizationId: string,
payload: CreatePaymentIntentRequest,
) =>
Effect.gen(function* () {
const db = yield* Database;
const user = yield* AuthenticatedUser;
const payments = yield* Payments;

// First verify user has access to this organization
const organization = yield* db.organizations.findById({
organizationId,
userId: user.id,
});

// Create PaymentIntent for credit purchase
const result =
yield* payments.paymentIntents.createRouterCreditsPurchaseIntent({
customerId: organization.stripeCustomerId,
amountInDollars: payload.amount,
metadata: {
organizationId: organization.id,
},
});

return {
clientSecret: result.clientSecret,
amount: result.amountInDollars,
};
});
28 changes: 28 additions & 0 deletions cloud/api/organizations.schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,17 @@ export const OrganizationRouterBalanceSchema = Schema.Struct({
}),
});

export const CreatePaymentIntentRequestSchema = Schema.Struct({
amount: Schema.Number.pipe(
Schema.positive({ message: () => "Amount must be positive" }),
),
});

export const CreatePaymentIntentResponseSchema = Schema.Struct({
clientSecret: Schema.String,
amount: Schema.Number,
});

export type Organization = typeof OrganizationSchema.Type;
export type OrganizationWithMembership =
typeof OrganizationWithMembershipSchema.Type;
Expand All @@ -66,6 +77,10 @@ export type UpdateOrganizationRequest =
typeof UpdateOrganizationRequestSchema.Type;
export type OrganizationRouterBalance =
typeof OrganizationRouterBalanceSchema.Type;
export type CreatePaymentIntentRequest =
typeof CreatePaymentIntentRequestSchema.Type;
export type CreatePaymentIntentResponse =
typeof CreatePaymentIntentResponseSchema.Type;

export class OrganizationsApi extends HttpApiGroup.make("organizations")
.add(
Expand Down Expand Up @@ -117,4 +132,17 @@ export class OrganizationsApi extends HttpApiGroup.make("organizations")
.addError(PermissionDeniedError, { status: PermissionDeniedError.status })
.addError(StripeError, { status: StripeError.status })
.addError(DatabaseError, { status: DatabaseError.status }),
)
.add(
HttpApiEndpoint.post(
"createPaymentIntent",
"/organizations/:id/credits/payment-intent",
)
.setPath(Schema.Struct({ id: Schema.String }))
.setPayload(CreatePaymentIntentRequestSchema)
.addSuccess(CreatePaymentIntentResponseSchema)
.addError(NotFoundError, { status: NotFoundError.status })
.addError(PermissionDeniedError, { status: PermissionDeniedError.status })
.addError(StripeError, { status: StripeError.status })
.addError(DatabaseError, { status: DatabaseError.status }),
) {}
48 changes: 47 additions & 1 deletion cloud/api/organizations.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { Effect, Schema } from "effect";
import { ParseError } from "effect/ParseResult";
import { describe, it, expect, TestApiContext } from "@/tests/api";
import { CreateOrganizationRequestSchema } from "@/api/organizations.schemas";
import {
CreateOrganizationRequestSchema,
CreatePaymentIntentRequestSchema,
} from "@/api/organizations.schemas";
import type { PublicOrganizationWithMembership } from "@/db/schema";

describe("CreateOrganizationRequestSchema validation", () => {
Expand Down Expand Up @@ -33,6 +36,31 @@ describe("CreateOrganizationRequestSchema validation", () => {
});
});

describe("CreatePaymentIntentRequestSchema validation", () => {
it("rejects negative amount", () => {
expect(() =>
Schema.decodeUnknownSync(CreatePaymentIntentRequestSchema)({
amount: -10,
}),
).toThrow("Amount must be positive");
});

it("rejects zero amount", () => {
expect(() =>
Schema.decodeUnknownSync(CreatePaymentIntentRequestSchema)({
amount: 0,
}),
).toThrow("Amount must be positive");
});

it("accepts valid request", () => {
const result = Schema.decodeUnknownSync(CreatePaymentIntentRequestSchema)({
amount: 50,
});
expect(result.amount).toBe(50);
});
});

describe.sequential("Organizations API", (it) => {
let org: PublicOrganizationWithMembership;

Expand Down Expand Up @@ -150,6 +178,24 @@ describe.sequential("Organizations API", (it) => {
}),
);

it.effect(
"POST /organizations/:id/credits/payment-intent - create payment intent",
() =>
Effect.gen(function* () {
const { client } = yield* TestApiContext;
const result = yield* client.organizations.createPaymentIntent({
path: { id: org.id },
payload: {
amount: 50,
},
});

expect(result.clientSecret).toBeDefined();
expect(result.clientSecret).toMatch(/^pi_test_/);
expect(result.amount).toBe(50);
}),
);

it.effect("DELETE /organizations/:id - delete organization", () =>
Effect.gen(function* () {
const { client } = yield* TestApiContext;
Expand Down
4 changes: 4 additions & 0 deletions cloud/api/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
updateOrganizationHandler,
deleteOrganizationHandler,
getOrganizationRouterBalanceHandler,
createPaymentIntentHandler,
} from "@/api/organizations.handlers";
import {
listProjectsHandler,
Expand Down Expand Up @@ -82,6 +83,9 @@ const OrganizationsHandlersLive = HttpApiBuilder.group(
.handle("delete", ({ path }) => deleteOrganizationHandler(path.id))
.handle("routerBalance", ({ path }) =>
getOrganizationRouterBalanceHandler(path.id),
)
.handle("createPaymentIntent", ({ path, payload }) =>
createPaymentIntentHandler(path.id, payload),
),
);

Expand Down
30 changes: 29 additions & 1 deletion cloud/app/api/organizations.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Effect } from "effect";
import { ApiClient, eq } from "@/app/api/client";
import type { CreateOrganizationRequest } from "@/api/organizations.schemas";
import type {
CreateOrganizationRequest,
CreatePaymentIntentRequest,
} from "@/api/organizations.schemas";

export const useOrganizations = () => {
return useQuery(
Expand Down Expand Up @@ -83,3 +86,28 @@ export const useOrganizationRouterBalance = (
enabled: !!organizationId,
});
};

export const useCreatePaymentIntent = () => {
return useMutation({
...eq.mutationOptions({
mutationKey: ["organizations", "createPaymentIntent"],
mutationFn: ({
organizationId,
data,
}: {
organizationId: string;
data: CreatePaymentIntentRequest;
}) =>
Effect.gen(function* () {
const client = yield* ApiClient;
return yield* client.organizations.createPaymentIntent({
path: { id: organizationId },
payload: data,
});
}),
}),
// NOTE: no `onSuccess` handler since we need to invalidate in the dialogue.
// If we do query invalidation here, we invalidate after creating intent, not
// after confirmation of a successful payment (which require the webhook)
});
};
Loading