Skip to content

Commit 7794fa9

Browse files
authored
Allow one free cloud worker without billing (#864)
* Allow one free cloud worker without billing * Add desktop and mobile PR screenshots * Add worker limit request action --------- Co-authored-by: jcllobet <jcllobet@users.noreply.github.com>
1 parent 4ec2eb0 commit 7794fa9

13 files changed

+592
-109
lines changed

packages/web/components/cloud-control.tsx

Lines changed: 144 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ const WORKER_STATUS_POLL_MS = 5000;
159159
const DEFAULT_AUTH_NAME = "OpenWork User";
160160
const OPENWORK_APP_CONNECT_BASE_URL = (process.env.NEXT_PUBLIC_OPENWORK_APP_CONNECT_URL ?? "").trim();
161161
const OPENWORK_AUTH_CALLBACK_BASE_URL = (process.env.NEXT_PUBLIC_OPENWORK_AUTH_CALLBACK_URL ?? "").trim();
162+
const BILLING_DISABLED_FOR_EXPERIMENT = true;
162163

163164
function getEmailDomain(email: string): string {
164165
const atIndex = email.lastIndexOf("@");
@@ -241,6 +242,32 @@ function getSocialProviderLabel(provider: SocialAuthProvider): string {
241242
return provider === "github" ? "GitHub" : "Google";
242243
}
243244

245+
function getExperimentBillingSummary(): BillingSummary {
246+
return {
247+
featureGateEnabled: false,
248+
hasActivePlan: false,
249+
checkoutRequired: false,
250+
checkoutUrl: null,
251+
portalUrl: null,
252+
price: null,
253+
subscription: null,
254+
invoices: [],
255+
productId: null,
256+
benefitId: null
257+
};
258+
}
259+
260+
function getAdditionalWorkerRequestHref(): string {
261+
const subject = "requesting an additional worker";
262+
const body = [
263+
"Hey Ben,",
264+
"",
265+
"I would like to create an additional worker in order to {INSERT REASON}"
266+
].join("\n");
267+
268+
return `mailto:ben@openwork.software?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
269+
}
270+
244271
function GitHubLogo() {
245272
return (
246273
<svg viewBox="0 0 16 16" aria-hidden="true" className="ow-social-icon">
@@ -1088,6 +1115,8 @@ export function CloudControlPanel() {
10881115
const showAuthFeedback = authInfo !== defaultAuthInfo || authError !== null;
10891116
const openworkConnectUrl = activeWorker?.openworkUrl ?? activeWorker?.instanceUrl ?? null;
10901117
const hasWorkspaceScopedUrl = Boolean(openworkConnectUrl && /\/w\/[^/?#]+/.test(openworkConnectUrl));
1118+
const ownedWorkerCount = workers.filter((item) => item.isMine).length;
1119+
const workerLimitReached = Boolean(user && ownedWorkerCount > 0);
10911120
const openworkDeepLink = buildOpenworkDeepLink(
10921121
openworkConnectUrl,
10931122
activeWorker?.clientToken ?? null,
@@ -1189,7 +1218,7 @@ export function CloudControlPanel() {
11891218

11901219
const selectedWorkerStatus = activeWorker?.status ?? selectedWorker?.status ?? "unknown";
11911220
const selectedStatusMeta = getWorkerStatusMeta(selectedWorkerStatus);
1192-
const effectiveCheckoutUrl = checkoutUrl ?? billingSummary?.checkoutUrl ?? null;
1221+
const effectiveCheckoutUrl = BILLING_DISABLED_FOR_EXPERIMENT ? null : (checkoutUrl ?? billingSummary?.checkoutUrl ?? null);
11931222
const billingSubscription = billingSummary?.subscription ?? null;
11941223
const billingPrice = billingSummary?.price ?? null;
11951224
const runtimeUpgradeCount = runtimeSnapshot?.services.filter((item) => item.upgradeAvailable).length ?? 0;
@@ -1428,6 +1457,14 @@ export function CloudControlPanel() {
14281457
}
14291458

14301459
async function refreshBilling(options: { includeCheckout?: boolean; quiet?: boolean } = {}) {
1460+
if (BILLING_DISABLED_FOR_EXPERIMENT) {
1461+
const summary = getExperimentBillingSummary();
1462+
setBillingSummary(summary);
1463+
setCheckoutUrl(null);
1464+
setBillingError(null);
1465+
return summary;
1466+
}
1467+
14311468
if (!user) {
14321469
setBillingSummary(null);
14331470
if (!options.quiet) {
@@ -1499,6 +1536,13 @@ export function CloudControlPanel() {
14991536
}
15001537

15011538
async function handleSubscriptionCancellation(cancelAtPeriodEnd: boolean) {
1539+
if (BILLING_DISABLED_FOR_EXPERIMENT) {
1540+
setBillingSummary(getExperimentBillingSummary());
1541+
setCheckoutUrl(null);
1542+
setBillingError("Billing is disabled for this experiment.");
1543+
return;
1544+
}
1545+
15021546
if (!user || billingSubscriptionBusy) {
15031547
return;
15041548
}
@@ -1647,6 +1691,13 @@ export function CloudControlPanel() {
16471691
}, [activeWorker?.workerId, selectedWorker?.workerId, runtimeSnapshot?.upgrade.status]);
16481692

16491693
useEffect(() => {
1694+
if (BILLING_DISABLED_FOR_EXPERIMENT) {
1695+
setBillingSummary(getExperimentBillingSummary());
1696+
setBillingError(null);
1697+
setCheckoutUrl(null);
1698+
return;
1699+
}
1700+
16501701
if (!user) {
16511702
setBillingSummary(null);
16521703
setBillingError(null);
@@ -1657,6 +1708,13 @@ export function CloudControlPanel() {
16571708
}, [user?.id, authToken]);
16581709

16591710
useEffect(() => {
1711+
if (BILLING_DISABLED_FOR_EXPERIMENT) {
1712+
if (shellView !== "workers") {
1713+
setShellView("workers");
1714+
}
1715+
return;
1716+
}
1717+
16601718
if (!user || shellView !== "billing") {
16611719
return;
16621720
}
@@ -1701,16 +1759,21 @@ export function CloudControlPanel() {
17011759
return;
17021760
}
17031761

1704-
setPaymentReturned(true);
1762+
// Polar checkout returns are ignored while billing is disabled for this experiment.
1763+
// TODO(den-free-first-worker): Re-enable the original Polar checkout return flow after the experiment.
1764+
// setPaymentReturned(true);
1765+
// setCheckoutUrl(null);
1766+
// setShellView("billing");
1767+
// setLaunchStatus("Checkout return detected. Click launch to continue worker provisioning.");
1768+
// setAuthInfo("Checkout return detected. Sign in to continue to Billing.");
1769+
// appendEvent("success", "Returned from checkout", `Session ${shortValue(customerSessionToken)}`);
1770+
// trackPosthogEvent("den_paywall_checkout_returned", {
1771+
// source: "polar",
1772+
// session_token_present: true
1773+
// });
17051774
setCheckoutUrl(null);
1706-
setShellView("billing");
1707-
setLaunchStatus("Checkout return detected. Click launch to continue worker provisioning.");
1708-
setAuthInfo("Checkout return detected. Sign in to continue to Billing.");
1709-
appendEvent("success", "Returned from checkout", `Session ${shortValue(customerSessionToken)}`);
1710-
trackPosthogEvent("den_paywall_checkout_returned", {
1711-
source: "polar",
1712-
session_token_present: true
1713-
});
1775+
setShellView("workers");
1776+
setLaunchStatus("Name your worker and click launch.");
17141777

17151778
params.delete("customer_session_token");
17161779
const nextQuery = params.toString();
@@ -1723,7 +1786,7 @@ export function CloudControlPanel() {
17231786
return;
17241787
}
17251788

1726-
void refreshBilling({ quiet: true });
1789+
// Billing refresh intentionally disabled for the one-worker experiment.
17271790
}, [paymentReturned, user?.id, authToken]);
17281791

17291792
useEffect(() => {
@@ -2119,33 +2182,41 @@ export function CloudControlPanel() {
21192182
12000
21202183
);
21212184

2122-
if (response.status === 402) {
2123-
const url = getCheckoutUrl(payload);
2124-
setCheckoutUrl(url);
2125-
setShellView("billing");
2126-
setBillingSummary((current) => {
2127-
if (!current) {
2128-
return current;
2129-
}
2130-
2131-
return {
2132-
...current,
2133-
hasActivePlan: false,
2134-
checkoutRequired: true,
2135-
checkoutUrl: url ?? current.checkoutUrl
2136-
};
2137-
});
2138-
setLaunchStatus("Payment is required. Complete checkout and return to continue launch.");
2139-
setLaunchError(url ? null : "Checkout URL missing from paywall response.");
2140-
appendEvent("warning", "Paywall required", url ? "Checkout URL generated" : "Checkout URL missing");
2141-
trackPosthogEvent("den_paywall_required", {
2142-
checkout_url_present: Boolean(url)
2143-
});
2144-
2145-
if (!url) {
2146-
void refreshBilling({ includeCheckout: true, quiet: true });
2147-
}
2148-
2185+
// TODO(den-free-first-worker): Restore this 402 paywall branch after the one-worker experiment ends.
2186+
// if (response.status === 402) {
2187+
// const url = getCheckoutUrl(payload);
2188+
// setCheckoutUrl(url);
2189+
// setShellView("billing");
2190+
// setBillingSummary((current) => {
2191+
// if (!current) {
2192+
// return current;
2193+
// }
2194+
//
2195+
// return {
2196+
// ...current,
2197+
// hasActivePlan: false,
2198+
// checkoutRequired: true,
2199+
// checkoutUrl: url ?? current.checkoutUrl
2200+
// };
2201+
// });
2202+
// setLaunchStatus("Payment is required. Complete checkout and return to continue launch.");
2203+
// setLaunchError(url ? null : "Checkout URL missing from paywall response.");
2204+
// appendEvent("warning", "Paywall required", url ? "Checkout URL generated" : "Checkout URL missing");
2205+
// trackPosthogEvent("den_paywall_required", {
2206+
// checkout_url_present: Boolean(url)
2207+
// });
2208+
//
2209+
// if (!url) {
2210+
// void refreshBilling({ includeCheckout: true, quiet: true });
2211+
// }
2212+
//
2213+
// return;
2214+
// }
2215+
if (response.status === 409) {
2216+
const message = getErrorMessage(payload, "You can only create one cloud worker during this experiment.");
2217+
setLaunchStatus("Worker limit reached.");
2218+
setLaunchError(message);
2219+
appendEvent("warning", "Worker limit reached", message);
21492220
return;
21502221
}
21512222

@@ -2582,15 +2653,16 @@ export function CloudControlPanel() {
25822653
>
25832654
Workers
25842655
</button>
2585-
<button
2656+
{/* TODO(den-free-first-worker): Restore Billing nav button after the experiment. */}
2657+
{/* <button
25862658
type="button"
25872659
onClick={() => setShellView("billing")}
25882660
className={`rounded-[12px] px-3 py-1.5 text-sm font-medium transition ${
25892661
shellView === "billing" ? "bg-[#1B29FF]/10 text-[#1B29FF]" : "text-slate-600 hover:bg-slate-100"
25902662
}`}
25912663
>
25922664
Billing
2593-
</button>
2665+
</button> */}
25942666
</div>
25952667
<button
25962668
type="button"
@@ -2602,7 +2674,7 @@ export function CloudControlPanel() {
26022674
</button>
26032675
</div>
26042676

2605-
{shellView === "workers" ? (
2677+
{shellView === "workers" || BILLING_DISABLED_FOR_EXPERIMENT ? (
26062678
<div className="flex h-full min-h-0 flex-col gap-4 lg:flex-row">
26072679
<aside className="hidden h-full w-[260px] shrink-0 flex-col justify-between rounded-[32px] border border-slate-200 bg-white p-5 shadow-sm lg:flex">
26082680
<div>
@@ -2618,13 +2690,14 @@ export function CloudControlPanel() {
26182690
>
26192691
Workers
26202692
</button>
2621-
<button
2693+
{/* TODO(den-free-first-worker): Restore Billing sidebar button after the experiment. */}
2694+
{/* <button
26222695
type="button"
26232696
className="w-full rounded-[14px] px-3 py-2.5 text-left text-sm font-medium text-slate-500 transition hover:bg-slate-50"
26242697
onClick={() => setShellView("billing")}
26252698
>
26262699
Billing
2627-
</button>
2700+
</button> */}
26282701
<span className="block rounded-[14px] px-3 py-2.5 text-sm font-medium text-slate-400">Settings</span>
26292702
<span className="block rounded-[14px] px-3 py-2.5 text-sm font-medium text-slate-400">Help Center</span>
26302703
</nav>
@@ -2698,10 +2771,12 @@ export function CloudControlPanel() {
26982771
type="button"
26992772
className="w-full rounded-[12px] bg-[#1B29FF] px-3 py-2.5 text-sm font-semibold text-white transition hover:bg-[#151FDA] disabled:cursor-not-allowed disabled:opacity-60"
27002773
onClick={handleLaunchWorker}
2701-
disabled={!user || launchBusy || worker?.status === "provisioning"}
2774+
disabled={!user || launchBusy || worker?.status === "provisioning" || workerLimitReached}
27022775
>
27032776
{launchBusy
27042777
? "Starting worker..."
2778+
: workerLimitReached
2779+
? "Worker limit reached"
27052780
: worker?.status === "provisioning"
27062781
? "Worker is starting..."
27072782
: `Launch "${workerName || "Cloud Worker"}"`}
@@ -2714,6 +2789,15 @@ export function CloudControlPanel() {
27142789
</div>
27152790
) : null}
27162791

2792+
{workerLimitReached ? (
2793+
<a
2794+
href={getAdditionalWorkerRequestHref()}
2795+
className="mt-3 inline-flex w-full items-center justify-center rounded-[12px] border border-slate-300 bg-white px-3 py-2.5 text-sm font-semibold text-slate-700 transition hover:border-slate-400 hover:text-slate-900"
2796+
>
2797+
Request an additional worker
2798+
</a>
2799+
) : null}
2800+
27172801
{effectiveCheckoutUrl ? (
27182802
<div className="mt-3 rounded-[12px] border border-amber-200 bg-amber-50 px-3 py-2.5">
27192803
<p className="text-sm font-semibold text-amber-800">Payment needed before launch</p>
@@ -2803,10 +2887,12 @@ export function CloudControlPanel() {
28032887
type="button"
28042888
className="w-full rounded-[12px] bg-[#1B29FF] px-3 py-2.5 text-sm font-semibold text-white transition hover:bg-[#151FDA] disabled:cursor-not-allowed disabled:opacity-60"
28052889
onClick={handleLaunchWorker}
2806-
disabled={!user || launchBusy || worker?.status === "provisioning"}
2890+
disabled={!user || launchBusy || worker?.status === "provisioning" || workerLimitReached}
28072891
>
28082892
{launchBusy
28092893
? "Starting worker..."
2894+
: workerLimitReached
2895+
? "Worker limit reached"
28102896
: worker?.status === "provisioning"
28112897
? "Worker is starting..."
28122898
: `Launch "${workerName || "Cloud Worker"}"`}
@@ -2819,7 +2905,17 @@ export function CloudControlPanel() {
28192905
</div>
28202906
) : null}
28212907

2822-
{effectiveCheckoutUrl ? (
2908+
{workerLimitReached ? (
2909+
<a
2910+
href={getAdditionalWorkerRequestHref()}
2911+
className="mt-3 inline-flex w-full items-center justify-center rounded-[12px] border border-slate-300 bg-white px-3 py-2.5 text-sm font-semibold text-slate-700 transition hover:border-slate-400 hover:text-slate-900"
2912+
>
2913+
Request an additional worker
2914+
</a>
2915+
) : null}
2916+
2917+
{/* TODO(den-free-first-worker): Restore checkout CTA block when paywall returns. */}
2918+
{/* {effectiveCheckoutUrl ? (
28232919
<div className="mt-3 rounded-[12px] border border-amber-200 bg-amber-50 px-3 py-2.5">
28242920
<p className="text-sm font-semibold text-amber-800">Payment needed before launch</p>
28252921
<a
@@ -2830,7 +2926,8 @@ export function CloudControlPanel() {
28302926
Continue to checkout
28312927
</a>
28322928
</div>
2833-
) : null}
2929+
) : null} */}
2930+
28342931
</div>
28352932
) : null}
28362933

services/den/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"dev": "OPENWORK_DEV_MODE=1 tsx watch src/index.ts",
77
"build": "tsc -p tsconfig.json",
88
"start": "node dist/index.js",
9+
"test:e2e:worker-limit": "node scripts/e2e-worker-limit.mjs",
910
"db:generate": "drizzle-kit generate",
1011
"db:migrate": "drizzle-kit migrate",
1112
"auth:generate": "npx @better-auth/cli@latest generate --config src/auth.ts --output src/db/better-auth.schema.ts --yes"

0 commit comments

Comments
 (0)