Custom Code Workflow Actions Explained:

Automated Deal Renewals in HubSpot Workflows

Automatically create renewal deals, clone recurring line items, and generate quotes—all in one workflow action.

Deal renewal workflow illustration

What This Workflow Does

This combined renewal workflow handles the entire renewal process in a single custom code action:

  1. Validates recurring line items – Only creates renewals for deals with recurring billing
  2. Creates a renewal deal – With calculated close date and inherited properties
  3. Associates company and contacts – Carries over relationships from the original deal
  4. Clones only recurring line items – Copies only recurring items (configurable)
  5. Generates a quote – Creates a CPQ or legacy quote with all associations

This tutorial assumes you've read the Kickbox workflow tutorial and understand the basics of custom code actions.

Before You Start

You'll need:

  • Operations Hub Professional or Enterprise – Required for custom code actions
  • A deal-based workflow – Triggered when deals meet your renewal criteria
  • A renewal pipeline + stage set up
  • A quote template – Either CPQ or legacy template ID

Optional Custom Properties

The workflow supports these optional deal properties. If they don't exist in your portal, set the corresponding config variable to null:

  • autorenew – Boolean to track auto-renewal status
  • parent – String property to store the original deal ID

Note: The workflow automatically handles missing properties—if a property doesn't exist, it retries without it.

Configuration Variables

At the top of the code, configure these constants to match your HubSpot setup:

renewal-workflow.js
// ----- CONFIG ----- // Pipeline + stage for the renewal deal. const PIPELINE_ID = 857600831; // renewal pipeline const DEALSTAGE_ID = 1279296340; // renewal stage // Quote template to use (CPQ or legacy template id). const QUOTE_TEMPLATE_ID = 488097695323; // If no term can be derived from line items, default to this many months. const FALLBACK_AUTORENEW_DURATION_MONTHS = 12; // Optional deal properties: // - If the property does not exist in the portal, set it to null. // - This prevents 400 errors for missing custom fields. const DEAL_AUTORENEW_PROP = "autorenew"; const DEAL_PARENT_ID_PROP = "parent"; // string property storing parent deal id // Quote mode toggle: // - "CPQ_QUOTE" creates a CPQ quote (adds signer association) // - "LEGACY" creates a legacy quote (no signer association) // This does NOT validate the template type; it assumes your template matches the mode. const QUOTE_TEMPLATE_TYPE = "CPQ_QUOTE"; // change to "LEGACY" for legacy quotes // Line item inclusion rules: // - When false, we skip one-time line items and only clone recurring items. const INCLUDE_ONCE_OFF_PAYMENTS = false; // set true to include one-time line items

Step 0: Check Recurring Line Items

Before creating anything, the workflow validates that the deal has recurring line items. If not, it exits early—no renewal needed.

renewal-workflow.js
// ----- STEP 0: CHECK RECURRING LINE ITEMS + CALCULATE RENEWAL CLOSE DATE ----- // We only create a renewal if there is at least one recurring line item. let recurringLineItemData = []; let baseRenewalDate; let longestTermMonths = 0; let totalRecurringAmount = 0; try { const lineItemIds = await fetchAssociations(hubspotClient, "deals", hs_object_id, "line_items"); logCompact("lineItemsFound", { count: lineItemIds.length }); const lineItemDetails = await Promise.all( lineItemIds.map(async (lineItemId) => { const lineItemResponse = await hubspotClient.crm.lineItems.basicApi.getById(lineItemId, [ "name", "quantity", "price", "recurringbillingfrequency", ]); return { id: lineItemId, properties: lineItemResponse.properties }; }) ); recurringLineItemData = lineItemDetails.filter(({ properties }) => { return properties.recurringbillingfrequency !== null && properties.recurringbillingfrequency !== ""; }); if (recurringLineItemData.length === 0) { logCompact("noRecurringLineItems", {}); return callback({ outputFields: { skipped: "no_recurring_line_items" } }); } // Calculate total amount based on recurring line items only. totalRecurringAmount = recurringLineItemData.reduce((sum, { properties }) => { const price = Number(properties.price); const quantity = Number(properties.quantity); const lineTotal = (Number.isFinite(price) ? price : 0) * (Number.isFinite(quantity) ? quantity : 1); return sum + lineTotal; }, 0); // ... renewal date calculation continues } catch (error) { logError("precheckLineItems", error); return callback({ error: "Failed precheck for recurring line items or renewal date" }); }

Key Points

  • Early exit pattern – Returns immediately if no recurring items found
  • Amount calculation – Sums only recurring line items for the renewal deal amount
  • Renewal date logic – Uses the longest billing term to calculate when the renewal is due

The billing frequency mapping converts HubSpot's internal values to months:

renewal-workflow.js
const BILLING_FREQUENCY_TO_MONTHS = { weekly: 0.25, biweekly: 0.5, monthly: 1, quarterly: 3, per_six_months: 6, annually: 12, per_two_years: 24, per_three_years: 36, per_four_years: 48, per_five_years: 60, };

Step 1: Create Renewal Deal

Creates a new deal in the renewal pipeline with properties from the original deal. The code includes automatic retry logic that removes optional custom properties if they don't exist in your portal:

renewal-workflow.js
// ----- STEP 1: CREATE RENEWAL DEAL ----- // Build a minimal set of properties plus any optional custom fields. let newDealId; try { const newAmount = Number.isFinite(totalRecurringAmount) ? totalRecurringAmount : null; const newDealName = `${dealname.split(" - ", 1)} - Renewal`; const properties = { ...(newAmount !== null ? { amount: newAmount } : {}), ...(DEAL_AUTORENEW_PROP && autorenew !== null ? { [DEAL_AUTORENEW_PROP]: autorenew } : {}), ...(hs_all_assigned_business_unit_ids ? { hs_all_assigned_business_unit_ids } : {}), // Use the computed renewal date as the close date of the renewal deal. closedate: baseRenewalDate.valueOf(), deal_currency_code, description, dealname: newDealName, hs_analytics_source, hubspot_owner_id, success_owner_id, contract_owner_id, pipeline: PIPELINE_ID, dealstage: DEALSTAGE_ID, ...(DEAL_PARENT_ID_PROP ? { [DEAL_PARENT_ID_PROP]: String(hs_object_id) } : {}), }; try { const dealCreateResponse = await hubspotClient.crm.deals.basicApi.create({ properties }); newDealId = dealCreateResponse.id; logCompact("dealCreated", { newDealId }); } catch (error) { // Retry logic: remove optional properties if they don't exist in the portal const missingProps = extractMissingProperties(error); const removableProps = [ ...(DEAL_AUTORENEW_PROP ? [DEAL_AUTORENEW_PROP] : []), ...(DEAL_PARENT_ID_PROP ? [DEAL_PARENT_ID_PROP] : []), ]; const toRemove = removableProps.filter((prop) => missingProps.includes(prop)); if (toRemove.length > 0) { const retryProperties = { ...properties }; toRemove.forEach((prop) => delete retryProperties[prop]); const dealCreateResponse = await hubspotClient.crm.deals.basicApi.create({ properties: retryProperties, }); newDealId = dealCreateResponse.id; logCompact("dealCreated", { newDealId, removedProps: toRemove }); } else { throw error; } } } catch (error) { logError("createDeal", error); return callback({ error: "Failed to create renewal deal" }); }

Step 1b: Associate Company and Contacts

Carries over the primary company and all contacts from the original deal:

renewal-workflow.js
// ----- STEP 1b: ASSOCIATE COMPANY + CONTACTS ----- // We attempt to carry over the primary company and all contacts. try { const [associatedCompaniesRaw, contactIds] = await Promise.all([ hubspotClient.crm.associations.v4.basicApi.getPage( "deals", hs_object_id, "companies", undefined, 100 ), fetchAssociations(hubspotClient, "deals", hs_object_id, "contacts"), ]); // Try to find a primary company association when available. const primaryCompany = associatedCompaniesRaw.results.find(({ associationTypes }) => associationTypes?.some(({ label }) => label === "Primary") ); const primaryCompanyId = primaryCompany?.toObjectId; if (primaryCompanyId) { await hubspotClient.crm.associations.v4.basicApi.create( "deals", newDealId, "companies", primaryCompanyId, [{ associationCategory: "HUBSPOT_DEFINED", associationTypeId: 5 }] // deal -> company (primary) ); logCompact("primaryCompanyAssociated", { primaryCompanyId }); } else { logCompact("primaryCompanyMissing", {}); } // Contacts: associate all contacts from the original deal to the renewal deal. if (contactIds.length > 0) { const inputs = contactIds.map((id) => ({ _from: { id: newDealId }, to: { id }, type: "deal_to_contact", })); await hubspotClient.crm.associations.batchApi.create("Deals", "Contacts", { inputs }); logCompact("contactsAssociated", { count: contactIds.length }); } else { logCompact("contactsMissing", {}); } } catch (error) { logError("associateCompanyContacts", error); return callback({ error: "Failed to associate company/contacts" }); }

Step 2: Clone Line Items

Clones recurring line items to the new deal. By default, one-time items are skipped:

renewal-workflow.js
// ----- STEP 2: CLONE RECURRING LINE ITEMS ----- // We clone only recurring line items unless INCLUDE_ONCE_OFF_PAYMENTS is true. try { const inputs = recurringLineItemData.map(({ properties }) => { // Remove system properties that shouldn't be copied ["createdate", "hs_lastmodifieddate", "hs_object_id"].forEach( (prop) => delete properties[prop] ); const includeLineItem = INCLUDE_ONCE_OFF_PAYMENTS || properties.recurringbillingfrequency !== null; const associations = includeLineItem ? [{ to: { id: newDealId }, types: [{ associationCategory: "HUBSPOT_DEFINED", associationTypeId: 20 }], }] : []; return { properties, associations }; }); // Only send line items that are actually included const filteredInputs = inputs.filter((input) => input.associations.length > 0); if (filteredInputs.length > 0) { await hubspotClient.crm.lineItems.batchApi.create({ inputs: filteredInputs }); logCompact("lineItemsCloned", { count: filteredInputs.length }); } else { logCompact("lineItemsSkipped", { reason: "no recurring line items" }); } } catch (error) { logError("cloneLineItems", error); return callback({ error: "Failed to clone line items" }); }

Step 3: Create Quote

Creates a quote and associates it with the deal, line items, contacts, and company. For CPQ quotes, it also sets a signer:

renewal-workflow.js
// ----- STEP 3: CREATE QUOTE ----- // Create a quote and associate it with the deal, line items, contacts, and company. let quoteId; try { const [associatedLineItems, associatedContacts, associatedCompanies] = await Promise.all([ fetchAssociations(hubspotClient, "deals", newDealId, "line_items"), fetchAssociations(hubspotClient, "deals", newDealId, "contacts"), fetchAssociations(hubspotClient, "deals", newDealId, "companies"), ]); // Build all association payloads const templateAssociation = createAssociations([QUOTE_TEMPLATE_ID], "HUBSPOT_DEFINED", 286); const dealAssociations = createAssociations([newDealId], "HUBSPOT_DEFINED", 64); const lineItemAssociations = associatedLineItems.length > 0 ? createAssociations(associatedLineItems, "HUBSPOT_DEFINED", 67) : []; const contactAssociations = associatedContacts.length > 0 ? createAssociations(associatedContacts, "HUBSPOT_DEFINED", 69) : []; const companyAssociations = associatedCompanies.length > 0 ? createAssociations([associatedCompanies[0]], "HUBSPOT_DEFINED", 71) : []; // CPQ requires a signer association (first contact). const signerAssociations = QUOTE_TEMPLATE_TYPE === "CPQ_QUOTE" && associatedContacts.length > 0 ? createAssociations([associatedContacts[0]], "HUBSPOT_DEFINED", 702) : []; const quoteProperties = { hs_title: `Renewal Quote - ${dealname}`, hs_expiration_date: baseRenewalDate.valueOf(), hubspot_owner_id, hs_template: null, hs_template_type: QUOTE_TEMPLATE_TYPE === "CPQ_QUOTE" ? "CPQ_QUOTE" : "CUSTOMIZABLE_QUOTE_TEMPLATE", hs_currency: deal_currency_code, hs_sender_company_name: "Reus", hs_sender_firstname: "Willem", hs_sender_lastname: "Reus", hs_sender_email: "wreus@hubspot.com", }; const quoteResponse = await hubspotClient.crm.quotes.basicApi.create({ properties: quoteProperties, associations: [ ...templateAssociation, ...dealAssociations, ...lineItemAssociations, ...contactAssociations, ...companyAssociations, ...signerAssociations, ], }); quoteId = quoteResponse.id; logCompact("quoteCreated", { quoteId, quoteType: QUOTE_TEMPLATE_TYPE }); // Legacy flow sets DRAFT to make it editable; also safe for CPQ. await hubspotClient.crm.quotes.basicApi.update(quoteId, { properties: { hs_status: "DRAFT" }, }); } catch (error) { logError("createQuote", error); return callback({ error: "Failed to create quote" }); }

Association Type IDs

The quote needs several associations. Here are the type IDs used:

AssociationType ID
Quote → Template286
Quote → Deal64
Quote → Line Item67
Quote → Contact69
Quote → Company71
Quote → Signer (CPQ)702

Complete Code

Here's the full workflow code. Copy this into your custom code action:

renewal-workflow.js
// Combined renewal workflow (Node.js 20) // This single workflow does: // 1) Create a renewal deal // 2) Clone recurring line items to the new deal // 3) Create a quote (CPQ or legacy) // // This file includes extensive explanation and compact logging // to fit HubSpot's limited console output. const hubspot = require('@hubspot/api-client'); // ----- CONFIG ----- // Pipeline + stage for the renewal deal. const PIPELINE_ID = 857600831; // renewal pipeline const DEALSTAGE_ID = 1279296340; // renewal stage // Quote template to use (CPQ or legacy template id). const QUOTE_TEMPLATE_ID = 488097695323; // If no term can be derived from line items, default to this many months. const FALLBACK_AUTORENEW_DURATION_MONTHS = 12; // Optional deal properties: // - If the property does not exist in the portal, set it to null. // - This prevents 400 errors for missing custom fields. const DEAL_AUTORENEW_PROP = "autorenew"; const DEAL_PARENT_ID_PROP = "parent"; // string property storing parent deal id // Recurring billing frequency to months mapping. // HubSpot line items store the "internal enum" values (right-side values below). // We convert those to months so we can compute the renewal date. const BILLING_FREQUENCY_TO_MONTHS = { weekly: 0.25, biweekly: 0.5, monthly: 1, quarterly: 3, per_six_months: 6, annually: 12, per_two_years: 24, per_three_years: 36, per_four_years: 48, per_five_years: 60, }; // Quote mode toggle: // - "CPQ_QUOTE" creates a CPQ quote (adds signer association) // - "LEGACY" creates a legacy quote (no signer association) // This does NOT validate the template type; it assumes your template matches the mode. const QUOTE_TEMPLATE_TYPE = "CPQ_QUOTE"; // change to "LEGACY" for legacy quotes // Line item inclusion rules: // - When false, we skip one-time line items and only clone recurring items. const INCLUDE_ONCE_OFF_PAYMENTS = false; // set true to include one-time line items // Logging: HubSpot console has a short character limit, so keep messages compact. const LOG_PREFIX = "renewal-workflow"; const MAX_LOG_CHARS = 700; // Compact logger to avoid HubSpot console truncation. function logCompact(label, payload) { const message = `${LOG_PREFIX} | ${label} | ${payload ? JSON.stringify(payload) : ""}`; console.log(message.length > MAX_LOG_CHARS ? message.slice(0, MAX_LOG_CHARS) + "..." : message); } // Compact error logger (message + status only to keep output small). function logError(label, err) { const payload = { message: err?.message || "unknown error", statusCode: err?.response?.statusCode, }; logCompact(`ERROR:${label}`, payload); } // Helper: best-effort extraction of missing property names from HubSpot errors. function extractMissingProperties(err) { const body = err?.response?.body; const errors = Array.isArray(body?.errors) ? body.errors : []; const namesFromErrors = errors.map((item) => item?.name).filter(Boolean); const message = body?.message || ""; const namesFromMessage = []; if (typeof message === "string" && message) { const regex = /"name":"([^"]+)"/g; let match; while ((match = regex.exec(message)) !== null) { namesFromMessage.push(match[1]); } } return [...new Set([...namesFromErrors, ...namesFromMessage])]; } // Helper: safe date calculations (Node 20 strictness). // Returns a Date or null if the input cannot be parsed. function addMonthsToDate(dateInput, monthsToAdd) { const date = new Date(Number(dateInput)); if (Number.isNaN(date.getTime())) return null; date.setMonth(date.getMonth() + Number(monthsToAdd || 0)); return date; } // Helper: build association payloads for the CRM API. function createAssociations(associatedItems, associationCategory, associationTypeId) { return associatedItems.map((item) => ({ to: { id: item }, types: [{ associationCategory, associationTypeId }], })); } // Helper: fetch associations (v4). // We use v4 because it is available in Node 20 SDKs. async function fetchAssociations(hubspotClient, fromObjectType, fromObjectId, toObjectType) { try { const response = await hubspotClient.crm.associations.v4.basicApi.getPage( fromObjectType, fromObjectId, toObjectType, undefined, 100 ); return response.results.map(({ toObjectId }) => String(toObjectId)); } catch (error) { logError(`fetchAssociations:${fromObjectType}->${toObjectType}`, error); return []; } } // ----- MAIN ENTRY ----- // HubSpot Custom Code Actions call this function. exports.main = async (event, callback) => { const hubspotClient = new hubspot.Client({ accessToken: process.env.PrivateAppSecret, }); // ----- INPUTS ----- // These are mapped in the workflow action "Input fields". const input = event.inputFields; const hs_object_id = input["hs_object_id"]; // original deal ID const autorenew_raw = input["autorenew"]; const autorenew = autorenew_raw === undefined || autorenew_raw === null || autorenew_raw === "" ? null : Boolean(autorenew_raw); const hubspot_owner_id = Number(input["hubspot_owner_id"]); const hs_all_assigned_business_unit_ids = input["hs_all_assigned_business_unit_ids"]; const deal_currency_code = input["deal_currency_code"]; const description = input["description"]; const dealname = input["dealname"]; const hs_analytics_source = input["hs_analytics_source"]; const renewal_date = Number(input["renewal_date"]); const closedate = Number(input["closedate"]); const hs_mrr = Number(input["hs_mrr"]); const success_owner_id = input["success_owner_id"]; const contract_owner_id = input["contract_owner_id"]; logCompact("start", { dealId: hs_object_id, quoteType: QUOTE_TEMPLATE_TYPE, }); // ----- STEP 0: CHECK RECURRING LINE ITEMS + CALCULATE RENEWAL CLOSE DATE ----- // We only create a renewal if there is at least one recurring line item. let recurringLineItemData = []; let baseRenewalDate; let longestTermMonths = 0; let totalRecurringAmount = 0; try { const lineItemIds = await fetchAssociations(hubspotClient, "deals", hs_object_id, "line_items"); logCompact("lineItemsFound", { count: lineItemIds.length }); const lineItemDetails = await Promise.all( lineItemIds.map(async (lineItemId) => { const lineItemResponse = await hubspotClient.crm.lineItems.basicApi.getById(lineItemId, [ "name", "quantity", "price", "recurringbillingfrequency", ]); return { id: lineItemId, properties: lineItemResponse.properties, }; }) ); recurringLineItemData = lineItemDetails.filter(({ properties }) => { return properties.recurringbillingfrequency !== null && properties.recurringbillingfrequency !== ""; }); if (recurringLineItemData.length === 0) { logCompact("noRecurringLineItems", {}); return callback({ outputFields: { skipped: "no_recurring_line_items", }, }); } // Calculate total amount based on recurring line items only. // This keeps the renewal deal amount aligned with what we will clone. totalRecurringAmount = recurringLineItemData.reduce((sum, { properties }) => { const price = Number(properties.price); const quantity = Number(properties.quantity); const lineTotal = (Number.isFinite(price) ? price : 0) * (Number.isFinite(quantity) ? quantity : 1); return sum + lineTotal; }, 0); // If renewal_date is provided, use it. Otherwise use closedate + max term months. if (!Number.isNaN(renewal_date) && renewal_date > 0) { baseRenewalDate = new Date(renewal_date); logCompact("renewalDateFromInput", { renewal_date }); } else { // Pick the longest recurring term so the renewal date is not too early. const maxTermMonths = recurringLineItemData.reduce((max, { properties }) => { const frequencyRaw = properties.recurringbillingfrequency || ""; const frequency = String(frequencyRaw).toLowerCase(); const termMonths = BILLING_FREQUENCY_TO_MONTHS[frequency]; return Number.isFinite(termMonths) && termMonths > max ? termMonths : max; }, 0); longestTermMonths = maxTermMonths; // If we cannot find a term, fall back to the default duration. const usedTermMonths = Number.isFinite(maxTermMonths) && maxTermMonths > 0 ? maxTermMonths : FALLBACK_AUTORENEW_DURATION_MONTHS; const computedRenewalDate = addMonthsToDate(closedate, usedTermMonths); if (!computedRenewalDate) { throw new Error("Missing/invalid closedate and renewal_date"); } baseRenewalDate = computedRenewalDate; logCompact("renewalDateComputed", { closedate, usedTermMonths }); } } catch (error) { logError("precheckLineItems", error); return callback({ error: "Failed precheck for recurring line items or renewal date" }); } // ----- STEP 1: CREATE RENEWAL DEAL ----- // Build a minimal set of properties plus any optional custom fields. let newDealId; try { const newAmount = Number.isFinite(totalRecurringAmount) ? totalRecurringAmount : null; const newDealName = `${dealname.split(" - ", 1)} - Renewal`; const properties = { ...(newAmount !== null ? { amount: newAmount } : {}), ...(DEAL_AUTORENEW_PROP && autorenew !== null ? { [DEAL_AUTORENEW_PROP]: autorenew } : {}), ...(hs_all_assigned_business_unit_ids ? { hs_all_assigned_business_unit_ids } : {}), // Use the computed renewal date as the close date of the renewal deal. closedate: baseRenewalDate.valueOf(), deal_currency_code, description, dealname: newDealName, hs_analytics_source, hubspot_owner_id, success_owner_id, contract_owner_id, pipeline: PIPELINE_ID, dealstage: DEALSTAGE_ID, ...(DEAL_PARENT_ID_PROP ? { [DEAL_PARENT_ID_PROP]: String(hs_object_id) } : {}), }; try { const dealCreateResponse = await hubspotClient.crm.deals.basicApi.create({ properties }); newDealId = dealCreateResponse.id; logCompact("dealCreated", { newDealId }); } catch (error) { const missingProps = extractMissingProperties(error); const removableProps = [ ...(DEAL_AUTORENEW_PROP ? [DEAL_AUTORENEW_PROP] : []), ...(DEAL_PARENT_ID_PROP ? [DEAL_PARENT_ID_PROP] : []), ]; const toRemove = removableProps.filter((prop) => missingProps.includes(prop)); if (toRemove.length > 0) { const retryProperties = { ...properties }; toRemove.forEach((prop) => delete retryProperties[prop]); const dealCreateResponse = await hubspotClient.crm.deals.basicApi.create({ properties: retryProperties, }); newDealId = dealCreateResponse.id; logCompact("dealCreated", { newDealId, removedProps: toRemove }); } else { throw error; } } } catch (error) { logError("createDeal", error); return callback({ error: "Failed to create renewal deal" }); } // ----- STEP 1b: ASSOCIATE COMPANY + CONTACTS ----- // We attempt to carry over the primary company and all contacts. try { // Fetch company and contact associations using v4 (works in Node 20 SDKs) const [associatedCompaniesRaw, contactIds] = await Promise.all([ hubspotClient.crm.associations.v4.basicApi.getPage("deals", hs_object_id, "companies", undefined, 100), fetchAssociations(hubspotClient, "deals", hs_object_id, "contacts"), ]); // Try to find a primary company association when available. const primaryCompany = associatedCompaniesRaw.results.find(({ associationTypes }) => associationTypes?.some(({ label }) => label === "Primary") ); const primaryCompanyId = primaryCompany?.toObjectId; if (primaryCompanyId) { await hubspotClient.crm.associations.v4.basicApi.create( "deals", newDealId, "companies", primaryCompanyId, [ { associationCategory: "HUBSPOT_DEFINED", associationTypeId: 5, // deal -> company (primary) }, ] ); logCompact("primaryCompanyAssociated", { primaryCompanyId }); } else { logCompact("primaryCompanyMissing", {}); } // Contacts: associate all contacts from the original deal to the renewal deal. if (contactIds.length > 0) { const inputs = contactIds.map((id) => ({ _from: { id: newDealId }, to: { id }, type: "deal_to_contact", })); await hubspotClient.crm.associations.batchApi.create("Deals", "Contacts", { inputs }); logCompact("contactsAssociated", { count: contactIds.length }); } else { logCompact("contactsMissing", {}); } } catch (error) { logError("associateCompanyContacts", error); return callback({ error: "Failed to associate company/contacts" }); } // ----- STEP 2: CLONE RECURRING LINE ITEMS ----- // We clone only recurring line items unless INCLUDE_ONCE_OFF_PAYMENTS is true. try { const inputs = recurringLineItemData.map(({ properties }) => { ["createdate", "hs_lastmodifieddate", "hs_object_id"].forEach((prop) => delete properties[prop]); const includeLineItem = INCLUDE_ONCE_OFF_PAYMENTS || properties.recurringbillingfrequency !== null; const associations = includeLineItem ? [ { to: { id: newDealId }, types: [ { associationCategory: "HUBSPOT_DEFINED", associationTypeId: 20, // lineitems -> deals }, ], }, ] : []; return { properties, associations }; }); // Only send line items that are actually included const filteredInputs = inputs.filter((input) => input.associations.length > 0); if (filteredInputs.length > 0) { await hubspotClient.crm.lineItems.batchApi.create({ inputs: filteredInputs }); logCompact("lineItemsCloned", { count: filteredInputs.length }); } else { logCompact("lineItemsSkipped", { reason: "no recurring line items" }); } } catch (error) { logError("cloneLineItems", error); return callback({ error: "Failed to clone line items" }); } // ----- STEP 3: CREATE QUOTE ----- // Create a quote and associate it with the deal, line items, contacts, and company. let quoteId; try { const [associatedLineItems, associatedContacts, associatedCompanies] = await Promise.all([ fetchAssociations(hubspotClient, "deals", newDealId, "line_items"), fetchAssociations(hubspotClient, "deals", newDealId, "contacts"), fetchAssociations(hubspotClient, "deals", newDealId, "companies"), ]); const templateAssociation = createAssociations( [QUOTE_TEMPLATE_ID], "HUBSPOT_DEFINED", 286 ); const dealAssociations = createAssociations([newDealId], "HUBSPOT_DEFINED", 64); const lineItemAssociations = associatedLineItems.length > 0 ? createAssociations(associatedLineItems, "HUBSPOT_DEFINED", 67) : []; const contactAssociations = associatedContacts.length > 0 ? createAssociations(associatedContacts, "HUBSPOT_DEFINED", 69) : []; const companyAssociations = associatedCompanies.length > 0 ? createAssociations([associatedCompanies[0]], "HUBSPOT_DEFINED", 71) : []; // CPQ requires a signer association (first contact). const signerAssociations = QUOTE_TEMPLATE_TYPE === "CPQ_QUOTE" && associatedContacts.length > 0 ? createAssociations([associatedContacts[0]], "HUBSPOT_DEFINED", 702) : []; const quoteProperties = { hs_title: `Renewal Quote - ${dealname}`, hs_expiration_date: baseRenewalDate.valueOf(), hubspot_owner_id, hs_template: null, hs_template_type: QUOTE_TEMPLATE_TYPE === "CPQ_QUOTE" ? "CPQ_QUOTE" : "CUSTOMIZABLE_QUOTE_TEMPLATE", hs_currency: deal_currency_code, hs_sender_company_name: "Reus", hs_sender_firstname: "Willem", hs_sender_lastname: "Reus", hs_sender_email: "wreus@hubspot.com", }; const quoteResponse = await hubspotClient.crm.quotes.basicApi.create({ properties: quoteProperties, associations: [ ...templateAssociation, ...dealAssociations, ...lineItemAssociations, ...contactAssociations, ...companyAssociations, ...signerAssociations, ], }); quoteId = quoteResponse.id; logCompact("quoteCreated", { quoteId, quoteType: QUOTE_TEMPLATE_TYPE }); // Legacy flow sets DRAFT to make it editable; also safe for CPQ. await hubspotClient.crm.quotes.basicApi.update(quoteId, { properties: { hs_status: "DRAFT" }, }); } catch (error) { logError("createQuote", error); return callback({ error: "Failed to create quote" }); } // ----- OUTPUT ----- // These output fields can be used in later workflow steps. callback({ outputFields: { NewDealID: newDealId, quoteID: quoteId, }, }); };

Using the Output

The workflow outputs two values you can use in subsequent actions:

  • NewDealID – The ID of the created renewal deal
  • quoteID – The ID of the generated quote

Workflow Input Fields

Map these deal properties as input fields in your workflow action:

Input FieldPropertyRequired
hs_object_idDeal IDYes
dealnameDeal nameYes
closedateClose dateYes*
hubspot_owner_idDeal ownerYes
deal_currency_codeCurrencyYes
autorenewAuto-renew flagNo
renewal_dateRenewal dateNo*
descriptionDescriptionNo
hs_analytics_sourceOriginal sourceNo
hs_mrrMonthly recurring revenueNo
success_owner_idSuccess ownerNo
contract_owner_idContract ownerNo
hs_all_assigned_business_unit_idsBusiness unit IDsNo

*Either closedate or renewal_date is required to calculate the renewal deal's close date. If renewal_date is provided, it's used directly. Otherwise, the close date is calculated from closedate plus the longest billing term from recurring line items.