Skip to content

Commit 495f7c2

Browse files
committed
feat: migrate billing portal from n8n to payment worker
1 parent 6c17a0e commit 495f7c2

4 files changed

Lines changed: 137 additions & 0 deletions

File tree

apps/builder/app/env/env.server.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ const env = {
5454
N8N_WEBHOOK_URL: process.env.N8N_WEBHOOK_URL,
5555
N8N_WEBHOOK_TOKEN: process.env.N8N_WEBHOOK_TOKEN,
5656

57+
PAYMENT_WORKER_URL: process.env.PAYMENT_WORKER_URL,
58+
PAYMENT_WORKER_TOKEN: process.env.PAYMENT_WORKER_TOKEN,
59+
5760
PUBLISHER_HOST: process.env.PUBLISHER_HOST || "wstd.work",
5861

5962
STAGING_USERNAME: process.env.STAGING_USERNAME ?? "admin",
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
2+
import { isRouteErrorResponse, useRouteError } from "@remix-run/react";
3+
import { z } from "zod";
4+
import { findAuthenticatedUser } from "~/services/auth.server";
5+
import { isDashboard, loginPath } from "~/shared/router-utils";
6+
import env from "~/env/env.server";
7+
import cookie from "cookie";
8+
import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie";
9+
import { redirect } from "~/services/no-store-redirect";
10+
import { allowedDestinations } from "~/services/destinations.server";
11+
12+
const zWorkerResponse = z.union([
13+
z.object({
14+
type: z.literal("error"),
15+
error: z.string(),
16+
}),
17+
z.object({
18+
type: z.literal("redirect"),
19+
to: z.string(),
20+
}),
21+
]);
22+
23+
const zWorkerEnv = z.object({
24+
PAYMENT_WORKER_URL: z.string(),
25+
PAYMENT_WORKER_TOKEN: z.string(),
26+
});
27+
28+
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
29+
if (isDashboard(request) === false) {
30+
throw new Response("Not Found", {
31+
status: 404,
32+
});
33+
}
34+
35+
preventCrossOriginCookie(request);
36+
allowedDestinations(request, ["document", "empty"]);
37+
38+
const user = await findAuthenticatedUser(request);
39+
40+
if (user === null) {
41+
const url = new URL(request.url);
42+
throw redirect(
43+
loginPath({
44+
returnTo: `${url.pathname}?${url.searchParams.toString()}`,
45+
})
46+
);
47+
}
48+
49+
const workerEnvParsed = zWorkerEnv.safeParse(env);
50+
if (workerEnvParsed.success === false) {
51+
throw new Response(workerEnvParsed.error.message, {
52+
status: 400,
53+
});
54+
}
55+
56+
const workerEnv = workerEnvParsed.data;
57+
58+
const workerUrl = new URL(workerEnv.PAYMENT_WORKER_URL);
59+
workerUrl.pathname = `${workerUrl.pathname}/${params["*"]}`
60+
.split("/")
61+
.filter(Boolean)
62+
.join("/");
63+
workerUrl.search = new URL(request.url).search;
64+
65+
const requestUrl = new URL(request.url);
66+
67+
const response = await fetch(workerUrl.href, {
68+
method: "POST",
69+
headers: {
70+
"Content-Type": "application/json",
71+
Authorization: `Bearer ${workerEnv.PAYMENT_WORKER_TOKEN}`,
72+
},
73+
body: JSON.stringify({
74+
userId: user.id,
75+
cookies: cookie.parse(request.headers.get("cookie") ?? ""),
76+
requestUrl: requestUrl.href,
77+
}),
78+
});
79+
80+
if (response.ok === false) {
81+
const text = await response.text();
82+
83+
throw new Response(
84+
`Fetch error status="${response.status}"\nMessage:\n${text.slice(
85+
0,
86+
1000
87+
)}"`,
88+
{
89+
status: response.status,
90+
}
91+
);
92+
}
93+
94+
const responseJson = await response.json();
95+
const workerResponseParsed = zWorkerResponse.safeParse(responseJson);
96+
97+
if (workerResponseParsed.success === false) {
98+
throw new Response(workerResponseParsed.error.message, {
99+
status: 400,
100+
});
101+
}
102+
103+
const workerResponse = workerResponseParsed.data;
104+
105+
if (workerResponse.type === "error") {
106+
throw new Response(workerResponse.error, {
107+
status: 400,
108+
});
109+
}
110+
111+
if (workerResponse.type === "redirect") {
112+
throw redirect(workerResponse.to);
113+
}
114+
115+
workerResponse satisfies never;
116+
117+
return json({});
118+
};
119+
120+
export const ErrorBoundary = () => {
121+
const error = useRouteError();
122+
123+
if (isRouteErrorResponse(error)) {
124+
return <div style={{ whiteSpace: "pre-wrap" }}>{error.data}</div>;
125+
}
126+
127+
return <div>Unexpected error</div>;
128+
};

apps/builder/app/shared/router-utils/path-utils.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { AUTH_PROVIDERS } from "~/shared/session";
22
import { publicStaticEnv } from "~/env/env.static";
33
import { getAuthorizationServerOrigin } from "./origins";
44
import type { BuilderMode } from "../nano-states/misc";
5+
import { isFeatureEnabled } from "@webstudio-is/feature-flags";
56

67
const searchParams = (params: Record<string, string | undefined | null>) => {
78
const searchParams = new URLSearchParams();
@@ -115,6 +116,10 @@ export const userPlanSubscriptionPath = (subscriptionId?: string) => {
115116
urlSearchParams.set("subscription", subscriptionId);
116117
}
117118

119+
if (isFeatureEnabled("paymentWorker")) {
120+
return `/payments/billing-portal/sessions?${urlSearchParams.toString()}`;
121+
}
122+
118123
return `/n8n/billing_portal/sessions?${urlSearchParams.toString()}`;
119124
};
120125

packages/feature-flags/src/flags.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export const internalComponents = false;
33
export const unsupportedBrowsers = false;
44
export const resourceProp = false;
55
export const tailwind = false;
6+
export const paymentWorker = false;

0 commit comments

Comments
 (0)