Skip to content

Commit 2a5b32a

Browse files
authored
Merge pull request #37 from ScrawnDotDev/feat/environments
Feat/environments
2 parents 9726310 + 0204f71 commit 2a5b32a

29 files changed

Lines changed: 359 additions & 186 deletions

.env.example

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@ DATABASE_URL="YOUR_NOT_SO_SECRET_DATABASE_URL_GOES_HERE"
44
# Sentry
55
SENTRY_DSN="" # Your Sentry DSN for error monitoring
66

7-
# Dodo Payments
8-
DODO_PAYMENTS_API_KEY="" # Your Dodo Payments API key (from dashboard → Developer → API)
7+
# Dodo Payments
8+
DODO_PAYMENTS_LIVE_API_KEY="" # Your Dodo Payments API key for live/production mode
9+
DODO_PAYMENTS_TEST_API_KEY="" # Your Dodo Payments API key for test/sandbox mode
910
DODO_PAYMENTS_PRODUCT_ID="" # Your Dodo product ID (prod_xxxxx)
1011
DODO_PAYMENTS_WEBHOOK_SECRET= # Webhook signing secret
11-
12+
REDIRECT_URL=
1213

1314
TEST_API_KEY=
1415
REDIS_URL=

src/factory/EventStorageAdapterFactory.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import type { EventKind } from "../interface/event/Event.ts";
22
import { ClickHouseAdapter } from "../storage/adapter/clickhouse/ClickHouseAdapter.ts";
3+
import { PostgresAdapter } from "../storage/adapter/postgres/postgres.js";
34

45
export class StorageAdapterFactory {
56
public static async getEventStorageAdapter(RequestType: EventKind) {
67
switch (RequestType) {
78
case "SDK_CALL":
89
case "AI_TOKEN_USAGE":
910
case "PAYMENT": {
10-
return new ClickHouseAdapter();
11+
return new PostgresAdapter();
1112
}
1213
default: {
1314
throw new Error(`Unknown event type: ${RequestType}`);

src/interface/storage/Storage.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,14 +62,14 @@ export interface StorageAdapter {
6262

6363
add(
6464
serialized: SerializedEvent,
65-
apiKeyId?: string,
66-
mode?: string
65+
apiKeyId: string,
66+
mode: "production" | "test"
6767
): Promise<{ id: string } | void>;
6868
price(
6969
userID: UserId,
7070
event_type: EventKind,
7171
beforeTimestamp: DateTime,
72-
mode?: string
72+
mode: "production" | "test"
7373
): Promise<number>;
7474
query(request: QueryRequest): Promise<QueryResponse>;
7575
}

src/routes/gRPC/payment/createCheckoutLink.ts

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import type {
1717
import {
1818
getPaymentProviderConfig,
1919
createProviderCheckout,
20-
getDodoClient,
2120
type CheckoutResult,
2221
} from "./paymentProvider.ts";
2322
import { StorageAdapterFactory } from "../../../factory";
@@ -26,7 +25,7 @@ import { apiKeyContextKey, type AuthContext } from "../../../context/auth";
2625
import { wideEventContextKey } from "../../../context/requestContext";
2726
import type { UserId } from "../../../config/identifiers";
2827
import { DateTime } from "luxon";
29-
import { handleAddSession } from "../../../storage/adapter/postgres/handlers";
28+
import { handleAddSession } from "../../../storage/db/postgres/helpers/sessions";
3029
import { type ContextUnaryCall } from "../../../interface/types/context.ts";
3130

3231
export async function createCheckoutLink(
@@ -45,16 +44,20 @@ export async function createCheckoutLink(
4544

4645
if (auth.role === "dashboard") {
4746
return callback?.(
48-
AuthError.permissionDenied("Dashboard keys cannot create checkout links")
47+
AuthError.permissionDenied(
48+
"Dashboard keys cannot create checkout links"
49+
)
4950
);
5051
}
5152

52-
if (auth.role === "test") {
53+
if (!auth.mode) {
5354
return callback?.(
54-
AuthError.permissionDenied("Test keys cannot create checkout links")
55+
AuthError.permissionDenied("Auth mode not set on API key")
5556
);
5657
}
5758

59+
const mode = auth.mode;
60+
5861
const config = getPaymentProviderConfig();
5962
const validatedData = validateRequest(req);
6063
wideEventBuilder?.setUser(validatedData.userId);
@@ -63,7 +66,7 @@ export async function createCheckoutLink(
6366
const custom_price = await calculatePrice(
6467
validatedData.userId,
6568
beforeTimestamp,
66-
auth.mode!
69+
mode
6770
);
6871
wideEventBuilder?.setPaymentContext({ priceAmount: custom_price });
6972

@@ -72,21 +75,24 @@ export async function createCheckoutLink(
7275
custom_price,
7376
validatedData.userId,
7477
auth.apiKeyId,
75-
beforeTimestamp
78+
beforeTimestamp,
79+
mode
7680
);
7781

78-
const sessionResult = await (handleAddSession as unknown as (
79-
userId: UserId, sessionId: string, billedUpto: DateTime, mode?: string
80-
) => Promise<{ id: string }>)(
82+
const sessionResult = await handleAddSession(
8183
validatedData.userId,
8284
checkoutResult.sessionId,
8385
beforeTimestamp,
84-
auth.mode!
86+
auth.apiKeyId,
87+
mode,
88+
checkoutResult.checkoutUrl
8589
);
8690
wideEventBuilder?.setPaymentContext({ sessionId: sessionResult.id });
8791

92+
const proxyUrl = `${process.env.APP_URL}/checkout/${sessionResult.id}`;
93+
8894
const response = new CreateCheckoutLinkResponse();
89-
response.setCheckoutlink(checkoutResult.checkoutUrl);
95+
response.setCheckoutlink(proxyUrl);
9096
callback?.(null, response);
9197
} catch (error) {
9298
callback?.(error as Error);
@@ -109,7 +115,7 @@ function validateRequest(
109115
async function calculatePrice(
110116
userId: UserId,
111117
beforeTimestamp: DateTime,
112-
mode: string
118+
mode: "production" | "test"
113119
): Promise<number> {
114120
const storageAdapter =
115121
await StorageAdapterFactory.getEventStorageAdapter("PAYMENT");
@@ -118,9 +124,12 @@ async function calculatePrice(
118124
throw PaymentError.storageAdapterFailed("Storage adapter not available");
119125
}
120126

121-
const price = await (storageAdapter as unknown as {
122-
price: (userId: UserId, eventType: string, beforeTs: DateTime, mode?: string) => Promise<number>;
123-
}).price(userId, "PAYMENT", beforeTimestamp, mode);
127+
const price = await storageAdapter.price(
128+
userId,
129+
"PAYMENT",
130+
beforeTimestamp,
131+
mode
132+
);
124133

125134
if (typeof price !== "number" || isNaN(price) || price < 0) {
126135
throw PaymentError.priceCalculationFailed(
@@ -137,15 +146,16 @@ async function createCheckoutSession(
137146
customPrice: number,
138147
userId: string,
139148
apiKeyId: string,
140-
beforeTimestamp: DateTime
149+
beforeTimestamp: DateTime,
150+
mode: "test" | "production"
141151
): Promise<CheckoutResult> {
142152
const params: CheckoutParams = {
143153
customPrice,
144154
userId,
145155
apiKeyId,
146156
};
147157

148-
const checkoutResult = await createProviderCheckout(config, params);
158+
const checkoutResult = await createProviderCheckout(config, params, mode);
149159

150160
if (
151161
!checkoutResult.checkoutUrl ||

src/routes/gRPC/payment/paymentProvider.ts

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,40 @@
11
import DodoPayments from "dodopayments";
22
import { PaymentError } from "../../../errors/payment";
33

4-
let client: DodoPayments | null = null;
4+
let liveClient: DodoPayments | null = null;
5+
let testClient: DodoPayments | null = null;
56

6-
export function getDodoClient(): DodoPayments {
7-
if (!client) {
8-
const apiKey = process.env.DODO_PAYMENTS_API_KEY;
7+
export function getDodoClient(mode?: "test" | "production"): DodoPayments {
8+
if (!mode) {
9+
mode = process.env.NODE_ENV === "production" ? "production" : "test";
10+
}
11+
if (mode === "production") {
12+
if (!liveClient) {
13+
const apiKey = process.env.DODO_PAYMENTS_LIVE_API_KEY;
14+
if (!apiKey) {
15+
throw PaymentError.missingApiKey();
16+
}
17+
liveClient = new DodoPayments({
18+
bearerToken: apiKey,
19+
environment: "live_mode",
20+
webhookKey: process.env.DODO_PAYMENTS_WEBHOOK_SIGNING_SECRET,
21+
});
22+
}
23+
return liveClient;
24+
}
25+
26+
if (!testClient) {
27+
const apiKey = process.env.DODO_PAYMENTS_TEST_API_KEY;
928
if (!apiKey) {
1029
throw PaymentError.missingApiKey();
1130
}
12-
client = new DodoPayments({
31+
testClient = new DodoPayments({
1332
bearerToken: apiKey,
14-
environment:
15-
process.env.NODE_ENV === "production" ? "live_mode" : "test_mode",
33+
environment: "test_mode",
1634
webhookKey: process.env.DODO_PAYMENTS_WEBHOOK_SIGNING_SECRET,
1735
});
1836
}
19-
return client;
37+
return testClient;
2038
}
2139

2240
export interface PaymentProviderConfig {
@@ -37,7 +55,7 @@ export interface CheckoutResult {
3755

3856
export function getPaymentProviderConfig(): PaymentProviderConfig {
3957
const productId = process.env.DODO_PAYMENTS_PRODUCT_ID;
40-
const returnUrl = `${process.env.APP_URL}/checkout/success`;
58+
const returnUrl = `${process.env.REDIRECT_URL}`;
4159

4260
if (!productId) {
4361
throw PaymentError.missingProductId();
@@ -48,9 +66,10 @@ export function getPaymentProviderConfig(): PaymentProviderConfig {
4866

4967
export async function createProviderCheckout(
5068
config: PaymentProviderConfig,
51-
params: CheckoutParams
69+
params: CheckoutParams,
70+
mode: "test" | "production"
5271
): Promise<CheckoutResult> {
53-
const client = getDodoClient();
72+
const client = getDodoClient(mode);
5473

5574
const session = await client.checkoutSessions.create({
5675
product_cart: [
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { FastifyRequest, FastifyReply } from "fastify";
2+
import { z } from "zod";
3+
import { getCheckoutUrl } from "../../storage/db/postgres/helpers/sessions";
4+
import { formatZodError } from "../../utils/formatZodError";
5+
6+
const checkoutParamsSchema = z.object({
7+
sessionId: z.string().uuid({ message: "Invalid session ID format" }),
8+
});
9+
10+
export async function handleCheckoutRedirect(
11+
request: FastifyRequest<{ Params: { sessionId: string } }>,
12+
reply: FastifyReply
13+
): Promise<void> {
14+
let sessionId: string;
15+
try {
16+
const parsed = checkoutParamsSchema.parse(request.params);
17+
sessionId = parsed.sessionId;
18+
} catch (error) {
19+
const formatted = formatZodError(error, (msg) => ({
20+
type: "ValidationError",
21+
message: msg,
22+
name: "ValidationError",
23+
}));
24+
reply.code(400);
25+
return reply.send({ error: formatted.message });
26+
}
27+
28+
const checkoutUrl = await getCheckoutUrl(sessionId);
29+
30+
if (!checkoutUrl) {
31+
reply.code(404);
32+
return reply.send({ error: "Checkout session not found" });
33+
}
34+
35+
reply.code(302).redirect(checkoutUrl);
36+
}

src/routes/http/createdCheckout.ts

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { getDodoClient } from "../gRPC/payment/paymentProvider.ts";
77
import { getPostgresDB } from "../../storage/db/postgres/db";
88
import { usersTable, sessionsTable } from "../../storage/db/postgres/schema";
99
import { eq } from "drizzle-orm";
10+
import { getSessionByCheckoutId } from "../../storage/db/postgres/helpers/sessions";
1011

1112

1213
const isDev = process.env.NODE_ENV !== "production";
@@ -85,20 +86,9 @@ export async function handleDodoWebhook(
8586
};
8687
}
8788

88-
const db = getPostgresDB();
89+
const session = await getSessionByCheckoutId(checkout_session_id);
8990

90-
const sessions = await db
91-
.select({
92-
id: sessionsTable.id,
93-
userId: sessionsTable.userId,
94-
billed_upto: sessionsTable.billed_upto,
95-
processed: sessionsTable.processed,
96-
})
97-
.from(sessionsTable)
98-
.where(eq(sessionsTable.sessionId, checkout_session_id))
99-
.limit(1);
100-
101-
if (sessions.length === 0 || !sessions[0]) {
91+
if (!session) {
10292
builder.setError(404, {
10393
type: "NotFoundError",
10494
message: `Session not found for checkout_session_id: ${checkout_session_id}`,
@@ -109,7 +99,7 @@ export async function handleDodoWebhook(
10999
};
110100
}
111101

112-
const session = sessions[0];
102+
const db = getPostgresDB();
113103

114104
if (session.processed) {
115105
builder.setSuccess(200);
@@ -163,7 +153,7 @@ export async function handleDodoWebhook(
163153
const adapter =
164154
await StorageAdapterFactory.getEventStorageAdapter("PAYMENT");
165155

166-
await adapter.add(paymentEvent.serialize());
156+
await adapter.add(paymentEvent.serialize(), session.apiKeyId, session.mode);
167157

168158
builder.setSuccess(200);
169159
return {

src/servers/fastifyServer.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { fastify } from "fastify";
22
import fastifyRawBody from "fastify-raw-body";
33
import { registerWebhookRoutes } from "../routes/http/registerWebhookRoutes.ts";
44
import { registerApiRoutes } from "../routes/http/api/registerApiRoutes.ts";
5+
import { handleCheckoutRedirect } from "../routes/http/checkoutRedirect.ts";
56
import { logger } from "../errors/logger.ts";
67

78
export async function startFastifyServer(port: number, grpcPort: number): Promise<void> {
@@ -19,6 +20,10 @@ export async function startFastifyServer(port: number, grpcPort: number): Promis
1920
return "Hello World!";
2021
});
2122

23+
server.get<{ Params: { sessionId: string } }>("/checkout/:sessionId", async (request, reply) => {
24+
await handleCheckoutRedirect(request, reply);
25+
});
26+
2227
await registerWebhookRoutes(server);
2328
await registerApiRoutes(server);
2429

0 commit comments

Comments
 (0)