diff --git a/README.md b/README.md
index 19193aa..88ec4ae 100644
--- a/README.md
+++ b/README.md
@@ -63,6 +63,7 @@ Vercel CLI 33.5.5
- Set the "Base URL" to your deployed project's URL e.g. https://example-marketplace-integration.vercel.app
- Set the "Redirect Login URL" to your deployed projects URL with the path `/callback` e.g. https://example-marketplace-integration.vercel.app/callback
- Click the "Update" button at the bottom to save your changes.
+-
6. In the same Marketplace Integration Settings, create a product for your Vercel Integration using the "Create Product" button. A "product" maps to your own products you want to sell on Vercel. Depending on the product type (e.g. storage), the Vercel dashboard will understand how to interact with your product.
diff --git a/app/connect/configure/actions.ts b/app/connect/configure/actions.ts
new file mode 100644
index 0000000..d1cec2f
--- /dev/null
+++ b/app/connect/configure/actions.ts
@@ -0,0 +1,21 @@
+"use server";
+
+import { requestTransferToMarketplace } from "@/lib/vercel/marketplace-api";
+import { billingPlans } from "@/lib/partner";
+
+export async function requestTransferToVercelAction(formData: FormData) {
+ const installationId = formData.get("installationId") as string;
+ const transferId = Math.random().toString(36).substring(2);
+ const requester = "Vanessa";
+ const billingPlan = billingPlans.find((plan) => plan.paymentMethodRequired);
+ if (!billingPlan) {
+ throw new Error("No billing plan found.");
+ }
+ const result = await requestTransferToMarketplace(
+ installationId,
+ transferId,
+ requester,
+ billingPlan
+ );
+ return result;
+}
diff --git a/app/connect/configure/page.tsx b/app/connect/configure/page.tsx
index 6740fae..a2b586b 100644
--- a/app/connect/configure/page.tsx
+++ b/app/connect/configure/page.tsx
@@ -1,12 +1,85 @@
+import TransferToVercelRedirect from "./transfer-to-vercel-redirect";
+
export default async function Page({
searchParams: { configurationId },
}: {
searchParams: { configurationId: string };
}) {
return (
-
-
Nothing to configure here. 👀
-
{configurationId}
+
+
+
+
+ Configuration ID: {configurationId}
+
+
ACME
+
+
+
+
+
Billing
+
+ Manage your billing information and payment methods
+
+
+
+
+
+
+ Payment Methods
+
+ Billing History
+
+
+
+
+
+
Payment Methods
+
+ Manage your payment methods for ACME services
+
+
+
+
+
+
+
+
Visa ending in 4242
+
Expires 04/2025
+
+
+
+ Edit
+
+
+
+
+ Add Payment Method
+
+
+
+
+
+
+
Account Ownership
+
+ Transfer your account ownership and billing to your Vercel team
+
+
+
+
+
+ Transferring ownership will move all billing responsibilities to
+ the selected Vercel team.
+
+
+
+
+
+
);
}
diff --git a/app/connect/configure/transfer-to-vercel-redirect.tsx b/app/connect/configure/transfer-to-vercel-redirect.tsx
new file mode 100644
index 0000000..54606da
--- /dev/null
+++ b/app/connect/configure/transfer-to-vercel-redirect.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import { useState } from "react";
+import { requestTransferToVercelAction } from "./actions";
+
+interface TransferToVercelRedirectProps {
+ configurationId: string;
+}
+
+export default function TransferToVercelRedirect({
+ configurationId,
+}: TransferToVercelRedirectProps) {
+ const [continueUrl, setContinueUrl] = useState
(null);
+ const [loading, setLoading] = useState(false);
+
+ async function handleTransfer() {
+ setLoading(true);
+ const formData = new FormData();
+ formData.append("installationId", configurationId);
+
+ try {
+ const result = await requestTransferToVercelAction(formData);
+ setContinueUrl(result.continueUrl);
+ } catch (error: any) {
+ console.error("Transfer failed:", error.message);
+ }
+ setLoading(false);
+ }
+
+ function handleCancel() {
+ setContinueUrl(null);
+ }
+
+ return (
+
+ {continueUrl ? (
+
+
+ Please go to Vercel Marketplace to complete the transfer.
+
+
+
+ ) : (
+
+ {loading ? "Loading..." : "Transfer to Vercel"}
+
+ )}
+
+ );
+}
diff --git a/app/dashboard/auth.ts b/app/dashboard/auth.ts
index 925c0cb..b083f34 100644
--- a/app/dashboard/auth.ts
+++ b/app/dashboard/auth.ts
@@ -1,4 +1,4 @@
-import { OidcClaims, verifyToken } from "@/lib/vercel/auth";
+import { type OidcClaims, verifyToken } from "@/lib/vercel/auth";
import { cookies } from "next/headers";
export async function getSession(): Promise {
diff --git a/app/dashboard/installation/actions.ts b/app/dashboard/installation/actions.ts
index 34fa860..9e73a86 100644
--- a/app/dashboard/installation/actions.ts
+++ b/app/dashboard/installation/actions.ts
@@ -9,8 +9,9 @@ import {
getResourceBalance,
listResources,
} from "@/lib/partner";
-import { Balance } from "@/lib/vercel/schemas";
+import type { Balance } from "@/lib/vercel/schemas";
import {
+ requestTransferFromMarketplace,
sendBillingData,
submitPrepaymentBalances,
} from "@/lib/vercel/marketplace-api";
@@ -49,3 +50,15 @@ export async function sendBillingDataAction() {
await sendBillingData(installationId, billingData);
await submitPrepaymentBalances(installationId, balances);
}
+
+export async function requestTransferFromVercelAction(formData: FormData) {
+ const installationId = formData.get("installationId") as string;
+ const transferId = Math.random().toString(36).substring(2);
+ const requester = "Vanessa";
+ const result = await requestTransferFromMarketplace(
+ installationId,
+ transferId,
+ requester
+ );
+ return result;
+}
diff --git a/app/dashboard/installation/page.tsx b/app/dashboard/installation/page.tsx
index c2cce47..776db48 100644
--- a/app/dashboard/installation/page.tsx
+++ b/app/dashboard/installation/page.tsx
@@ -4,6 +4,7 @@ import { getAccountInfo } from "@/lib/vercel/marketplace-api";
import { Section } from "../components/section";
import { addInstallationBalance, sendBillingDataAction } from "./actions";
import { FormButton } from "../components/form-button";
+import TransferFromVercelRedirect from "./transfer-from-vercel-redirect";
export const dynamic = "force-dynamic";
@@ -74,6 +75,9 @@ export default async function IntallationPage() {
+
);
}
diff --git a/app/dashboard/installation/transfer-from-vercel-redirect.tsx b/app/dashboard/installation/transfer-from-vercel-redirect.tsx
new file mode 100644
index 0000000..f33d312
--- /dev/null
+++ b/app/dashboard/installation/transfer-from-vercel-redirect.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import { useState } from "react";
+import { requestTransferFromVercelAction } from "./actions";
+
+interface TransferFromVercelRedirectProps {
+ configurationId: string;
+}
+
+export default function TransferFromVercelRedirect({
+ configurationId,
+}: TransferFromVercelRedirectProps) {
+ const [continueUrl, setContinueUrl] = useState(null);
+ const [loading, setLoading] = useState(false);
+
+ async function handleTransfer() {
+ setLoading(true);
+ const formData = new FormData();
+ formData.append("installationId", configurationId);
+
+ try {
+ const result = await requestTransferFromVercelAction(formData);
+ setContinueUrl(result.continueUrl);
+ } catch (error: any) {
+ console.error("Transfer failed:", error.message);
+ }
+ setLoading(false);
+ }
+
+ function handleCancel() {
+ setContinueUrl(null);
+ }
+
+ return (
+
+ {continueUrl ? (
+
+
+ Please go to Vercel Marketplace to complete the transfer.
+
+
+
+ ) : (
+
+ {loading ? "Loading..." : "Transfer from Vercel"}
+
+ )}
+
+ );
+}
diff --git a/app/dashboard/resources/[resourceId]/actions.ts b/app/dashboard/resources/[resourceId]/actions.ts
index 679bf83..7d84ad9 100644
--- a/app/dashboard/resources/[resourceId]/actions.ts
+++ b/app/dashboard/resources/[resourceId]/actions.ts
@@ -9,7 +9,7 @@ import {
updateResource,
updateResourceNotification,
} from "@/lib/partner";
-import { Notification, Resource } from "@/lib/vercel/schemas";
+import type { Notification, Resource } from "@/lib/vercel/schemas";
import { dispatchEvent, updateSecrets } from "@/lib/vercel/marketplace-api";
import { getSession } from "../../auth";
import { revalidatePath } from "next/cache";
diff --git a/app/v1/installations/[installationId]/transfers/from-marketplace/route.ts b/app/v1/installations/[installationId]/transfers/from-marketplace/route.ts
new file mode 100644
index 0000000..c703855
--- /dev/null
+++ b/app/v1/installations/[installationId]/transfers/from-marketplace/route.ts
@@ -0,0 +1,24 @@
+import { installIntegration } from "@/lib/partner";
+import { readRequestBodyWithSchema } from "@/lib/utils";
+import { withAuth } from "@/lib/vercel/auth";
+import { transferInstallationFromMarketplaceRequestSchema } from "@/lib/vercel/schemas";
+
+export const POST = withAuth(async (claims, request) => {
+ const requestBody = await readRequestBodyWithSchema(
+ request,
+ transferInstallationFromMarketplaceRequestSchema
+ );
+
+ if (!requestBody.success) {
+ return new Response(null, { status: 400 });
+ }
+
+ await installIntegration(claims.installation_id, {
+ type: "external",
+ scopes: requestBody.data.scopes,
+ credentials: requestBody.data.credentials,
+ acceptedPolicies: {},
+ });
+
+ return Response.json({});
+});
diff --git a/app/v1/installations/[installationId]/transfers/to-marketplace/route.ts b/app/v1/installations/[installationId]/transfers/to-marketplace/route.ts
new file mode 100644
index 0000000..e26b071
--- /dev/null
+++ b/app/v1/installations/[installationId]/transfers/to-marketplace/route.ts
@@ -0,0 +1,22 @@
+import { installIntegration } from "@/lib/partner";
+import { readRequestBodyWithSchema } from "@/lib/utils";
+import { withAuth } from "@/lib/vercel/auth";
+import { TransferInstallationToMarketplaceRequestSchema } from "@/lib/vercel/schemas";
+
+export const POST = withAuth(async (claims, request) => {
+ const requestBody = await readRequestBodyWithSchema(
+ request,
+ TransferInstallationToMarketplaceRequestSchema
+ );
+
+ if (!requestBody.success) {
+ return new Response(null, { status: 400 });
+ }
+
+ await installIntegration(claims.installation_id, {
+ type: "marketplace",
+ ...requestBody.data,
+ });
+
+ return Response.json({});
+});
diff --git a/lib/partner/index.ts b/lib/partner/index.ts
index ff58c48..373eb60 100644
--- a/lib/partner/index.ts
+++ b/lib/partner/index.ts
@@ -1,5 +1,5 @@
import { nanoid } from "nanoid";
-import {
+import type {
BillingPlan,
GetBillingPlansResponse,
GetResourceResponse,
@@ -24,7 +24,7 @@ import {
importResource as importResourceToVercelApi,
} from "../vercel/marketplace-api";
-const billingPlans: BillingPlan[] = [
+export const billingPlans: BillingPlan[] = [
{
id: "default",
scope: "resource",
@@ -125,7 +125,11 @@ export async function uninstallInstallation(
// Installation is finalized immediately if it's on a free plan.
const billingPlan = billingPlanMap.get(installation.billingPlanId);
- return { finalized: billingPlan?.paymentMethodRequired === false };
+ return {
+ finalized:
+ billingPlan?.paymentMethodRequired === false ||
+ process.env.FORCE_FINALIZE_INSTALLATION === "true",
+ };
}
export async function listInstallations(): Promise {
@@ -342,7 +346,7 @@ export async function provisionPurchase(
const balances: Record = {};
for (const item of invoice.items ?? []) {
- const amountInCents = Math.floor(parseFloat(item.total) * 100);
+ const amountInCents = Math.floor(Number.parseFloat(item.total) * 100);
if (item.resourceId) {
const balance = await addResourceBalanceInternal(
installationId,
diff --git a/lib/vercel/marketplace-api.ts b/lib/vercel/marketplace-api.ts
index bfcc188..fd534d5 100644
--- a/lib/vercel/marketplace-api.ts
+++ b/lib/vercel/marketplace-api.ts
@@ -1,9 +1,10 @@
import { getInstallation, getResource } from "../partner";
import { env } from "../env";
import { z } from "zod";
-import {
+import type {
Balance,
BillingData,
+ BillingPlan,
CreateInvoiceRequest,
DeploymentActionOutcome,
ImportResourceRequest,
@@ -11,6 +12,10 @@ import {
Invoice,
InvoiceDiscount,
RefundInvoiceRequest,
+ RequestTransferFromMarketplace,
+ RequestTransferFromMarketplaceResponse,
+ RequestTransferToMarketplace,
+ RequestTransferToMarketplaceResponse,
SubmitPrepaymentBalanceRequest,
UpdateDeploymentActionRequest,
} from "./schemas";
@@ -159,20 +164,26 @@ export async function submitInvoice(
let items = billingData.billing.filter((item) => Boolean(item.resourceId));
if (maxAmount !== undefined) {
- const total = items.reduce((acc, item) => acc + parseFloat(item.total), 0);
+ const total = items.reduce(
+ (acc, item) => acc + Number.parseFloat(item.total),
+ 0
+ );
if (total > maxAmount) {
const ratio = maxAmount / total;
items = items.map((item) => ({
...item,
quantity: item.quantity * ratio,
- total: (parseFloat(item.total) * ratio).toFixed(2),
+ total: (Number.parseFloat(item.total) * ratio).toFixed(2),
}));
}
}
const discounts: InvoiceDiscount[] = [];
if (opts?.discountPercent !== undefined && opts.discountPercent > 0) {
- const total = items.reduce((acc, item) => acc + parseFloat(item.total), 0);
+ const total = items.reduce(
+ (acc, item) => acc + Number.parseFloat(item.total),
+ 0
+ );
if (total > 0) {
const discount = total * opts.discountPercent;
discounts.push({
@@ -292,3 +303,43 @@ export async function getDeployment(
}
);
}
+
+export async function requestTransferToMarketplace(
+ installationId: string,
+ transferId: string,
+ requester: string,
+ billingPlan: BillingPlan
+): Promise {
+ const result = (await fetchVercelApi(
+ `/v1/installations/${installationId}/transfers/to-marketplace`,
+ {
+ installationId,
+ method: "POST",
+ data: {
+ transferId,
+ requester: { name: requester },
+ billingPlan,
+ } satisfies RequestTransferToMarketplace,
+ }
+ )) as RequestTransferToMarketplaceResponse;
+ return result;
+}
+
+export async function requestTransferFromMarketplace(
+ installationId: string,
+ transferId: string,
+ requester: string
+): Promise {
+ const result = (await fetchVercelApi(
+ `/v1/installations/${installationId}/transfers/from-marketplace`,
+ {
+ installationId,
+ method: "POST",
+ data: {
+ transferId,
+ requester: { name: requester },
+ } satisfies RequestTransferFromMarketplace,
+ }
+ )) as RequestTransferFromMarketplaceResponse;
+ return result;
+}
diff --git a/lib/vercel/schemas.ts b/lib/vercel/schemas.ts
index b9bb90b..4a46099 100644
--- a/lib/vercel/schemas.ts
+++ b/lib/vercel/schemas.ts
@@ -646,10 +646,11 @@ const deploymentIntegrationActionStartEventSchema =
payload: deploymentWebhookPayloadEventSchema.extend({
installationId: z.string(),
action: z.string(),
- resourceId: z.string(), configuration: z.object({
+ resourceId: z.string(),
+ configuration: z.object({
id: z.string(),
}),
- })
+ }),
});
const deploymentEvent = (eventType: T) => {
@@ -657,7 +658,7 @@ const deploymentEvent = (eventType: T) => {
type: z.literal(eventType),
payload: deploymentWebhookPayloadEventSchema,
});
-}
+};
export type WebhookEvent = z.infer;
export const webhookEventSchema = z.discriminatedUnion("type", [
@@ -681,3 +682,97 @@ export const unknownWebhookEventSchema = webhookEventBaseSchema.extend({
payload: z.unknown(),
unknown: z.boolean().optional().default(true),
});
+
+// Transfer to/from Marketplacce
+
+export type RequestTransferToMarketplace = z.infer<
+ typeof requestTransferToMarketplaceSchema
+>;
+export const requestTransferToMarketplaceSchema = z.object({
+ transferId: z.string().min(1),
+ requester: z.object({
+ name: z.string().min(1),
+ }),
+ billingPlan: billingPlanSchema,
+});
+
+export type RequestTransferToMarketplaceResponse = z.infer<
+ typeof requestTransferToMarketplaceResponseSchema
+>;
+export const requestTransferToMarketplaceResponseSchema = z.object({
+ continueUrl: z.string().url(),
+});
+
+export type TransferInstallationToMarketplaceRequest = z.infer<
+ typeof TransferInstallationToMarketplaceRequestSchema
+>;
+
+export const TransferInstallationToMarketplaceRequestSchema = z.object({
+ transferId: z
+ .string()
+ .describe('Provided in the "request-transfer-to-marketplace".'),
+ billingPlanId: z
+ .string()
+ .optional()
+ .describe(
+ 'Transfer billing plan, if one was provided in the "request-transfer-to-marketplace".'
+ ),
+ metadata: metadataSchema
+ .optional()
+ .describe(
+ 'Installation-level metadata, if one was provided in the "request-transfer-to-marketplace".'
+ ),
+ scopes: z
+ .array(z.string().min(1))
+ .describe("Scopes for the new installation post transfer."),
+ acceptedPolicies: z.record(datetimeSchema),
+ credentials: z.object({
+ access_token: z.string().min(1),
+ token_type: z.string().min(1),
+ }),
+ account: z
+ .object({
+ name: z.string().optional(),
+ url: z.string().url(),
+ contact: z
+ .object({
+ email: z.string().email(),
+ name: z.string().optional(),
+ })
+ .nullable()
+ .optional(),
+ })
+ .describe(
+ "The account information for this installation. Use Get Account Info API to re-fetch this data post transfer."
+ ),
+});
+
+export type RequestTransferFromMarketplace = z.infer<
+ typeof requestTransferFromMarketplaceSchema
+>;
+export const requestTransferFromMarketplaceSchema = z.object({
+ transferId: z.string().min(1),
+ requester: z.object({
+ name: z.string().min(1),
+ }),
+});
+
+export type RequestTransferFromMarketplaceResponse = z.infer<
+ typeof requestTransferToMarketplaceResponseSchema
+>;
+export const requestTransferFromMarketplaceResponseSchema = z.object({
+ continueUrl: z.string().url(),
+});
+
+export type TransferInstallationFromMarketplaceRequest = z.infer<
+ typeof transferInstallationFromMarketplaceRequestSchema
+>;
+
+export const transferInstallationFromMarketplaceRequestSchema = z.object({
+ transferId: z.string(),
+ scopes: z.array(z.string().min(1)),
+ credentials: z.object({
+ access_token: z.string().min(1),
+ token_type: z.string().min(1),
+ }),
+});