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}

+
+
+
+
+

ACME Corp

+
+
+ Configuration ID: {configurationId} +
+
ACME
+
+ +
+
+

Billing

+

+ Manage your billing information and payment methods +

+
+ +
+
+ + +
+
+ +
+
+

Payment Methods

+

+ Manage your payment methods for ACME services +

+
+ +
+
+
+
+
Visa ending in 4242
+
Expires 04/2025
+
+
+ +
+ + +
+
+ +
+
+

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. +

+
+ + Proceed 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. +

+
+ + Proceed to 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), + }), +});