← Files SugerARCHIVED FILE
SKILL.md
53 KB · Oct 2, 2026 · 00:17 UTC
---
name: offer-mapping-aws
description: "Generate the JavaScript script that builds an AWS Marketplace private offer (Standard, CPPO, or ABO) from a Salesforce Opportunity / Quote / custom record. Covers all three AWS offer types in one skill — branch on $OfferType inside the script."
---
# Generate AWS Private Offer Mapping Script
You help the user write a JavaScript script that runs at offer-creation time and **builds the AWS Marketplace private offer body from a Salesforce source record**. The script handles all three AWS offer archetypes — Standard, CPPO, ABO — by branching on the runtime variable `$OfferType`.
The script is the **primary mapping mechanism** for anything beyond simple scalar fields. Per-field "fillers" handle direct value mapping (e.g. `name = Account.Name`). The script is where the real work happens: querying Salesforce for related records, building `info.commits` / `info.dimensions` / `info.paymentInstallments`, computing renewal flags, attaching contacts, and applying CPPO/ABO-specific blocks.
---
## Runtime model — read this first
The script is **NOT a function**. It is a block of top-level statements. The runtime wraps it as `(() => { <your script> })()` and reads back any mutations you made to `$target`. There is **no `parseOfferInput(input)` wrapper, no `return` of an offer object** — just mutate `$target` in place.
```js
// ✅ Correct shape — top-level statements, mutate $target
const record = $query("SELECT ... FROM SBQQ__Quote__c WHERE Id = '" + $source.Id + "' LIMIT 1");
$target.name = record.Account.Name + "_offer";
$target.info = $target.info || {};
$target.info.eulaType = "ISV";
```
```js
// ❌ Wrong — do not write a function wrapper
function parseOfferInput(input) {
const o = input.offer; // these globals don't exist
o.info.eulaType = "ISV";
return o; // return is ignored
}
```
### Available globals
| Global | Type | What it is |
|---|---|---|
| `$source` | object | The SFDC record the offer is being created from. Has `.Id` and the fields the dialog's "Source Object Type" record returned |
| `$target` | object | The offer being built. **Mutate `$target.*`** — these become the offer body. Common: `$target.name`, `$target.expireTime`, `$target.contactIds`, `$target.productID`, `$target.metaInfo.*`, `$target.info.*` |
| `$OfferType` | string | `"Standard"`, `"CPPO"`, or `"ABO"`. Branch on this for archetype-specific logic |
| `$Product` | object \| undefined | The Suger Product the user picked in the dialog, if any. Has `.id` |
| `$Entitlement` | object \| undefined | Only set when `$OfferType === "ABO"`. Has `.id` of the prior entitlement |
| `$query(soql)` | function | Executes SOQL via Salesforce API and returns ONE record (the runner uses `QueryOne`). Returns `null` if no match. Subqueries return `{ records: [...] }` |
| `$createContact({name, emailAddress})` | function | Creates a Suger contact in the org and returns `{ id, ... }`. Use for `$target.contactIds = [...]` |
| `$marketplaceApi.getOAuth2Token({clientId})` | function | Returns an OAuth2 access token for an integration registered in the org |
| `$marketplaceApi.oauth2Request({token, method, url, body})` | function | Calls an external HTTPS API with the token. Returns parsed JSON if the response is JSON |
| `$marketplaceApi.getProduct(productId)` | function | Fetches a Suger Product by ID — useful for backfilling dimensions in CPPO |
| `$marketplaceApi.downloadFile(url)` | function | Downloads a file (rarely needed in offer mapping) |
Standard JS globals are available: `Date`, `JSON`, `Math`, `Array`, `RegExp`, `Number`, `String`, `console.log`. **No** `fetch`, `require`, modules, or `setTimeout`.
### Choosing the source object: Quote vs Opportunity vs custom
`$source` is whichever SFDC record the dialog's "Source Object Type" dropdown points to. Pick based on how the customer's pricing data lives in Salesforce:
| Customer setup | Use as source | What `$source` looks like |
|---|---|---|
| Salesforce CPQ (SBQQ__) | `SBQQ__Quote__c` (the **primary syncing** quote) | `$source.Id` is the quote Id. Related: `SBQQ__LineItems__r` (line items), `SBQQ__Opportunity2__r` (parent opportunity), `Account_Name__c`, `SBQQ__StartDate__c`, `SBQQ__SubscriptionTerm__c`, `SBQQ__NetAmount__c` |
| Standard Salesforce Quote object | `Quote` | `$source.Id` is the quote Id. Related: `QuoteLineItems` / `LineItems`, `Opportunity`, `Account`, `ExpirationDate`, `TotalPrice`, `Subscription_Term__c` (custom) |
| External CPQ (DealHub / Oracle CPQ / etc.) syncing into Quote | `Quote` (or custom Quote-mirror object) | Same as above; you may also need `$marketplaceApi.oauth2Request(...)` to call the external CPQ for full pricing detail |
| No CPQ — pricing on Opportunity | `Opportunity` | `$source.Id` is the opp Id. Related: `OpportunityLineItems`, `Account`, `Owner`, custom `__c` fields |
**Picking the primary quote**: when the source is `SBQQ__Quote__c` or `Quote`, customers usually have a "is primary" flag — common patterns are `IsSyncing = true`, `Status = "Approved"`, or a custom `Primary__c` field. Always include the primary filter in your SOQL `WHERE` clause; failing to do so picks an arbitrary quote and the offer numbers will drift.
**Navigating relationships**:
- From an `Opportunity` source: line items live at `(SELECT Id, Quantity, ProductCode, Product2.Name FROM OpportunityLineItems)` subquery; account name is `Account.Name`; owner is `Owner.Email`.
- From a `Quote` / `SBQQ__Quote__c` source: parent opportunity is `Opportunity` / `SBQQ__Opportunity2__r`; account is `Account.Name`; line items are `(SELECT ... FROM QuoteLineItems)` or `(SELECT ... FROM SBQQ__LineItems__r)`.
If the dialog's source object is `Opportunity` but the customer told you the data lives on `Quote`, **do not silently switch** — stop and ask the user to change the dialog's "Source Object Type" first; otherwise `$source.Id` is the wrong record and your script will fail.
### Output shape: what to put on `$target`
| Path | Meaning |
|---|---|
| `$target.name` | Offer name (string). Strip non-alphanumeric: `name.replace(/[^a-zA-Z0-9_-]/g, "")` |
| `$target.productID` | Suger Product ID. Usually already set by a filler — only override if you look it up dynamically (e.g. by a Quote line's external SKU/ListKey) |
| `$target.expireTime` | Date object or ISO date string (`YYYY-MM-DD`). Must be future-dated |
| `$target.contactIds` | Array of contact IDs from `$createContact({...}).id` |
| `$target.metaInfo.isRenewalOffer` | Boolean — true for renewals/amendments |
| `$target.metaInfo.renewalOfferType` | `"AwsMarketplace"` when isRenewalOffer is true |
| `$target.info.eulaType` | `"ISV"` (default), `"CUSTOM"` (with `eulaUrl`), or `"SCMP"` |
| `$target.info.eulaUrl` | Required when `eulaType === "CUSTOM"` |
| `$target.info.currency` | `"USD"` etc. Default `"USD"` |
| `$target.info.startTime` | Date object. Set to `null` to mean "starts on acceptance" |
| `$target.info.endTime` | Date object — only when `startTime` is a future date |
| `$target.info.duration` | Term in months, integer 1–60 |
| `$target.info.commits` | Array of `{ key, quantity?, rate?, length?, timeUnit? }`. `length` = months on the FIRST commit only |
| `$target.info.dimensions` | Array of `{ key, rate? }` — usage-based dimensions |
| `$target.info.paymentInstallments` | Array of `{ chargeOn: ISOString, amount: number }` |
| `$target.info.buyerAwsAccountIds` | Array of 12-digit AWS account ID strings |
| `$target.info.attachEulaType` | Used in CPPO scenarios |
| `$target.info.awsCppoOpportunity` | **CPPO only** — `{ Name?, discountType, opportunityDurationType, partnerId? }` |
---
## Archetype branches
### Standard offer (`$OfferType === "Standard"` or undefined)
The default. Build commits + payment installments + EULA + dates from the SFDC record. **~85% of customer scripts are this archetype.** All four worked examples below cover Standard; some also include CPPO and ABO branches.
### CPPO (`$OfferType === "CPPO"`)
Channel partner offer. In addition to the standard fields:
- Set `$target.info.awsCppoOpportunity = { discountType, opportunityDurationType, partnerId }`
- `discountType`: `"CUSTOM_PRICE"` or `"CUSTOM_PRICE_WITH_FPS"`
- `opportunityDurationType`: typically `"ONE_TIME"`
- `partnerId`: read from CRM (e.g. `quoteData.HyperScalarPartnerId`)
- Set `$target.info.attachEulaType = "ISV"` (alongside `eulaType`)
- Often you must **backfill missing usage dimensions from the Product** because CPPO requires all dimensions present:
```js
if ($OfferType === "CPPO") {
const fullProduct = $marketplaceApi.getProduct($target.productID);
if (fullProduct?.info?.dimensions) {
const existingKeys = new Set(($target.info.dimensions || []).map(d => d.key));
for (const prodDim of fullProduct.info.dimensions) {
if (!existingKeys.has(prodDim.key)) {
$target.info.dimensions.push({ key: prodDim.key, rate: prodDim.rate || 0 });
}
}
}
}
```
- Some products forbid commits in CPPO (e.g. Professional Services). Check and set `$target.info.commits = []` when applicable.
### ABO — Agreement-Based Offer (`$OfferType === "ABO"`)
Renewal/amendment of an existing AWS agreement. The previous entitlement is in `$Entitlement`.
- **Always** set `$target.metaInfo.isRenewalOffer = true; $target.metaInfo.renewalOfferType = "AwsMarketplace"`
- **Merge unbilled installments from the prior entitlement with the new ones**, sorted by `chargeOn`:
```js
if ($OfferType === "ABO") {
paymentInstallments.forEach((x) => x.chargeOn = new Date(x.chargeOn));
const previousEntitlementRecord = $query(
"SELECT Suger__Entitlement_Info__c FROM Suger__Entitlement__c " +
"WHERE Suger__Entitlement_ID__c = '" + $Entitlement.id + "' LIMIT 1"
);
const previousInfo = JSON.parse(previousEntitlementRecord.Suger__Entitlement_Info__c);
const previousInstallments = previousInfo?.paymentInstallments || [];
previousInstallments.forEach((x) => x.chargeOn = new Date(x.chargeOn));
const now = new Date();
const previousUnbilled = previousInstallments.filter((x) => x.chargeOn > now);
const merged = previousUnbilled.concat(paymentInstallments);
merged.sort((a, b) => new Date(a.chargeOn) < new Date(b.chargeOn) ? -1 : 1);
$target.info.paymentInstallments = merged;
}
```
---
## Common patterns
### 1. Query the source record + related objects
The first thing almost every script does is run a SOQL `$query` to fetch the fields it needs (often joining Account, Owner, Opportunity, line items).
```js
const record = $query(
"SELECT Name, TotalPrice, ExpirationDate, " +
"Account.Name, " +
"Opportunity.Owner.Name, Opportunity.Owner.Email, " +
"(SELECT Product_Name__c, SBQQ__Quantity__c FROM SBQQ__LineItems__r LIMIT 50) " +
"FROM SBQQ__Quote__c WHERE Id = '" + $source.Id + "' LIMIT 1"
);
const lines = record?.SBQQ__LineItems__r?.records || [];
```
When you don't know the SFDC schema, **ask the user** or call `query_sfdc_object_schema` (the chatbot action). Don't invent custom fields like `Buyer_AWS_Account__c` unless you've confirmed they exist.
### 2. Notification contacts
Always create at least an internal ops contact + the opportunity/account owner.
```js
const contactIds = [];
const ops = $createContact({
name: "Marketplace Support",
emailAddress: "marketplace-support@example.com",
});
contactIds.push(ops.id);
if (record?.Opportunity?.Owner?.Email) {
const oppOwner = $createContact({
name: record.Opportunity.Owner.Name,
emailAddress: record.Opportunity.Owner.Email,
});
contactIds.push(oppOwner.id);
}
$target.contactIds = contactIds;
```
### 3. Renewal flag
```js
if (record?.Opportunity?.Type === "Renewal" || record?.Opportunity?.Type === "Amendment") {
$target.metaInfo = $target.metaInfo || {};
$target.metaInfo.isRenewalOffer = true;
$target.metaInfo.renewalOfferType = "AwsMarketplace";
}
```
### 4. Commits — by product
Most scripts have a `productId` → `commits[]` lookup table because each Suger Product corresponds to a specific set of AWS dimension keys.
```js
let productId = $target.productID;
if (typeof $Product !== "undefined" && $Product !== null) {
productId = $Product.id;
}
if (productId === "<EXAMPLE_PRODUCT_ID_A>") {
$target.info.commits = [{ key: "<EXAMPLE_COMMIT_KEY_A>", quantity: 1, timeUnit: "MONTH" }];
} else if (productId === "<EXAMPLE_PRODUCT_ID_B>") {
$target.info.commits = [
{ key: "<EXAMPLE_AWS_COMMIT_KEY_B1>" },
{ key: "<EXAMPLE_AWS_COMMIT_KEY_B2>" },
];
}
```
`commits[0].length` (in months) is set later from the term length.
### 5. Dimensions — from quote lines
When AWS dimensions come from Salesforce quote lines (e.g. an external CPQ flow), filter by `DimensionType` and map to `{ key, rate }`.
```js
const usageItems = quoteData.QuoteLines.filter(
(item) => item.DimensionType === "BURST" || item.DimensionType === "PAYGO"
);
$target.info.dimensions = usageItems.map((item) => ({
key: item.DimensionKey,
rate: Number(item.UnitNetPrice || 0),
}));
```
### 6. Start / End / Expire dates
```js
function parseDate(s) {
if (!s) return null;
const d = new Date(s);
return isNaN(d.getTime()) ? null : d;
}
const startTime = parseDate(record.SBQQ__StartDate__c);
const termLength = record.SBQQ__SubscriptionTerm__c || 0;
$target.info.startTime = startTime;
if (startTime && startTime > new Date()) {
// Future start date — set explicit endTime
const endTime = new Date(startTime);
endTime.setMonth(startTime.getMonth() + termLength);
endTime.setDate(endTime.getDate() - 1);
$target.endTime = endTime;
} else {
// Starts on acceptance — encode termLength on the first commit instead
if (Array.isArray($target.info.commits) && $target.info.commits.length > 0) {
$target.info.commits[0].length = termLength;
}
$target.info.startTime = null;
}
// Expire = today + 14 (or whatever the SFDC record says, capped)
const today = new Date();
const expire = new Date(today);
expire.setDate(today.getDate() + 14);
$target.expireTime = expire;
```
### 7. Payment installments by frequency
This is the single most-repeated block across customer scripts. Frequency is one of: `"Upfront"` / `"Monthly"` / `"Quarterly"` / `"Semi-Annual"` / `"Annual"` / `"All Upfront"` (string varies by customer field — check what their CRM uses).
```js
function roundToTwoDecimalPlaces(n) {
return Math.round(n * 100) / 100;
}
function addMonths(date, months) {
const d = new Date(date);
const day = d.getDate();
d.setMonth(d.getMonth() + months);
if (d.getDate() < day) d.setDate(0);
return d;
}
let numOfInstallments = 0;
let monthsPerInstallment = 1;
const freq = (record.Payment_Frequency__c || "").toLowerCase();
if (freq === "monthly") { numOfInstallments = termLength; monthsPerInstallment = 1; }
else if (freq === "quarterly") { numOfInstallments = Math.ceil(termLength / 3); monthsPerInstallment = 3; }
else if (freq === "semi-annual") { numOfInstallments = Math.ceil(termLength / 6); monthsPerInstallment = 6; }
else if (freq === "annual") { numOfInstallments = Math.ceil(termLength / 12); monthsPerInstallment = 12; }
else if (freq === "upfront" || freq === "all upfront") {
numOfInstallments = 1;
monthsPerInstallment = termLength;
}
let paymentInstallments = [];
if (amount > 0 && numOfInstallments > 0 && startTime) {
const perPeriod = roundToTwoDecimalPlaces(amount / numOfInstallments);
let total = 0;
for (let i = 0; i < numOfInstallments; i++) {
const chargeOn = addMonths(startTime, i * monthsPerInstallment);
paymentInstallments.push({ amount: perPeriod, chargeOn: chargeOn.toISOString() });
total += perPeriod;
}
// Adjust last installment for rounding drift
const drift = roundToTwoDecimalPlaces(amount - total);
paymentInstallments[paymentInstallments.length - 1].amount = roundToTwoDecimalPlaces(
paymentInstallments[paymentInstallments.length - 1].amount + drift
);
}
$target.info.paymentInstallments = paymentInstallments;
```
### 8. EULA
```js
// Default — most offers
$target.info.eulaType = "ISV";
// Custom EULA
$target.info.eulaType = "CUSTOM";
// The Custom EULA URL must come from the user / customer config —
// don't fabricate the bucket-key shape.
$target.info.eulaUrl = "<TODO hosted EULA URL>";
```
---
## Hard constraints — never violate
- **Buyer AWS account ID**: exactly 12 digits, validated with `/^\d{12}$/`. NEVER invent. Source it from a CRM field the user has confirmed.
- **`info.duration`**: integer 1–60 (months).
- **Dimension `key`**: matches `^[a-zA-Z][a-zA-Z0-9_]*$`, max 36 chars. NO hyphens, NO dots, NO spaces.
- **Commit name**: max 80 chars. **Commit description**: max 1000.
- **`expireTime`**: future date.
- **EULA**: `"ISV"` for standard private offers (not CPPO with channel agreement). `"CUSTOM"` requires `eulaUrl`.
---
## 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.
- ❌ Hardcode customer-specific values like account IDs, dates like `2026-12-31`, or specific opportunity IDs (those are for the user's own testing comments only).
- ❌ Overwrite a field that fillers already populate correctly. Read existing `$target.*` values first.
### ⚠️ Cross-cloud field contamination — the silent killer
AWS offers use flat fields under `$target.info.*` (`info.commits`, `info.dimensions`, `info.paymentInstallments`, `info.buyerAwsAccountIds`, `info.awsCppoOpportunity`). The AWS offer-creation API **silently ignores** any field it doesn't recognise — no error, no warning. So if your script writes Azure-only or GCP-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 an AWS script** (they belong to other clouds):
| Azure-only — DO NOT use here | GCP-only — DO NOT use here |
|---|---|
| `$target.info.azurePrivateOffer.*` | `$target.info.gcpPrivateOffer.*` |
| (everything nested under it) | `$target.info.gcpDuration` |
| | `$target.info.gcpCustomerInfo` |
| | `$target.info.gcpPlans` |
| | `$target.info.gcpOfferDealType` |
| | `$target.info.gcpResellerPrivateOfferPlan` |
| | `$target.info.gcpSkuDiscounts` |
| | `$target.info.gcpUsagePlanPriceModel` |
| | `$target.info.gcpProviderInfo` |
For AWS, see the "Output shape" table above for the full field list. Cross-cloud bleed is most common when porting a script from another cloud; **delete every `info.azurePrivateOffer.*` / `info.gcp*` line before rebuilding from a Pattern A–D scaffold.**
### ⚠️ The `crmFields` form value is NOT a list of SOQL-queryable fields
The form exposes a `crmFields` array. **It is the filler dropdown's source list, not a list of real SFDC fields.** It contains *virtual* aliases like `_PrimaryContactEmail`, `_PrimaryContactFirstName`, `_Contact_PrimaryOrFirst`, `_Contact_Decision_Maker` etc. These are framework-level placeholders resolved at filler-execution time via Go templates (`{{ $target._PrimaryContactEmail }}`) — **they do not exist in the Salesforce database**.
**Hard rule for SOQL**:
- Inside `$query("SELECT ... FROM ...")`, ONLY use real Salesforce API names: `Id`, `Name`, `Account.Name`, `Owner.Email`, `Opportunity.Type`, custom fields ending in `__c`, etc.
- NEVER include any field that starts with `_` (underscore) — those are virtual filler aliases.
- NEVER include UI-label-looking strings like `_Contact_PrimaryOrFirst` — they are not SOQL-resolvable.
- If you want owner/contact emails: use `Owner.Email`, `Account.Owner.Email`, or query the `OpportunityContactRole` child relationship.
- When unsure whether a field exists, call `query_sfdc_object_schema({objectName: ...})` and use only what comes back.
### ⚠️ SOQL injection — interpolating user-modifiable values
`$source.Id` is a Salesforce-controlled 18-character alphanumeric identifier and is safe to interpolate directly. **Any other value taken from a Salesforce record may contain a single quote and break out of the SOQL string.** That includes `Name`, custom-text fields like `ListKey`, `PlanKey`, `Quote.Account.Name`, and any value the customer typed into Salesforce.
Two safe patterns:
1. **Validate the charset** before interpolation. For product/plan/list keys, customers' values are almost always alphanumeric + `-` / `_` — assert that and refuse otherwise:
```js
if (!/^[A-Za-z0-9_-]+$/.test(listKey)) throw new Error("invalid listKey: " + listKey);
const product = $query(
"SELECT Suger__Product_ID__c, Name FROM Suger__Product__c " +
"WHERE Suger__Product_External_ID__c = '" + listKey + "' LIMIT 1"
);
```
2. **Escape single quotes** (and backslashes) before interpolation:
```js
const safeKey = String(listKey).replace(/\\/g, "\\\\").replace(/'/g, "\\'");
const product = $query("... WHERE Suger__Product_External_ID__c = '" + safeKey + "' LIMIT 1");
```
Pick one per script. When neither applies (the value is genuinely free-form text), prefer fetching by the parent record's `Id` and reading the field from the returned object — never let arbitrary user text near a SOQL string.
### ⚠️ Never invent `commit.key` or `dimension.key` values
AWS dimension keys (`commits[].key`, `dimensions[].key`) must match the keys defined on the Suger Product. Inventing a placeholder-sounding key like `"private_offer"` / `"Capacity"` / `"main_commit"` will fail offer validation if the product doesn't actually have that key.
Before writing commit/dimension keys:
1. Ask the user which Suger Product this offer is for (or read `$Product?.id` if available in the form).
2. Ask the user what dimension keys the product uses, OR call `$marketplaceApi.getProduct(productId)` to fetch the product and read `info.dimensions[].key` / `info.commits[].key` from it.
3. If the user wants a single bundled commit and confirms the key, use that. Otherwise, leave a `// TODO:` comment and ask.
### ⚠️ Don't gate `paymentInstallments` on a future-dated startTime
A common mistake: building installments only when `startTime > now`. Production offers frequently start on acceptance (`startTime = null`) and still need installments. Compute the first charge date defensively:
```js
const now = new Date();
const firstCharge = startTime && startTime > now ? new Date(startTime) : new Date(now.getTime() + 86400000); // tomorrow
```
Or follow the "shift to tomorrow" pattern: when the start date is already in the past, push the first installment to `now + 1 day` so AWS doesn't reject the schedule.
---
## Worked example patterns
The four anonymized patterns below are **distilled from real production scripts** across multiple customers. Customer names, emails, URLs, OAuth client IDs, Suger product IDs, and AWS commit keys have been replaced with placeholders — the SHAPES (control flow, SOQL, branching) are accurate. When the user asks you to draft, prefer adapting one of these over writing from scratch. Always replace the placeholders with the user's real values before saving.
### Pattern A — SBQQ__Quote__c source, per-product commit table, ABO branch
Used when the customer is on Salesforce CPQ (`SBQQ__`) and has multiple Suger Products that each map to different AWS commit keys. Includes per-product lookup, dynamic installments, and an explicit ABO branch that merges unbilled installments from the prior entitlement.
```js
function parseDate(dateString) {
if (!dateString) return null;
const d = new Date(dateString);
return isNaN(d.getTime()) ? null : d;
}
const record = $query(
"SELECT Name, Technical_Services_Total__c, SBQQ__NetAmount__c, Payment_Frequency__c, " +
"SBQQ__SubscriptionTerm__c, SBQQ__StartDate__c, SBQQ__Opportunity2__r.Start_Date__c, " +
"SBQQ__Opportunity2__r.Type, SBQQ__Opportunity2__r.Owner.Name, " +
"SBQQ__Opportunity2__r.Product_TCV__c, SBQQ__Opportunity2__r.Owner.Email " +
"FROM SBQQ__Quote__c WHERE Id = '" + $source.Id + "' LIMIT 1"
);
const opp = record?.SBQQ__Opportunity2__r;
if (!$target.metaInfo) $target.metaInfo = {};
// Renewal
if (opp?.Type === "Renewal" || opp?.Type === "Amendment") {
$target.metaInfo.isRenewalOffer = true;
$target.metaInfo.renewalOfferType = "AwsMarketplace";
}
// Notification contacts
const contactIds = [];
const ops = $createContact({
name: "Marketplace Support",
emailAddress: "<TODO: ops alias email>",
});
contactIds.push(ops.id);
if (opp?.Owner?.Email) {
const oppOwner = $createContact({ name: opp.Owner.Name, emailAddress: opp.Owner.Email });
contactIds.push(oppOwner.id);
}
$target.contactIds = contactIds;
// Commits — per-product lookup table
let productId = $target.productID;
if (typeof $Product !== "undefined" && $Product !== null) productId = $Product.id;
if (productId === "<EXAMPLE_PRODUCT_ID_CAPACITY>") {
// Capacity-style product
$target.info.commits = [{ key: "<EXAMPLE_COMMIT_KEY_CAPACITY>", quantity: 1, timeUnit: "MONTH" }];
} else if (productId === "<EXAMPLE_PRODUCT_ID_PRO_SERVICES>") {
// Professional Services product
$target.info.commits = [
{ key: "<EXAMPLE_AWS_COMMIT_KEY_1>" },
{ key: "<EXAMPLE_AWS_COMMIT_KEY_2>" },
];
}
// ... (other product branches as needed)
if (!$target.info) $target.info = {};
// Start/end dates
const startTime = parseDate(record.SBQQ__StartDate__c);
$target.info.startTime = startTime;
const termLength = record.SBQQ__SubscriptionTerm__c || 0;
if (startTime && startTime > new Date()) {
const endTime = new Date(startTime);
endTime.setMonth(startTime.getMonth() + termLength);
endTime.setDate(endTime.getDate() - 1);
$target.endTime = endTime;
} else {
if (Array.isArray($target.info.commits) && $target.info.commits.length > 0) {
$target.info.commits[0].length = termLength;
}
$target.info.startTime = null;
}
// Amount — varies by product (some pull from opp TCV, others from a quote-level total)
let amount = 0;
if (productId === "<EXAMPLE_PRODUCT_ID_CAPACITY>") {
amount = opp.Product_TCV__c || 0;
} else if (productId === "<EXAMPLE_PRODUCT_ID_PRO_SERVICES>") {
$target.info.startTime = null;
amount = record.Technical_Services_Total__c || 0;
}
// Installments
function buildInstallments(firstChargeDate, num, intervalMonths, perCharge) {
const out = [];
let chargeDate = new Date(firstChargeDate);
for (let i = 0; i < num; i++) {
out.push({ chargeOn: new Date(chargeDate), amount: Number(perCharge.toFixed(2)) });
chargeDate.setMonth(chargeDate.getMonth() + intervalMonths);
}
return out;
}
let paymentInstallments = [];
const frequency = record.Payment_Frequency__c;
if (frequency === "Upfront") {
const now = new Date();
const firstCharge = startTime < now
? new Date(now.setDate(now.getDate() + 1))
: new Date(startTime);
paymentInstallments = [{ chargeOn: firstCharge.toISOString(), amount: amount }];
} else if (termLength && startTime) {
let interval = 0;
if (frequency === "Quarterly") interval = 3;
else if (frequency === "Semi Annual") interval = 6;
else if (frequency === "Annual") interval = 12;
if (interval) {
const num = termLength / interval;
const per = amount / num;
const installments = buildInstallments(startTime, num, interval, per);
const now = new Date();
if (startTime < now && installments.length) {
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
installments[0].chargeOn = tomorrow;
}
installments.forEach((x) => (x.chargeOn = x.chargeOn.toISOString()));
paymentInstallments = installments;
}
}
$target.info.paymentInstallments = paymentInstallments;
// ABO branch — merge prior unbilled installments
if ($OfferType === "ABO") {
paymentInstallments.forEach((x) => (x.chargeOn = new Date(x.chargeOn)));
const prevRec = $query(
"SELECT Suger__Entitlement_Info__c FROM Suger__Entitlement__c " +
"WHERE Suger__Entitlement_ID__c = '" + $Entitlement.id + "' LIMIT 1"
);
const prevInfo = JSON.parse(prevRec.Suger__Entitlement_Info__c);
const prevInstallments = prevInfo?.paymentInstallments || [];
prevInstallments.forEach((x) => (x.chargeOn = new Date(x.chargeOn)));
const now = new Date();
const prevUnbilled = prevInstallments.filter((x) => x.chargeOn > now);
const merged = prevUnbilled.concat(paymentInstallments);
merged.sort((a, b) => (new Date(a.chargeOn) < new Date(b.chargeOn) ? -1 : 1));
$target.info.paymentInstallments = merged;
}
```
### Pattern B — External CPQ API call, dynamic product lookup, CPPO branch
Used when the customer has an external CPQ (e.g. Oracle CPQ, DealHub) syncing into Salesforce. The script calls the external CPQ via OAuth2 for full pricing detail, looks up the matching Suger Product by an external SKU/ListKey, builds commits and dimensions from the returned line items, and includes a CPPO branch that backfills missing usage dimensions from the Product.
```js
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;
}
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; }
function convertToZuluFormat(ts) {
if (!ts) return null;
const d = new Date(ts);
return isNaN(d) ? null : d.toISOString();
}
if (!$target.info) $target.info = {};
const inputQuoteId = $source.Id;
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 " +
"FROM Quote WHERE Id = '" + inputQuoteId + "'"
);
const requestBody = {
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,
};
// Call external CPQ via OAuth2
const token = $marketplaceApi.getOAuth2Token({
clientId: "<TODO: oauth client id registered in this org>",
});
const quoteData = $marketplaceApi.oauth2Request({
token: token,
method: "POST",
url: "https://<TODO: external cpq host>/<endpoint-path>/" + record.Quote_Number_CPQ__c,
body: JSON.stringify(requestBody),
});
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);
}
// Dynamic product lookup by ListKey from QuoteLines.
// listKey originates from the external CPQ API response — treat it as
// untrusted text and validate the charset before interpolating into SOQL
// (see "SOQL injection" section above).
let listKey = null;
if (Array.isArray(quoteData.QuoteLines)) {
const firstLine = quoteData.QuoteLines.find((l) => l.ListKey);
if (firstLine) listKey = firstLine.ListKey;
}
if (!listKey || !/^[A-Za-z0-9_-]+$/.test(listKey)) {
throw new Error('Invalid or missing ListKey: "' + String(listKey) + '"');
}
const matchedProduct = $query(
"SELECT Suger__Product_ID__c, Name, Suger__Product_Type__c, Suger__Product_External_ID__c " +
"FROM Suger__Product__c WHERE Suger__Product_External_ID__c = '" + listKey + "' LIMIT 1"
);
if (!matchedProduct?.Suger__Product_ID__c) {
throw new Error('No product found for ListKey "' + listKey + '"');
}
$target.productID = matchedProduct.Suger__Product_ID__c;
const isCPPO = $OfferType === "CPPO";
const isProfessionalServices = matchedProduct.Suger__Product_Type__c === "PROFESSIONAL_SERVICES";
// Renewal
if (!$target.metaInfo) $target.metaInfo = {};
if (quoteData.RenewalFlag === "true") {
$target.metaInfo.isRenewalOffer = true;
$target.metaInfo.renewalOfferType = "AwsMarketplace";
} else {
$target.metaInfo.isRenewalOffer = false;
}
// Offer name
const today = new Date();
const todaySimple = today.toISOString().split("T")[0];
const rawName = record.Account.Name + "-" + matchedProduct.Name + "-" + todaySimple;
$target.name = rawName.replace(/[^a-zA-Z0-9_-]/g, "");
// Expiry — min(today + 28d, ExpirationDate)
const thirtyish = new Date(today);
thirtyish.setDate(today.getDate() + 28);
const expiration = parseDate(record.ExpirationDate);
const expiryDate = expiration ? new Date(Math.min(thirtyish, expiration)) : thirtyish;
$target.expireTime = expiryDate;
$target.info.currency = quoteData.currency || "USD";
// Commits — CPPO Pro Services forbids commits
if (isCPPO && isProfessionalServices) {
$target.info.commits = [];
} else {
const commitItems = quoteData.QuoteLines.filter(
(i) => i.DimensionType === "COMMIT" && Number(i.ListPrice || 0) > 0
);
$target.info.commits = commitItems.map((i) => ({
key: i.DimensionKey,
quantity: Number(i.priceQuantity || 0),
rate: Number(i.ListPrice),
}));
}
// Usage dimensions
const usageItems = quoteData.QuoteLines.filter(
(i) => i.DimensionType === "BURST" || i.DimensionType === "PAYGO"
);
$target.info.dimensions = usageItems.map((i) => ({
key: i.DimensionKey,
rate: Number(i.UnitNetPrice || 0),
}));
// CPPO — backfill missing usage dimensions from the Product
if (isCPPO) {
const fullProduct = $marketplaceApi.getProduct($target.productID);
if (fullProduct?.info?.dimensions) {
const existingKeys = new Set($target.info.dimensions.map((d) => d.key));
for (const prodDim of fullProduct.info.dimensions) {
if (!existingKeys.has(prodDim.key)) {
$target.info.dimensions.push({ key: prodDim.key, rate: prodDim.rate || 0 });
}
}
}
}
$target.info.buyerAwsAccountIds = [quoteData.HyperScalarCustomerId];
// Installments
const amount = quoteData.TransactionTotal;
const billFreq = (quoteData.QuoteLines[0]?.BillingFrequency || "").toLowerCase();
const numOfMonths = quoteData.QuoteLines[0]?.ServiceDuration;
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 firstInvoice = new Date(expiryDate);
const installments = [];
if (amount > 0 && numOfInstallments > 0) {
const per = roundToTwoDecimalPlaces(amount / numOfInstallments);
let total = 0;
for (let i = 0; i < numOfInstallments; i++) {
const chargeOn = addMonths(firstInvoice, i * monthsPerInstallment);
installments.push({ amount: per, chargeOn: chargeOn.toISOString() });
total += per;
}
const drift = roundToTwoDecimalPlaces(amount - total);
installments[installments.length - 1].amount = roundToTwoDecimalPlaces(
installments[installments.length - 1].amount + drift
);
$target.info.paymentInstallments = installments;
}
if ($target.info.commits.length > 0) {
$target.info.commits[0].length = Number(numOfMonths);
}
// Notification contacts
const contactIds = [];
const ops = $createContact({
name: "Marketplace Operations",
emailAddress: "<TODO: ops alias email>",
});
contactIds.push(ops.id);
$target.contactIds = contactIds;
// CPPO-specific block
if (isCPPO) {
$target.info.awsCppoOpportunity = {
discountType: "CUSTOM_PRICE_WITH_FPS",
opportunityDurationType: "ONE_TIME",
partnerId: quoteData.HyperScalarPartnerId,
};
$target.info.attachEulaType = "ISV";
}
$target.info.eulaType = "ISV";
```
### Pattern C — Standard Quote source, custom EULA, single bundled commit
A simpler shape: standard `Quote` object as source, one bundled commit covering the whole offer, custom EULA attached as a PDF. Useful template when the customer has a Master Customer Agreement / custom contract that needs to be attached, and pricing is a single line item.
```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; }
const quoteData = $query(
"SELECT Name, QuoteNumber, Account.Name, TotalPrice, End_Date__c, Subscription_Term__c, " +
"Opportunity.RecordType.Name, Opportunity.Owner.Name, Opportunity.Owner.Email, " +
"Opportunity.Account.Owner.Name, Opportunity.Account.Owner.Email " +
"FROM Quote WHERE Id = '" + $source.Id + "' AND IsSyncing = true " +
"AND ApprovalStatus__c = 'Approved' LIMIT 1"
);
const recordType = quoteData?.Opportunity?.RecordType?.Name;
const quoteNum = quoteData?.QuoteNumber;
const accName = (quoteData?.Account?.Name ?? "").toString().replace(/[^a-zA-Z0-9_-]/g, "");
const totalPrice = quoteData?.TotalPrice || 0;
const amount = roundToTwoDecimalPlaces(totalPrice);
const termLength = quoteData?.Subscription_Term__c;
$target.name = quoteNum + "_" + accName;
if (!$target.metaInfo) $target.metaInfo = {};
$target.metaInfo.isRenewalOffer = recordType === "Renewal";
if (recordType === "Renewal") {
$target.metaInfo.renewalOfferType = "AwsMarketplace";
}
// Contacts
const contactIds = [];
const ops = $createContact({
name: "Marketplace Order",
emailAddress: "<TODO: ops alias email>",
});
contactIds.push(ops.id);
if (quoteData?.Opportunity?.Owner?.Email) {
const c = $createContact({
name: quoteData.Opportunity.Owner.Name,
emailAddress: quoteData.Opportunity.Owner.Email,
});
contactIds.push(c.id);
}
$target.contactIds = contactIds;
// Expire = today + 14
const today = new Date();
const expire = new Date(today);
expire.setDate(today.getDate() + 14);
$target.expireTime = expire;
// Custom EULA + bundled commit
$target.info = {};
$target.info.eulaType = "CUSTOM";
$target.info.eulaUrl = "<TODO hosted EULA URL — supplied by user / customer config>";
$target.info.commits = [{ key: "<TODO product commit key>", quantity: 1, rate: amount, length: termLength }];
$target.info.awsCppoOpportunity = {
Name: quoteNum + "_" + accName,
discountType: "CUSTOM_PRICE",
opportunityDurationType: "ONE_TIME",
};
```
### Pattern D — SBQQ__Quote__c source, offer name composed from line items, simple upfront installments
Used when the offer name needs to encode something computed from quote-line attributes (e.g. workload count, product variant, renewal flag). Installment logic stays simple — only handles the `Upfront-<period>` pattern, falling back to empty for special-terms cases.
```js
const id = $source.Id;
const quote = $query(
"SELECT SBQQ__SubscriptionTerm__c, TCV__c, Billable_Terms__c, SBQQ__StartDate__c, " +
"Account_Name__c, VM_Count__c, isRenewal__c, " +
"(SELECT Product_Name__c, SBQQ__Quantity__c FROM SBQQ__LineItems__r LIMIT 3) " +
"FROM SBQQ__Quote__c WHERE Id='" + id + "'"
);
const quoteLines = quote.SBQQ__LineItems__r?.records || [];
const termLength = quote.SBQQ__SubscriptionTerm__c || 0;
const amount = quote.TCV__c || 0;
const frequency = quote.Billable_Terms__c || "";
const startTime = quote.SBQQ__StartDate__c;
const accountName = quote.Account_Name__c;
const isRenewal = quote.isRenewal__c;
// Build offer name from line items
let vmCount = 0;
let productName = "";
for (let i = 0; i < quoteLines.length; i++) {
const li = quoteLines[i];
productName += li?.Product_Name__c;
if (i !== quoteLines.length - 1) productName += "/";
if (!li?.Product_Name__c?.includes("Support")) vmCount = li?.SBQQ__Quantity__c;
}
const renewalType = isRenewal ? "Renewal" : "New";
$target.name = accountName + " - " + vmCount + " VM/Workload - " + renewalType +
" - " + termLength + " months - " + productName;
const now = new Date();
const firstCharge = startTime < now
? new Date(now.setDate(now.getDate() + 1))
: new Date(startTime);
let interval = 0;
if (frequency === "Upfront - Annual") interval = 12;
else if (frequency === "Upfront - Monthly") interval = 1;
else if (frequency === "Upfront-Quarterly") interval = 3;
else {
$target.info.paymentInstallments = [];
}
function buildInstallments(firstChargeDate, num, intervalMonths, perCharge) {
const out = [];
let chargeDate = new Date(firstChargeDate);
for (let i = 0; i < num; i++) {
out.push({ chargeOn: new Date(chargeDate), amount: Number(perCharge.toFixed(2)) });
chargeDate.setMonth(chargeDate.getMonth() + intervalMonths);
}
return out;
}
if (interval > 0 && termLength && amount) {
const num = termLength < interval ? 1 : termLength / interval;
const per = amount / num;
const installments = buildInstallments(firstCharge, num, interval, per);
installments.forEach((x) => (x.chargeOn = x.chargeOn.toISOString()));
$target.info.paymentInstallments = installments;
}
$target.info.commits = [{ key: "<TODO product commit key>", quantity: 1 }];
```
---
## CPQ Intake Form ingestion — **best-case input source**
The Suger CS team uses a standardized **CPQ Intake Form** (a ClickUp task with numbered fields like `1a.`, `2b.`, `4a.`, `5b.`, `7a.`, `11a.`) that the customer fills in to describe their CPQ → AWS mapping. **When the user pastes this intake form into chat, treat it as the primary source of truth and skip the "list unknowns" step entirely.**
Numbered IDs in the intake form are stable across customers. Use this lookup table to map intake answers → script behavior. If the same numbered ID appears more than once with different sub-questions, all instances are valid — read each in context.
| Intake question | Script behavior |
|---|---|
| `1a. Salesforce object API:` | Sets the `sourceObject` for the dialog. SOQL queries `FROM <this object>`. |
| `1a. Custom field API name:` (with `2.` "primary" question) | Filter primary records, e.g. `WHERE Id = '<id>' AND IsSyncing = true` |
| `2b. Primary quote: Custom field / Standard field` | Confirms whether to apply the primary filter |
| `4a. Dates: Acceptance Date / Future Date / Both` | Drives `$target.info.startTime`. "Acceptance" → `null`. "Future" → use `4c. Start Date` field. "Both" → set a `useFutureStartDate` flag (see `4f.`) |
| `4a. Offer name inputs:` | Template like `CustomerName_AccountID_QuoteNumber_<ProductTag>_v1` with field substitutions. Build `$target.name` accordingly; sanitize with `replace(/[^a-zA-Z0-9_-]/g, "")` |
| `4b. Auto-populate description? + 4c. Default description template:` | Set offer description if a template is given; if `TBD`, leave a `// TODO:` |
| `4b. Subscription Term:` | Term-length field (months) → `termMonths` variable, used for `commits[0].length` and installment count |
| `4c. Start Date:` / `4d. End Date:` | Real Quote field paths → `$target.info.startTime` / `$target.endTime` (only when `4a.` says future-dated) |
| `4e. Internal Notes behavior:` | Whether to mirror notes from CRM or keep them internal-only |
| `4f. If other (dates):` | Hybrid logic — read which Quote field flags future-dated vs acceptance-dated |
| `4g. Expiration logic:` (Object and Field / Offer + N days) | Drives `$target.expireTime` source |
| `5a. CPQ total value field:` | Drives `amount` variable, e.g. `Quote.GrandTotal` |
| `5b. This amount represents: TCV / ACV` | If TCV, use directly; if ACV, multiply by years |
| `5b. Default billing cadence:` / `5g. If Other:` | Frequency field path, e.g. `Quote.Billing_Frequency__c` |
| `5d. First installment date:` | First charge date logic |
| `5e. If no End Date exists:` | "Calculate End Date as Start Date + Term" → standard `addMonths(startDate, term)` |
| `5h. Expiration field:` | `$target.expireTime` source field |
| `5i. Allow extending expiration / offer + N days:` | UX-only behavior; doesn't change the script |
| `6a. / 7a. SKU/Dimension strategy:` | "Single Generic Dimension" → bundled `commits = [{ key, quantity: 1, rate: amount, length: term }]`. "Granular" → per-line-item commits/dimensions. "1:1 with AWS dimensions" → straight map |
| `7d. Quantity and Commit Field:` | Real Quote line-item fields for granular mapping |
| `7e. If Generic Dimension:` | The actual commit key string. If `TBD`, leave a `<TODO>` placeholder. |
| `10a–10e. Internal approval:` | Workflow/UI behavior, NOT script — no script change |
| `11a. Additional CPQ fields:` | Free-form additional mappings — read these and add to script |
| `11a. Active AWS agreement warning:` | UI/config behavior, NOT script |
When the user pastes an intake form:
1. Parse field-path values directly from intake answers — never re-ask "which field?".
2. If a value is `TBD` / `–` / not provided, leave a `// TODO:` referencing the intake question ID (e.g. `// TODO: per intake 7e — generic dimension key`).
3. Skip the "list unknowns + scaffold-now / fill-in" step from the workflow below; go straight to producing the complete unified script in one message.
4. Mention in your reply which intake fields you used so the user can verify mapping (one line per major decision, e.g. *"Used 5a. Quote.GrandTotal for amount; 5g. Quote.Billing_Frequency__c for cadence; 7a. single generic dimension."*).
## Workflow — one unified script, scaffold-first, never drip-feed questions
You MUST follow this workflow. Two anti-patterns are explicitly forbidden:
1. Asking the user which archetype (Standard / CPPO / ABO) up front — see "One unified script" below.
2. Drip-feeding clarifying questions one at a time — see "Anti-pattern" below.
### One unified script — do NOT prompt for archetype
Production scripts in customer orgs all handle multiple archetypes in **the same script** by branching on `$OfferType` at runtime:
```js
// Always-run standard logic
$target.info.eulaType = "ISV";
// ... build commits, contacts, dates, installments ...
if ($OfferType === "CPPO") {
$target.info.awsCppoOpportunity = { discountType: "...", opportunityDurationType: "ONE_TIME", partnerId: "..." };
$target.info.attachEulaType = "ISV";
// CPPO dimension backfill from product, etc.
}
if ($OfferType === "ABO") {
// Query prior Suger__Entitlement__c, merge unbilled installments, etc.
}
```
The runtime supplies `$OfferType` per-execution. The user creating a Standard offer today and a CPPO offer tomorrow uses the **same** saved script — the branches activate themselves. So the scaffold MUST always include all three handlers (standard body + CPPO branch + ABO branch), not pick one.
NEVER ask "Which AWS offer archetype do you want?" — the script handles all three.
### Step 1 — Read state once
Call `get_form_values({ formId })`. If `sourceObject` is set and you don't recognize it, also call `query_sfdc_object_schema({ objectName: "<sourceObject>" })`. Both happen before any user-facing message in this round.
### Step 2 — List unknowns + offer two paths in a SINGLE message, then STOP
Compute the list of "unknown but needed" inputs. Typically:
- Suger Product the offer is for (drives commit/dimension keys — script will read keys via `$marketplaceApi.getProduct(productId)`)
- SFDC field that holds the 12-digit buyer AWS account ID
- SFDC field that holds the term length in months
- SFDC field that holds payment frequency (if installments needed) and its allowed values
- SFDC field that holds total contract amount (if not `Amount`)
- SFDC field that holds start date (if not `CloseDate`)
- Whether the customer ever uses CPPO or ABO (to keep or strip those branches)
- Any external API call needed for dimensions/pricing (external-CPQ pattern)
Then post ONE message that:
1. Lists ALL these unknowns as a numbered list — not as separate clarifying questions.
2. Ends with `show_quick_choices(["Scaffold now with TODOs", "I'll fill in the values"])`.
That's it. Do not ask further sub-questions in this turn. Do not loop back to step 2 with another sub-question — step 2 happens **once**.
### Step 3a — User picks "Scaffold now with TODOs"
In ONE message, produce the **complete unified script** as a `\`\`\`js` fenced block. The scaffold MUST include:
1. **Always-run standard body** — query, name, contacts, EULA, currency, expiration, dates, paymentInstallments fallback, commits/dimensions read from `$marketplaceApi.getProduct(productID)` (do NOT invent keys).
2. **CPPO branch** — `if ($OfferType === "CPPO") { ... }` with `awsCppoOpportunity` block (`discountType`, `opportunityDurationType`, `partnerId` as TODO), `attachEulaType`, and dimension backfill from product.
3. **ABO branch** — `if ($OfferType === "ABO") { ... }` with prior-entitlement query and unbilled-installment merge.
**General principle**: look at the user's answers and the worked examples below (Patterns A / B / C / D). Pick the pattern closest to the user's described setup and adapt it. Don't enumerate options for the user to pick — infer from context. When information is missing, leave a `// TODO:` placeholder.
Concrete defaults for the parts that are universal:
- **Never invent commit/dimension keys**. If the user hasn't given you real keys (or a way to fetch them), use `"<TODO product commit key>"` as the placeholder and let them fix it.
- **`buyerAwsAccountIds`**: do not set; leave a `// TODO:` showing where to plug in the real 12-digit field.
- **`paymentInstallments`**: include a frequency branch (`Upfront` / `Monthly` / `Quarterly` / `Semi-Annual` / `Annual`); fallback is a single upfront installment from `Amount` + `CloseDate`.
- **`duration` / `endTime`**: only set when termLength is known.
- **`eulaType`**: `"ISV"` default. Add a commented-out `CUSTOM` block as TODO.
- **Notification contacts**: ops mailbox (TODO email) + Opportunity Owner if email present.
- **Renewal flag**: `Type === "Renewal" || Type === "Amendment"` heuristic.
- **CPPO branch**: `awsCppoOpportunity = { discountType, opportunityDurationType, partnerId }` — TODO each field. Include the dimension-backfill-from-`getProduct` block (Pattern B) here, not in the standard body.
- **ABO branch**: prior-entitlement query via `$Entitlement.id` + unbilled-installment merge — copy from Pattern A.
After the fenced block, post a one-line note: *"This handles Standard out of the box. The CPPO and ABO branches are inert until `$OfferType` is `\"CPPO\"` or `\"ABO\"` at runtime — keep them or delete them based on whether your team uses those offer types."*
Then `show_quick_choices(["Save to editor", "Edit further", "Start over"])` and STOP.
### Step 3b — User picks "I'll fill in the values"
In ONE message, post a single bulleted form for the user to fill in:
```
Reply in one message with these values (or "skip" for any you don't have):
1. Suger Product ID:
2. Buyer AWS account field path (e.g. Buyer_AWS_Account__c):
3. Term length field path:
4. Payment frequency field path + the values it can take:
5. Amount field path (skip if it's just `Amount`):
6. Start date field path (skip if it's just `CloseDate`):
7. Does your team use CPPO offers? (yes/no — keeps or strips the CPPO branch)
8. Does your team use ABO renewals? (yes/no — keeps or strips the ABO branch)
```
After the user replies, generate the **complete unified script** in ONE message (still with all enabled branches present), then offer Save / Edit / Start over.
### Step 4 — On user approval
`invoke_action({ action_id: "set_offer_script", script: "<full script>" })`. The script must be the COMPLETE replacement — there is no patch/append. The handler automatically pushes the previous editor content onto an undo stack before replacing.
After saving, post this exact reminder so the user knows two-step persistence AND that undo is available:
> *"Saved into the editor. Two more steps before this is live:*
> *1. Click **Test** at the top of the dialog to validate the script against a real Salesforce record.*
> *2. Once the test result looks right, click the **Save** button at the top of the dialog (next to Test) to persist the whole config to the backend. The script is only in the in-memory editor right now — closing the dialog without that final Save loses it.*
> *If the replacement is wrong, click **Undo** in the toast that just appeared, or say "undo" and I'll restore the previous version."*
### Step 4.1 — Undo on request
If the user says "undo" / "revert" / "go back" / "restore previous" / similar, call `invoke_action({ action_id: "undo_offer_script" })`. The action pops the most recent pre-AI snapshot off the stack (up to 10 deep) and restores it. After it returns, briefly confirm what was reverted (e.g. *"Reverted to the previous script (N chars)."*).
### Anti-pattern — DO NOT do this
❌ Asking "Which AWS offer archetype?" — there is no per-conversation archetype; the script branches on `$OfferType` at runtime.
❌ Asking "which Suger Product?" → user answers → asking "which buyer field?" → user answers → asking "which term field?" → user answers → finally drafting. **This is the exact behavior to avoid.** Every clarification question above the first one must be batched into the single Step 2 message. If the user answers with partial info, fill the gaps with TODOs and produce the scaffold anyway — do not loop back to ask the missing pieces one by one.
❌ Asking "ok so just to confirm: for the buyer AWS account ID, you mean…?" — if their answer is ambiguous, put a TODO with a comment quoting their answer, and let them fix it in Edit further.
❌ Producing a partial / incomplete script and offering "I can keep going" — always produce the COMPLETE script (standard body + CPPO branch + ABO branch + all helper functions) in one turn.
❌ Stopping after Save to editor without telling the user to click the dialog's top-right Save button — they will lose the script when they close the dialog.
SHA-256: df2757889c687f9f45389276b5ce63036cbcb2caf9e0d91c28af49bb026f6680