← Files SugerARCHIVED FILE

SKILL.md

42.4 KB · Oct 2, 2026 · 00:17 UTC

↓ Download file

---
name: offer-mapping-gcp
description: "Generate the JavaScript script that builds a GCP Marketplace private offer (Standard, CPPO, or Replacement) from a Salesforce Opportunity / Quote / custom record. Covers all three GCP archetypes in one script — branch on $OfferType inside."
---

# Generate GCP Private Offer Mapping Script

You help the user write a JavaScript script that runs at offer-creation time and **builds the GCP Marketplace private offer body from a Salesforce source record**. The script handles all three GCP archetypes — Standard, CPPO, Replacement — by branching on `$OfferType` at runtime. (GCP uses "Replacement" as the renewal/amendment analog; AWS calls this "ABO".)

The script is the **primary mapping mechanism** for anything beyond simple scalar fields. Per-field "fillers" handle direct value mapping. The script is where the real work happens: querying Salesforce, building the `gcpPrivateOffer` body, computing duration / customer info / payment schedule, attaching contacts, and applying CPPO/Replacement-specific blocks.

---

## Runtime model — read this first

Same as the AWS / Azure skills. The script is **NOT a function**, just top-level statements that mutate `$target`. The runtime wraps it as `(() => { <your script> })()`. NEVER write `function parseOfferInput(input) { return o; }` — this wrapper is from a non-existent older API.

```js
// ✅ Correct shape
const record = $query("SELECT ... FROM Quote WHERE Id = '" + $source.Id + "' LIMIT 1");
$target.info = $target.info || {};
$target.info.gcpPrivateOffer = $target.info.gcpPrivateOffer || {};
$target.info.gcpDuration = 12;
```

### Available globals (cloud-agnostic)

| Global | What it is |
|---|---|
| `$source` | The SFDC record (`.Id` plus the fields the source-object schema returned) |
| `$target` | The offer being built. Mutate `$target.*` |
| `$OfferType` | `"Standard"`, `"CPPO"`, or `"Replacement"` for GCP. Branch on this for archetype-specific logic |
| `$Product` | Suger Product picked in dialog (optional) |
| `$Entitlement` | For Replacement offers — has `.id` of the prior entitlement being replaced |
| `$query(soql)` | Runs SOQL via Salesforce API; returns one record (or null) |
| `$createContact({name, emailAddress})` | Creates a Suger contact, returns `{ id, ... }` |
| `$marketplaceApi.{getOAuth2Token, oauth2Request, getProduct, downloadFile}` | Same as other skills |

Standard JS globals: `Date`, `JSON`, `Math`, `Array`, `RegExp`, `Number`, `String`, `console.log`. **No** `fetch`, `require`, modules, or `setTimeout`.

### Choosing the source object: Quote vs Opportunity vs custom

Same as AWS/Azure — pick based on how the customer's pricing data lives in Salesforce. For Quote sources, always include the primary-quote filter (`IsSyncing = true`, `Status = "Approved"`, custom `Primary__c`) in the SOQL `WHERE` clause.

If the dialog's source object is `Opportunity` but the customer's data lives on `Quote`, **stop and ask the user to change the dialog's "Source Object Type" first** — don't switch silently.

---

## Output shape — GCP-specific

GCP's offer body is split between a top-level `gcpPrivateOffer` (for the private offer record itself) and several flat fields under `info.*` for offer-creation parameters. Common paths:

### Top-level `$target.*`

| Path | Meaning |
|---|---|
| `$target.name` | Offer name (string). Strip non-alphanumeric: `name.replace(/[^a-zA-Z0-9_-]/g, "")` |
| `$target.productID` | Suger Product ID — usually pre-set by a filler |
| `$target.expireTime` | Date / ISO string. Mirror to `info.gcpPrivateOffer.expireTime` |
| `$target.contactIds` | Array of Suger contact IDs |
| `$target.metaInfo.isRenewalOffer` | true for Replacement / native-renewal flows |
| `$target.metaInfo.renewalOfferType` | `"GcpMarketplace"` when isRenewalOffer is true |

### `$target.info.*` (offer-creation parameters)

| Path | Meaning |
|---|---|
| `$target.info.eulaType` | `"ISV"` (default) or `"CUSTOM"` |
| `$target.info.gcpDuration` | Term length in months (integer 1–60). REQUIRED for GCP private offers |
| `$target.info.gcpCustomerInfo` | REQUIRED. Identifies the buyer. Use `{ billingAccountId: "billingAccounts/<id>" }` when the buyer's billing account ID is known and verified, OR `{ unverifiedBillingAccount: "<id>", organization, contact, email }` when the seller has only the customer's organization name and contact (Patterns C/D). Pattern B uses the contact-only form `{ organization, contact, email }` for non-CPPO. |
| `$target.info.gcpProviderInfo` | `{ ... }` — provider/seller info |
| `$target.info.gcpProviderInternalNote` | Seller-only note (not visible to buyer) |
| `$target.info.gcpProviderPublicNote` | Buyer-visible note. Defaults to offer name if omitted |
| `$target.info.gcpOfferDealType` | One of `OFFER_DEAL_TYPE_UNSPECIFIED` (new business), `CHANNEL_SHIFT`, `MIGRATION`, `NATIVE_RENEWAL` |
| `$target.info.gcpFeatures` | Array of `GcpMarketplaceProductFeatureValue` — feature overrides |
| `$target.info.gcpPlans` | Array — pricing plans referenced by this offer |
| `$target.info.gcpUsagePlanPriceModel` | Usage-plan price model (only for Usage plan, not Subscription) |
| `$target.info.gcpPaymentSchedule` | `"PREPAY"` or `"POSTPAY"` (deprecated soon; use `info.paymentSchedule`) |
| `$target.info.gcpSkuDiscounts` | Array of `{ metricId, discountPercent }` for POSTPAY offers |
| `$target.info.gcpSowAgreementDocument` | Optional — Statement of Work doc for professional services |
| `$target.info.startTime` | Date — future start. Omit / null for acceptance-start |
| `$target.info.paymentSchedule` | Cross-cloud `PREPAY`/`POSTPAY`. Prefer this over `gcpPaymentSchedule` |
| `$target.info.paymentInstallments` | Array of `{ chargeOn: ISOString, amount, skuDiscounts? }` for PREPAY |

### `$target.info.gcpPrivateOffer.*` (private offer body)

These mirror the GCP Cloud Billing API's privateOffer resource:

| Path | Meaning |
|---|---|
| `$target.info.gcpPrivateOffer.offerTitle` | Customer-facing offer title |
| `$target.info.gcpPrivateOffer.expireTime` | Date when the offer expires if not accepted |
| `$target.info.gcpPrivateOffer.offerSource` | `"OFFER"` (Standard) or `"RESOLD"` (CPPO via channel partner) |
| `$target.info.gcpPrivateOffer.providerPublicNote` | Buyer-visible note |
| `$target.info.gcpPrivateOffer.providerInternalNote` | Seller-only note |
| `$target.info.gcpPrivateOffer.policies` | `{ defaultRenewalPolicy, downgradePolicy, cancellationPolicy, purchaseApproval, offerDealType }` |
| `$target.info.gcpPrivateOffer.useLegacyPartnerEula` | Boolean — true forces partner-EULA flow |
| `$target.info.gcpPrivateOffer.replacementMetadata` | REQUIRED for Replacement offers — `{ replacedOfferId, replacedAgreement, ... }` |
| `$target.info.gcpPrivateOffer.resellerInfo` | REQUIRED for CPPO (`offerSource = "RESOLD"`) — partner identity |

### GCP-specific gotchas

- **`gcpDuration` is REQUIRED**: term length in months (integer). Without it, GCP rejects the offer.
- **`gcpCustomerInfo.billingAccountId` is REQUIRED**: format `billingAccounts/01ABCD-234567-EFGH89` (you'll see this exact prefix). NEVER invent — read from a CRM field.
- **CPPO via `offerSource: "RESOLD"`**: GCP's CPPO model uses a separate `GcpResellerPrivateOfferPlan` template upstream. The script-time output for CPPO Standard private offers needs `offerSource = "RESOLD"` + `resellerInfo` + `partnerId` references.
- **Replacement offers**: GCP's analog of AWS ABO. Set `gcpOfferDealType = "NATIVE_RENEWAL"` AND `gcpPrivateOffer.replacementMetadata = { replacedOfferId, ... }`. The replaced offer's id comes from `$Entitlement.id` or a related-entitlement query.
- **PREPAY vs POSTPAY**: PREPAY uses `paymentInstallments`; POSTPAY uses `gcpSkuDiscounts` (per-metric discount %).

---

## Archetype branches

### Standard (`$OfferType === "Standard"` or undefined)

Direct customer offer, no channel partner.

```js
$target.info.gcpPrivateOffer.offerSource = "OFFER";
// no resellerInfo, no replacementMetadata
```

### CPPO (`$OfferType === "CPPO"`)

Channel partner / reseller offer.

```js
$target.info.gcpPrivateOffer.offerSource = "RESOLD";
$target.info.gcpPrivateOffer.resellerInfo = {
  // TODO: shape varies — confirm with the user. Typical fields:
  // partnerName: "<TODO partner display name>",
  // partnerAccountId: "<TODO partner account id>",
};
// CPPO often uses a different EULA flow; confirm before setting useLegacyPartnerEula
```

### Replacement (`$OfferType === "Replacement"`) — GCP's renewal/amendment analog

Replaces an existing GCP entitlement. Requires `$Entitlement.id` of the prior entitlement.

```js
if ($OfferType === "Replacement") {
  $target.metaInfo = $target.metaInfo || {};
  $target.metaInfo.isRenewalOffer = true;
  $target.metaInfo.renewalOfferType = "GcpMarketplace";

  $target.info.gcpOfferDealType = "NATIVE_RENEWAL";

  $target.info.gcpPrivateOffer.replacementMetadata = {
    // TODO: confirm exact shape with the user. Typical:
    // replacedOfferId: "<prior offer id>",
    // replacedAgreement: "projects/<projectNumber>/agreements/<agreementId>",
  };

  // If the customer's flow merges unbilled installments from the prior entitlement
  // (similar to the AWS ABO pattern), query the prior Suger Entitlement record:
  // const prevRec = $query(
  //   "SELECT Suger__Entitlement_Info__c FROM Suger__Entitlement__c " +
  //   "WHERE Suger__Entitlement_ID__c = '" + $Entitlement.id + "' LIMIT 1"
  // );
  // ... merge prior unbilled installments with the current schedule, sort by chargeOn ...
}
```

---

## Common patterns

### 1. Query the source record

```js
const record = $query(
  "SELECT Id, Name, Account.Name, " +
  "Owner.Email, Owner.Name, " +
  "ExpirationDate, TotalPrice, Subscription_Term__c, " +
  "GCP_Billing_Account__c, " +
  "Type " +
  "FROM Quote WHERE Id = '" + $source.Id + "' AND IsSyncing = true LIMIT 1"
);
if (!record) throw new Error("Primary syncing Quote not found for " + $source.Id);
```

### 2. Customer info (REQUIRED — GCP rejects offers without this)

```js
const billingAccount = String(record.GCP_Billing_Account__c || "").trim();
if (!billingAccount) {
  // TODO: replace GCP_Billing_Account__c with the real CRM field path
  throw new Error("GCP billing account ID is required.");
}
$target.info.gcpCustomerInfo = {
  billingAccountId: billingAccount, // Format: "billingAccounts/01ABCD-234567-EFGH89"
};
```

### 3. Duration (REQUIRED)

```js
const termMonths = Math.floor(Number(record.Subscription_Term__c || 0));
if (!termMonths || termMonths < 1 || termMonths > 60) {
  throw new Error("GCP duration must be between 1 and 60 months. Got: " + termMonths);
}
$target.info.gcpDuration = termMonths;
```

### 4. Notification contacts

```js
const contactIds = [];
const opsContact = $createContact({
  name: "Marketplace Operations",
  emailAddress: "<TODO ops alias email>",
});
contactIds.push(opsContact.id);
if (record?.Owner?.Email) {
  const owner = $createContact({
    name: record.Owner.Name || "Quote Owner",
    emailAddress: record.Owner.Email,
  });
  contactIds.push(owner.id);
}
$target.contactIds = contactIds;
```

### 5. Renewal flag (always set, then specialise per archetype)

```js
if (record?.Type === "Renewal" || record?.Type === "Amendment") {
  $target.metaInfo = $target.metaInfo || {};
  $target.metaInfo.isRenewalOffer = true;
  $target.metaInfo.renewalOfferType = "GcpMarketplace";
  $target.info.gcpOfferDealType = "NATIVE_RENEWAL";
}
```

### 6. Payment installments — PREPAY only

```js
function roundToTwo(n) { return Math.round(n * 100) / 100; }
function addMonths(date, m) {
  const d = new Date(date);
  const day = d.getDate();
  d.setMonth(d.getMonth() + m);
  if (d.getDate() < day) d.setDate(0);
  return d;
}

const totalAmount = roundToTwo(record.TotalPrice || 0);
const freq = String(record.Billing_Frequency__c || "").toLowerCase();
let numInstallments = 0;
let monthsPer = 1;
if (freq === "monthly")          { numInstallments = termMonths;                monthsPer = 1; }
else if (freq === "quarterly")   { numInstallments = Math.ceil(termMonths / 3); monthsPer = 3; }
else if (freq === "annual")      { numInstallments = Math.ceil(termMonths / 12); monthsPer = 12; }
else if (freq === "upfront" || freq === "all upfront") {
  numInstallments = 1;
  monthsPer = termMonths;
}

const installments = [];
const startDate = parseDate(record.Subscription_Start_Date__c) || new Date();
if (totalAmount > 0 && numInstallments > 0) {
  const per = roundToTwo(totalAmount / numInstallments);
  let total = 0;
  for (let i = 0; i < numInstallments; i++) {
    const chargeOn = addMonths(startDate, i * monthsPer);
    installments.push({ amount: per, chargeOn: chargeOn.toISOString() });
    total += per;
  }
  // Round-drift adjustment on last installment
  const drift = roundToTwo(totalAmount - total);
  installments[installments.length - 1].amount = roundToTwo(installments[installments.length - 1].amount + drift);
}
$target.info.paymentInstallments = installments;
$target.info.paymentSchedule = "PREPAY";
$target.info.gcpPaymentSchedule = "PREPAY"; // legacy alias
```

### 7. EULA

```js
// Default
$target.info.eulaType = "ISV";

// Custom partner EULA
// $target.info.eulaType = "CUSTOM";
// $target.info.gcpPrivateOffer.useLegacyPartnerEula = true;
```

---

## Hard constraints — never violate

- **`gcpCustomerInfo.billingAccountId`**: required, format `billingAccounts/<id>`. NEVER invent.
- **`gcpDuration`**: integer 1–60 (months).
- **`gcpOfferDealType`**: must be one of `OFFER_DEAL_TYPE_UNSPECIFIED`, `CHANNEL_SHIFT`, `MIGRATION`, `NATIVE_RENEWAL`.
- **`offerSource`**: must be `"OFFER"` or `"RESOLD"`.
- **Replacement offers**: must include `replacementMetadata` referencing the prior agreement.
- **PREPAY**: must include `paymentInstallments[]` with at least one entry; `chargeOn` ISO timestamps in the future.

---

## Don't do these

- ❌ Reference SFDC fields you haven't confirmed exist. Ask, or call `query_sfdc_object_schema`.
- ❌ Wrap your code in `function parseOfferInput(input) {}` — there is no such function.
- ❌ `return` an offer at the top level — mutate `$target` instead.
- ❌ Use `fetch`, `require`, ES modules, `setTimeout`, `Promise` — not available.
- ❌ Hard-code GCP billing account IDs, project IDs, or service names. Always use `<TODO ...>`.

### ⚠️ Cross-cloud field contamination — the silent killer

GCP has its own offer-body shape under `$target.info.gcpPrivateOffer.*` plus several flat `info.gcp*` fields. The GCP offer-creation API **silently ignores** any field it doesn't recognise — no error, no warning. So if your script writes AWS-only or Azure-only fields by mistake, the offer is created but with missing data, and the bug only surfaces when a customer can't accept it or pricing is wrong.

**NEVER write any of these in a GCP script** (they belong to other clouds):

| AWS-only — DO NOT use here | Azure-only — DO NOT use here |
|---|---|
| `$target.info.commits` | `$target.info.azurePrivateOffer.*` |
| `$target.info.dimensions` | (everything under that nested object) |
| `$target.info.awsCppoOpportunity` | |
| `$target.info.buyerAwsAccountIds` | |
| `$target.info.attachEulaType` | |

For GCP, the offer-specific fields are:
- `$target.info.gcpPrivateOffer.*` (nested private-offer body)
- `$target.info.gcpDuration` (REQUIRED — months)
- `$target.info.gcpCustomerInfo` (REQUIRED — `billingAccountId`)
- `$target.info.gcpProviderInfo` (sales contact)
- `$target.info.gcpPlans[]` (plan references)
- `$target.info.gcpOfferDealType` (deal-type enum)
- `$target.info.gcpPaymentSchedule` / cross-cloud `info.paymentSchedule`
- `$target.info.gcpUsagePlanPriceModel`
- `$target.info.gcpSkuDiscounts[]` (POSTPAY)
- `$target.info.gcpResellerPrivateOfferPlan` (CPPO)
- `$target.info.paymentInstallments[]` (cross-cloud, used for PREPAY)

See the "Output shape — GCP-specific" tables above for the full list. Cross-cloud bleed is the single most common bug when adapting an AWS or Azure script to GCP; if you're translating from another cloud, **delete every line that touches `info.commits` / `info.dimensions` / `info.azurePrivateOffer.*` and rebuild from a Pattern A–D scaffold.**

### ⚠️ The `crmFields` form value is NOT a list of SOQL-queryable fields

Same warning as AWS / Azure skills — `crmFields` may include virtual aliases like `_PrimaryContactEmail`, `_Contact_Decision_Maker` etc. These are framework-level placeholders resolved by Go templates and **do not exist in the Salesforce database**. NEVER include `_`-prefixed names in SOQL.

### ⚠️ SOQL injection — interpolating user-modifiable values

`$source.Id` is the safe Salesforce Id format. Any other value taken from a Salesforce record — `Name`, custom-text fields like `ListKey`, `PlanKey`, customer-typed fields — may contain a single quote and break the SOQL string. Validate the charset (`/^[A-Za-z0-9_-]+$/`) before interpolation, or escape single quotes with `replace(/\\\\/g, "\\\\\\\\").replace(/'/g, "\\\\'")`. Never interpolate free-form text directly into a SOQL `WHERE` clause.

### ⚠️ Never invent `gcpDuration`, `gcpCustomerInfo`, or `gcpOfferDealType` values

These three are non-optional for GCP offer creation. If the user hasn't given you real values, throw with a clear error message (better than silently passing a placeholder).

### ⚠️ Don't treat `Replacement` like AWS `ABO` blindly

The two are conceptually similar (both replace a prior entitlement) but have different output shapes:
- AWS ABO uses `paymentInstallments` merge from `Suger__Entitlement__c`
- GCP Replacement requires `gcpPrivateOffer.replacementMetadata` with `replacedAgreement` resource name AND optionally the same installment-merge pattern
Confirm with the user which fields their flow needs.

---

## Worked example patterns

The four anonymized patterns below are **distilled from real production GCP scripts** across multiple customers. Customer names, emails, OAuth client IDs, GCP plan names, and Suger product IDs have been replaced with placeholders — the SHAPES (control flow, SOQL, GCP customer/provider/plan structure, branching) are accurate. Always replace placeholders with the user's real values before saving.

### Pattern A — SBQQ__Quote__c source, customer email from Opportunity.ContactId, frequency-based installments

The simplest GCP shape. Pulls the customer's contact name/email from the Opportunity's primary contact, builds payment installments from term + Payment_Frequency__c, and uses a fixed `gcpPlans` reference.

```js
function parseDate(s) {
  if (!s) return null;
  const d = new Date(s);
  return isNaN(d.getTime()) ? null : d;
}

const record = $query(
  "SELECT SBQQ__Opportunity2__r.ContactId, SBQQ__NetAmount__c, " +
  "SBQQ__SubscriptionTerm__c, Payment_Frequency__c, SBQQ__StartDate__c " +
  "FROM SBQQ__Quote__c WHERE Id = '" + $source.Id + "' LIMIT 1"
);
const contact = $query(
  "SELECT Name, Email FROM Contact WHERE Id = '" + record.SBQQ__Opportunity2__r.ContactId + "'"
);

$target.info.gcpCustomerInfo.contact = contact.Name;
$target.info.gcpCustomerInfo.email = contact.Email;
$target.info.gcpPlans = [{ name: "<TODO product gcp plan name>" }];
$target.info.gcpPaymentSchedule = "PREPAY";

// Future-dated start only — leave null for acceptance-start
const startDate = parseDate(record.SBQQ__StartDate__c);
if (startDate && startDate > new Date()) {
  $target.info.startTime = startDate.toISOString();
}

// Installments by frequency
const startTime = record.SBQQ__StartDate__c;
const termLength = record.SBQQ__SubscriptionTerm__c;
const amount = record.SBQQ__NetAmount__c;
const frequency = record.Payment_Frequency__c;
$target.info.paymentInstallments = [];

if (frequency === "Upfront") {
  // GCP upfront: a single installment charged at offer start. NEVER use
  // `info.commits` — that is an AWS-only field; GCP silently drops it.
  $target.info.paymentInstallments = [{
    chargeOn: startTime ? new Date(startTime).toISOString() : new Date().toISOString(),
    amount: parseFloat(amount.toFixed ? amount.toFixed(2) : Number(amount).toFixed(2)),
  }];
} else if (termLength && startTime) {
  let interval = 0;
  if (frequency === "Monthly")     interval = 1;
  if (frequency === "Quarterly")   interval = 3;
  if (frequency === "Semi Annual") interval = 6;
  if (frequency === "Annual")      interval = 12;

  if (interval) {
    const installments = [];
    const numberOfInstallments = termLength / interval;
    const installmentAmount = amount / numberOfInstallments;
    let curDate = new Date(startTime);
    for (let i = 0; i < numberOfInstallments; i++) {
      installments.push({
        chargeOn: curDate.toISOString(),
        chargeOnStr: curDate.toISOString().substring(0, 10),
        amount: parseFloat(installmentAmount.toFixed(2)),
      });
      curDate.setMonth(curDate.getMonth() + interval);
    }
    $target.info.paymentInstallments = installments;
  }
}
```

### Pattern B — Standard Quote source, custom EULA, gcpResellerPrivateOfferPlan template, NATIVE_RENEWAL flow

Customer with a Master Customer Agreement attached as PDF, single bundled commit covering the offer, per-product `gcpPlans` lookup, and a CPPO-style reseller plan template (`gcpResellerPrivateOfferPlan`) for offer-term/payment-recurrence. CPPO branch differentiates by `$OfferType`.

```js
function parseDate(s) {
  if (!s) return null;
  const d = new Date(s);
  return isNaN(d.getTime()) ? null : d;
}
function roundToTwoDecimalPlaces(n) { return Math.round(n * 100) / 100; }

$target.info = {};
const quoteData = $query(
  "SELECT Name, QuoteNumber, Account.Name, TotalPrice, Subscription_Term__c, " +
  "Opportunity.RecordType.Name, " +
  "Opportunity.Owner.Name, Opportunity.Owner.Email, " +
  "Opportunity.Account.Owner.Name, Opportunity.Account.Owner.Email, " +
  "Opportunity.Contact__c " +
  "FROM Quote WHERE Id = '" + $source.Id + "' " +
  "AND IsSyncing = true AND ApprovalStatus__c = 'Approved' LIMIT 1"
);

const recordType = quoteData?.Opportunity?.RecordType?.Name;
const accName = quoteData?.Account?.Name;
const totalPrice = quoteData?.TotalPrice || 0;
const amount = roundToTwoDecimalPlaces(totalPrice);
const termLength = quoteData?.Subscription_Term__c;
const oppLevelBuyerContactId = quoteData?.Opportunity?.Contact__c;

// Buyer contact — try opportunity-level contact first, fall back to a TODO default
let buyerContactEmail, buyerContactName;
if (oppLevelBuyerContactId) {
  const oppBuyerData = $query(
    "SELECT Id, Name, Email FROM Contact WHERE Id = '" + oppLevelBuyerContactId + "'"
  );
  buyerContactEmail = oppBuyerData?.Email;
  buyerContactName  = oppBuyerData?.Name;
}
if (buyerContactEmail == null || buyerContactName == null) {
  buyerContactEmail = "<TODO default buyer contact email>";
  buyerContactName  = "<TODO default buyer contact name>";
}

$target.info.gcpCustomerInfo = {};
if ($OfferType !== "CPPO") {
  $target.info.gcpCustomerInfo.contact = buyerContactName;
  $target.info.gcpCustomerInfo.email   = buyerContactEmail;
}
$target.info.gcpCustomerInfo.organization = accName;

let updatedAccName = (accName ?? "").toString().replace(/[^a-zA-Z0-9_-]/g, "");
$target.name = quoteData?.QuoteNumber + "_" + updatedAccName;

// Notification contacts
const contactIds = [];
contactIds.push($createContact({
  name: "Marketplace Order",
  emailAddress: "<TODO ops alias email>",
}).id);
if (quoteData?.Opportunity?.Owner?.Email) {
  contactIds.push($createContact({
    name: quoteData.Opportunity.Owner.Name,
    emailAddress: quoteData.Opportunity.Owner.Email,
  }).id);
}
$target.contactIds = contactIds;

// Expiry / start / end
const today = new Date();
const expireDate = new Date();
expireDate.setDate(today.getDate() + 14);
$target.expireTime = expireDate;

const startDate = new Date();
startDate.setDate(today.getDate() + 1);
if ($OfferType === "CPPO") {
  $target.info.startTime = startDate;
}
const endDate = new Date();
endDate.setDate(startDate.getDate() + 14);
$target.endTime = endDate;

// Provider info
$target.info.gcpProviderInfo = {};
$target.info.gcpProviderInfo.salesContactName = "<TODO sales contact name>";
$target.info.gcpProviderInfo.salesContactEmail = "<TODO sales contact email>";

$target.info.gcpDuration = termLength;
// GCP bundled-commit pricing lives on `info.gcpPlans` + the per-product
// gcpResellerPrivateOfferPlan template defined later in this Pattern. Do
// NOT use `info.commits` here — that is an AWS-only field and the GCP
// offer creation API silently drops it, leaving the offer with no priced commit.

// Custom EULA
$target.info.eulaType = "CUSTOM";
$target.info.eulaUrl = "<TODO hosted EULA URL — supplied by user / customer config>";

$target.info.gcpUsagePlanPriceModel = "CUD_LIST_PRICE";
$target.info.gcpPaymentSchedule = "PREPAY";
$target.info.gcpOfferDealType = (recordType === "Renewal") ? "CHANNEL_SHIFT" : "OFFER_DEAL_TYPE_UNSPECIFIED";

// Reseller offer-term template (used for both Standard and CPPO in this pattern)
$target.info.gcpResellerPrivateOfferPlan = {
  offerTermTemplate: {
    paymentRecurrence: "CUSTOM_PERIOD",
    startPolicy: "OFFER_START_POLICY_IMMEDIATE",
    termDurationConstraint: { defaultDuration: { count: termLength } },
  },
  reusePolicy: "REUSE_POLICY_SINGLE_USE",
  startPolicy: "OFFER_START_POLICY_IMMEDIATE",
};

$target.info.paymentInstallments = [{ amount, discountPercentage: 0 }];

// Per-product gcpPlans lookup
let productId = $target.productID;
if (typeof $Product !== "undefined" && $Product !== null) productId = $Product.id;

if (productId === "<EXAMPLE_PRODUCT_ID_A>") {
  $target.info.gcpPlans = [{
    name: "<TODO gcp plan name for product A>",
    priceInfo: { priceModel: "SUBSCRIPTION" },
  }];
} else if (productId === "<EXAMPLE_PRODUCT_ID_B>") {
  $target.info.gcpPlans = [{
    name: "<TODO gcp plan name for product B>",
    priceInfo: { priceModel: "SUBSCRIPTION" },
  }];
}
```

### Pattern C — SBQQ__Quote__c source, CPPO via gcpResellerPrivateOfferPlan, per-product gcpPlans for many products

CPPO offer with reseller-plan template. Renewal flag derived from `Account.New_Customer_Status__c`. Includes installment schedule from explicit start/end dates and per-product `gcpPlans` lookup across many SKUs.

```js
function simpleCurrentDate() {
  const date = new Date();
  const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
  return months[date.getMonth()] + date.getFullYear();
}
function getMonthDifference(d1, d2) {
  const a = new Date(d1), b = new Date(d2);
  let total = (b.getFullYear() - a.getFullYear()) * 12 + (b.getMonth() - a.getMonth());
  if (b.getDate() < a.getDate()) total--;
  return total;
}
function roundToTwoDecimalPlaces(n) { return Math.round(n * 100) / 100; }
function addMonths(d, months) {
  const date = new Date(d);
  const day = date.getDate();
  date.setMonth(date.getMonth() + months);
  if (date.getDate() < day) date.setDate(0);
  return date;
}
function parseDate(s) {
  if (!s) return null;
  const d = new Date(s);
  return isNaN(d.getTime()) ? null : d;
}

if (!$target.info) $target.info = {};
if (!$target.metaInfo) $target.metaInfo = {};

const quoteData = $query(
  "SELECT SBQQ__Opportunity2__r.Customer_AWS_Account_Number__c, " +
  "SBQQ__Opportunity2__r.Partner_AWS_Account_Number__c, " +
  "CPQ_Reseller_Total_Amount__c, Invoicing_Terms__c, CPQ_Non_Standard_Request__c, " +
  "SBQQ__Opportunity2__r.Account.Name, Name, CPQ_PartnerAccountName__c, " +
  "SBQQ__Opportunity2__r.Account.New_Customer_Status__c, " +
  "SBQQ__EndDate__c, SBQQ__StartDate__c, " +
  "SBQQ__Opportunity2__r.Owner.Name, SBQQ__Opportunity2__r.Owner.Email, " +
  "SBQQ__Opportunity2__r.Account.Owner.Name, SBQQ__Opportunity2__r.Account.Owner.Email " +
  "FROM SBQQ__Quote__c WHERE Id = '" + $source.Id + "' LIMIT 1"
);

const customerAwsAccNumber = quoteData.SBQQ__Opportunity2__r?.Customer_AWS_Account_Number__c || "";
const partnerName          = quoteData.CPQ_PartnerAccountName__c;
const customerName         = quoteData.SBQQ__Opportunity2__r?.Account.Name || "";
const customerStatus       = quoteData.SBQQ__Opportunity2__r?.Account.New_Customer_Status__c || "";
const resellerTotalAmt     = quoteData.CPQ_Reseller_Total_Amount__c;
const invoicingTerms       = quoteData.Invoicing_Terms__c;
const nonStandardReq       = quoteData.CPQ_Non_Standard_Request__c;
const quoteStartDate       = parseDate(quoteData.SBQQ__StartDate__c);
const quoteEndDate         = parseDate(quoteData.SBQQ__EndDate__c);

// Offer name — different shape for CPPO
let offerName = customerName + "-" + partnerName + "-<TODO ISV name>-" + quoteData.Name + "-" + simpleCurrentDate();
if ($OfferType === "CPPO") {
  offerName = customerName + "-" + partnerName + "-GCP-<TODO ISV name>-" + quoteData.Name + "-" + simpleCurrentDate();
}
$target.name = offerName.replace(/[^a-zA-Z0-9_-]/g, "");

$target.metaInfo.isRenewalOffer = customerStatus.includes("Current Customer");

// Notification contacts
const contactIds = [];
contactIds.push($createContact({
  name: "Cloud Deal Desk",
  emailAddress: "<TODO ops alias email>",
}).id);
const oppOwnerEmail = quoteData.SBQQ__Opportunity2__r?.Owner?.Email;
if (oppOwnerEmail) {
  contactIds.push($createContact({
    name: quoteData.SBQQ__Opportunity2__r.Owner.Name,
    emailAddress: oppOwnerEmail,
  }).id);
}
$target.contactIds = contactIds;

$target.info.gcpCustomerInfo = {};
$target.info.gcpCustomerInfo.unverifiedBillingAccount = customerAwsAccNumber;
$target.info.gcpCustomerInfo.organization = customerName;

$target.info.gcpUsagePlanPriceModel = "CUD_LIST_PRICE";
$target.info.gcpPaymentSchedule = "PREPAY";
$target.info.eulaType = "CUSTOM";

// Installments — interval-based when start/end dates are explicit, otherwise single charge today
if (
  nonStandardReq === "Service Contract with Invoicing Schedule" &&
  invoicingTerms !== "Upfront" &&
  quoteStartDate != null && quoteEndDate != null
) {
  const numOfMonths = getMonthDifference(quoteStartDate, quoteEndDate);
  let interval = 0;
  let numOfInstalments = 0;
  if (invoicingTerms === "Monthly")   { interval = 1;  numOfInstalments = numOfMonths; }
  if (invoicingTerms === "Annual")    { interval = 12; numOfInstalments = Math.ceil(numOfMonths / 12); }
  if (invoicingTerms === "Quarterly") { interval = 3;  numOfInstalments = Math.ceil(numOfMonths / 3); }

  if (resellerTotalAmt > 0 && numOfInstalments > 0) {
    const amountPerMonth = roundToTwoDecimalPlaces(resellerTotalAmt / numOfInstalments);
    const instalments = [];
    let totalAmount = 0;
    for (let i = 0; i < numOfInstalments; i++) {
      const curChargeDate = addMonths(quoteStartDate, i * interval);
      instalments.push({ amount: amountPerMonth, chargeOn: curChargeDate.toISOString() });
      totalAmount += amountPerMonth;
    }
    const drift = roundToTwoDecimalPlaces(resellerTotalAmt - totalAmount);
    instalments[numOfInstalments - 1].amount = roundToTwoDecimalPlaces(
      instalments[numOfInstalments - 1].amount + drift
    );
    $target.info.paymentInstallments = instalments;
  }
} else {
  $target.info.paymentInstallments = [{ amount: resellerTotalAmt, chargeOn: new Date().toISOString() }];
}

// CPPO-specific block
if ($OfferType === "CPPO") {
  $target.info.startTime = new Date().toISOString();
  $target.info.gcpProviderInternalNote = "All products related to <TODO order tag> " + quoteData.Name;
  $target.info.gcpResellerPrivateOfferPlan = {
    offerTermTemplate: {
      paymentRecurrence: "CUSTOM_PERIOD",
      startPolicy: "OFFER_START_POLICY_IMMEDIATE",
    },
    reusePolicy: "REUSE_POLICY_SINGLE_USE",
    startPolicy: "OFFER_START_POLICY_IMMEDIATE",
  };
}
$target.info.gcpProviderPublicNote = "All products related to <TODO order tag> " + quoteData.Name;

// Per-product gcpPlans lookup (many SKUs)
let productId = $target.productID;
if (typeof $Product !== "undefined" && $Product !== null) productId = $Product.id;

if      (productId === "<EXAMPLE_PRODUCT_ID_A>") $target.info.gcpPlans = [{ name: "<TODO gcp plan A>" }];
else if (productId === "<EXAMPLE_PRODUCT_ID_B>") $target.info.gcpPlans = [{ name: "<TODO gcp plan B>" }];
else if (productId === "<EXAMPLE_PRODUCT_ID_C>") $target.info.gcpPlans = [{ name: "<TODO gcp plan C>" }];
// ... etc, one branch per Suger Product

$target.info.gcpDuration = -1; // -1 means use Quote-driven duration via offerTermTemplate
```

### Pattern D — External CPQ API + dynamic product lookup + skuDiscounts for POSTPAY + CPPO branch

The most complex GCP pattern. Calls an external CPQ via OAuth2 for full pricing, looks up Suger Product by ListKey, builds per-meter `gcpSkuDiscounts` for POSTPAY usage products, and includes both PREPAY (paymentInstallments) and POSTPAY (skuDiscounts on each installment) shapes.

```js
function addMonths(date, monthsToAdd) {
  const newDate = new Date(date);
  const originalDay = newDate.getDate();
  newDate.setMonth(newDate.getMonth() + monthsToAdd);
  if (newDate.getDate() < originalDay) newDate.setDate(0);
  return newDate;
}
function convertToZuluFormat(ts) {
  if (!ts) return null;
  const d = new Date(ts);
  return isNaN(d) ? null : d.toISOString();
}
function parseDate(s) {
  if (!s) return null;
  const d = new Date(s);
  return isNaN(d.getTime()) ? null : d;
}
function roundToTwoDecimalPlaces(n) { return Math.round(n * 100) / 100; }

if (!$target.info) $target.info = {};

const record = $query(
  "SELECT External_ID__c, CPQ_Quote_Sync_Timestamp__c, PVR_Status__c, Status, " +
  "Net_Price__c, Quote_Number_CPQ__c, Account.Name, ExpirationDate, OpportunityId " +
  "FROM Quote WHERE Id = '" + $source.Id + "'"
);

// Look up primary contact for sales-contact-name fallback
const contactData = $query(
  "SELECT Contact.Name FROM OpportunityContactRole " +
  "WHERE IsPrimary = true AND OpportunityId = '" + record.OpportunityId + "'"
);
const primaryContactName = contactData?.Contact?.Name || "Marketplace Operations";

// Call external CPQ via OAuth2
const token = $marketplaceApi.getOAuth2Token({
  clientId: "<TODO oauth client id>",
});
const quoteData = $marketplaceApi.oauth2Request({
  token: token,
  method: "POST",
  url: "https://<TODO external cpq host>/<endpoint-path>/" + record.Quote_Number_CPQ__c,
  body: JSON.stringify({
    quoteid: record.External_ID__c,
    cpqlastmodifiedts: convertToZuluFormat(record.CPQ_Quote_Sync_Timestamp__c),
    pvrstatus: record.PVR_Status__c,
    quotestatus: record.Status,
    extendednetprice: record.Net_Price__c,
  }),
});

if (quoteData.Status !== "200" || quoteData.StatusMessage !== "SUCCESS") {
  if (quoteData.StatusMessage === "Data is not synchronized, please try after some time.") {
    throw new Error("CPQ data not synchronized. Please retry later.");
  }
  throw new Error("External CPQ API error: " + quoteData.StatusMessage);
}

const amount = quoteData.TransactionTotal;
let listKey, planKey, numOfMonths, billFreq, discount, priceModel;
if (Array.isArray(quoteData.QuoteLines)) {
  const firstLine = quoteData.QuoteLines[0];
  if (firstLine) {
    listKey      = firstLine.ListKey;
    planKey      = firstLine.PlanKey;
    billFreq     = firstLine.BillingFrequency;
    numOfMonths  = Number(firstLine.ServiceDuration);
    discount     = Number(firstLine.Discount);
    priceModel   = firstLine.PricingMode;
  }
}

// listKey is text from a CPQ quote line — validate before interpolating
// into SOQL (see "SOQL injection" guidance above).
if (!listKey || !/^[A-Za-z0-9_-]+$/.test(listKey)) {
  throw new Error('Invalid or missing ListKey: "' + String(listKey) + '"');
}
const productInfo = $query(
  "SELECT Suger__Product_ID__c, Name, Suger__Product_Type__c " +
  "FROM Suger__Product__c WHERE Suger__Product_External_ID__c = '" + listKey + "' LIMIT 1"
);
$target.productID = productInfo.Suger__Product_ID__c;

$target.metaInfo.isRenewalOffer = (quoteData.RenewalFlag === "true");

// Offer name
const today = new Date();
const todaySimpleDate = today.toISOString().split("T")[0];
$target.name = (record.Account.Name + "-" + productInfo?.Name + "-" + todaySimpleDate)
  .replace(/[^a-zA-Z0-9_-]/g, "");

// Expiry — min(today + 28d, ExpirationDate)
const thirtyDaysFromToday = new Date(today);
thirtyDaysFromToday.setDate(today.getDate() + 28);
const expirationDate = parseDate(record.ExpirationDate);
const expiryDate = expirationDate ? new Date(Math.min(thirtyDaysFromToday, expirationDate)) : thirtyDaysFromToday;
$target.expireTime = expiryDate;

$target.info.currency = quoteData.currency || "USD";

// SKU discounts for POSTPAY usage dimensions
const skuDiscounts = quoteData.QuoteLines
  .filter((i) => i.DimensionType === "BURST" || i.DimensionType === "PAYGO")
  .map((i) => ({ metricId: i.DimensionKey, discount: Number(i.Discount) }));

// Installments — frequency-based
let numOfInstallments = 0;
let monthsPerInstallment = 1;
if (billFreq === "Monthly")        { numOfInstallments = numOfMonths;                monthsPerInstallment = 1; }
else if (billFreq === "Annual")    { numOfInstallments = Math.ceil(numOfMonths / 12); monthsPerInstallment = 12; }
else if (billFreq === "Semi-Annual") { numOfInstallments = Math.ceil(numOfMonths / 6); monthsPerInstallment = 6; }
else if (billFreq === "Quarter")   { numOfInstallments = Math.ceil(numOfMonths / 4);  monthsPerInstallment = 3; }
else if (billFreq === "All Upfront" || billFreq === "one-time") {
  numOfInstallments = 1;
  monthsPerInstallment = numOfMonths;
}

const firstInvoiceDate = new Date(expiryDate);
const instalments = [];
if (amount > 0 && numOfInstallments > 0) {
  const amountPerMonth = roundToTwoDecimalPlaces(amount / numOfInstallments);
  let totalAmount = 0;
  for (let i = 0; i < numOfInstallments; i++) {
    const curChargeDate = addMonths(firstInvoiceDate, i * monthsPerInstallment);
    instalments.push({
      amount: amountPerMonth,
      chargeOn: curChargeDate,
      chargeOnStr: curChargeDate.toISOString(),
      skuDiscounts,
      discountPercentage: 0,
    });
    totalAmount += amountPerMonth;
  }
  const drift = roundToTwoDecimalPlaces(amount - totalAmount);
  instalments[numOfInstallments - 1].amount = roundToTwoDecimalPlaces(
    instalments[numOfInstallments - 1].amount + drift
  );
  $target.info.paymentInstallments = instalments;
}

// Notification contacts
const contactIds = [];
contactIds.push($createContact({
  name: "Marketplace Operations",
  emailAddress: "<TODO ops alias email>",
}).id);
$target.contactIds = contactIds;

// Customer / provider info
$target.info.gcpCustomerInfo = {};
$target.info.gcpProviderInfo = {};
$target.info.gcpCustomerInfo.unverifiedBillingAccount =
  $OfferType === "CPPO" ? quoteData.HyperScalarPartnerId : quoteData.HyperScalarCustomerId;
$target.info.gcpCustomerInfo.organization = record.Account.Name;
$target.info.gcpCustomerInfo.contact      = "Marketplace Operations";
$target.info.gcpCustomerInfo.email        = "<TODO ops alias email>";
$target.info.gcpProviderInfo.salesContactName  = primaryContactName;
$target.info.gcpProviderInfo.salesContactEmail = "<TODO ops alias email>";

$target.info.gcpPlans = [{ name: planKey }];
$target.info.eulaType = "SCMP";
$target.info.gcpDuration = numOfMonths;

// PREPAY vs POSTPAY
$target.info.gcpPaymentSchedule = "PREPAY";
$target.info.gcpUsagePlanPriceModel = priceModel;

// POSTPAY-only: top-level skuDiscounts (PREPAY uses installment.skuDiscounts above)
$target.info.gcpSkuDiscounts = skuDiscounts;
$target.info.discountPercentage = discount;

$target.info.gcpOfferDealType = quoteData.RenewalFlag === "true"
  ? "NATIVE_RENEWAL"
  : "OFFER_DEAL_TYPE_UNSPECIFIED";

// CPPO branch — adds reseller plan, start/end dates, and per-installment discount
if ($OfferType === "CPPO") {
  $target.info.gcpResellerPrivateOfferPlan = {
    offerTermTemplate: {
      paymentRecurrence: "CUSTOM_PERIOD",
      startPolicy: "OFFER_START_POLICY_IMMEDIATE",
      termDurationConstraint: { defaultDuration: { count: numOfMonths } },
    },
    reusePolicy: "REUSE_POLICY_SINGLE_USE",
    startPolicy: "OFFER_START_POLICY_IMMEDIATE",
  };
  $target.info.startTime = today;
  const endDate = new Date(today);
  endDate.setDate(today.getDate() + 28);
  $target.endTime = endDate;

  for (let i = 0; i < instalments.length; i++) {
    instalments[i].discountPercentage = discount;
  }
}
```

---

## CPQ Intake Form ingestion

When the user pastes a numbered CPQ Intake Form, parse it and **skip the "list unknowns" step**. Use the AWS skill's intake mapping table — most rows apply. GCP-specific differences:

- **`7a. SKU/Dimension strategy`** maps to `info.gcpPlans[]` shape. "Single Generic Dimension" → one plan reference; "Granular" → multiple plan entries.
- **`5b. Default billing cadence`** → `info.paymentSchedule` (PREPAY) + per-row `gcpPaymentSchedule`.
- **`4g. Expiration logic`** → `info.gcpPrivateOffer.expireTime`.
- For Replacement: ask which Quote field identifies the prior GCP agreement / entitlement.
- **`11a. Active AWS agreement warning`** is irrelevant for GCP; ignore.

---

## Workflow — one unified script, scaffold-first, never drip-feed questions

Same workflow as AWS / Azure skills.

### Step 1 — Read state once
`get_form_values({ formId })`. Optionally `query_sfdc_object_schema({ objectName })`.

### Step 2 — List unknowns + offer two paths in a SINGLE message, then STOP
Typical GCP-specific unknowns:
- Suger Product the offer is for (drives `info.gcpPlans[]`)
- SFDC field for the GCP `billingAccounts/<id>` (REQUIRED)
- SFDC field for term length in months (REQUIRED, drives `gcpDuration`)
- SFDC field for billing frequency / paymentSchedule
- SFDC field for total contract amount (if not `Amount` / `TotalPrice`)
- SFDC field for start date (if not `CloseDate`)
- Whether the team uses CPPO (drives `offerSource = "RESOLD"`)
- Whether the team uses Replacement (drives `gcpOfferDealType = "NATIVE_RENEWAL"` and `replacementMetadata`)
- For Replacement: how to identify the prior agreement (Quote field path or `$Entitlement` lookup)

End with `show_quick_choices(["Scaffold now with TODOs", "I'll fill in the values"])`.

### Step 3a — User picks "Scaffold now with TODOs"
Produce the COMPLETE unified script with all three branches present (standard body + CPPO branch + Replacement branch). Mark unknowns with `// TODO:`. Then `show_quick_choices(["Save to editor", "Edit further", "Start over"])`.

### Step 3b — User picks "I'll fill in the values"
Single bulleted form. After user replies, produce the complete script.

### Step 4 — On user approval
`invoke_action({ action_id: "set_offer_script", script: "<full script>" })`. Handler pushes previous script to undo stack.

After saving, post the standard two-step Save reminder:
> *"Saved into the editor. Two more steps before this is live: 1) click Test at the top of the dialog; 2) click Save at the top of the dialog to persist. If wrong, click Undo in the toast or say 'undo'."*

### Step 4.1 — Undo on request
If user says "undo" / "revert" / "go back", call `invoke_action({ action_id: "undo_offer_script" })`.

### Anti-patterns — DO NOT do this
- ❌ Asking "Which GCP offer archetype?" — one script handles all three via `$OfferType`.
- ❌ Asking sequential clarifying questions — batch them.
- ❌ Producing a partial script with "I can keep going".
- ❌ Stopping after Save to editor without telling the user about the dialog's top Save button.

SHA-256: c3ad162d76ac165ddb1fdfc9f8c142b6b400bbb47765498752b49accf0ab0395