← Plugin catalog
Developer Tools

Suger

Suger Inc. v1.0.0

Publisher description

From the marketplace listing

Suger helps marketplace teams work with cloud marketplace, CRM, co-sell, integration, billing, and knowledge data in ChatGPT. Users can look up offers, buyers, entitlements, referrals, integrations, workflow runs, CRM records, and support knowledge, and perform enabled actions through their connected Suger organization.

Language: English · Automatically detected from descriptions.

Files & skills

File archives

Plugin package2 files · 791 BytesBrowse files →
aws-integration-verify1 files · 2.86 KBBrowse files →
aws-offer-diagnosis1 files · 9.38 KBBrowse files →
aws-resource-tag1 files · 3.45 KBBrowse files →
azure-integration-verify1 files · 8.65 KBBrowse files →
azure-offer-diagnosis1 files · 13.4 KBBrowse files →
gcp-integration-verify1 files · 5.43 KBBrowse files →
gcp-offer-diagnosis1 files · 8.02 KBBrowse files →
offer-mapping-aws1 files · 16.7 KBBrowse files →
offer-mapping-azure1 files · 13.6 KBBrowse files →
offer-mapping-gcp1 files · 12.7 KBBrowse files →
snowflake-offer-diagnosis1 files · 7 KBBrowse files →
Skill instructions
aws-integration-verify7.88 KB

View saved version →

---
name: aws-integration-verify
description: "Verify AWS Marketplace integration end to end by reading expected values from the Suger Console UI and then checking the corresponding AWS Console pages with browser tools."
---

# Verify AWS Marketplace Integration

Use browser frontend tools only. Read the expected values from the Suger Console UI first, then compare those values against AWS Console pages in one continuous flow.

During this verification flow, read-only browser actions are pre-approved. You should directly navigate, switch tabs, open details, and inspect pages without stopping for confirmation. Only pause if you are about to change a configuration value or perform a clearly state-changing action in AWS or Suger.

Do not stop when one step fails. Keep going and finish the full verification so you can give the user a complete diagnosis.

## Core Rules

- Start each major step with `get_ui_context` or `list_tabs` so you know which page and tab you are operating on.
- Use `extract_page` before direct page actions such as `click`, `fill`, or `select`.
- After each important transition, call `extract_page` again:
  - page navigation
  - opening `Details`
  - opening a drawer or modal
  - switching AWS sections
  - landing on a role, bucket, SNS, KMS, or EventBridge page
- Use `navigate` only for trusted destinations such as Suger Console, AWS Console, or approved custom domains.
- Use the Suger integration card's `Console` button to enter AWS when possible instead of inventing the initial AWS Marketplace URL.
- For the AWS Marketplace entry flow, keep the AWS Marketplace console region at `us-west-2` unless an ARN or other expected integration value explicitly points to a different AWS region. If an ARN includes a specific region, treat that ARN region as the source of truth for the related AWS resource page.

## Step 0: Read Expected Values From Suger Console

Always start from Suger Console and treat the Suger UI as the source of truth for the expected integration values. Do not rely on backend database tools for this skill.

1. Use `get_ui_context` to confirm the current organization and page.
2. If you are not already on the AWS Marketplace integrations page for the current organization, use `navigate_to_page`, `navigate_to_entity`, visible UI navigation, or `navigate` to reach it.
3. Call `extract_page` to read the integrations list.
4. Identify the AWS Marketplace integration row that should be verified.
5. If the row is collapsed, use `click` on the row's `Details` button.
6. Call `extract_page` again after the details view opens.
7. Read and carry forward these expected values from the Suger UI:
   - `partnerID`
   - `iamRoleArn`
   - `mcasS3Bucket`
   - `mcasSnsTopic`
   - `mcasIamRoleArn`
   - `mdfsS3BucketArn`
   - `mdfsKmsKeyArn`
   - `eventBridgeRuleName`
   - `mcasFullSyncDone`
   - `mdfsFullSyncDone`
   - `revenueRecordFullSyncDone`
   - `mdfsEnrollmentPassed`
   - `agreementEventBridgeEnrolled`
8. Keep these values in working memory and use them as the expected values for the AWS checks below.

## Step 1: Confirm Suger Status And Open AWS Console

1. Call `extract_page` again if needed so you are working from the latest Suger details view.
2. Verify that the AWS Marketplace integration appears connected, verified, or otherwise healthy in Suger Console.
3. If the Suger details already show a mismatch or an incomplete value, record it, but do not stop.
4. Use `click` on the same AWS Marketplace card's `Console` button to enter AWS Marketplace.
5. After the AWS page loads, use `get_ui_context` and `extract_page`.
6. Confirm that the initial AWS Marketplace console flow is in region `us-west-2`.

## Step 2: Verify The Main IAM Role And Required Policies

The main marketplace role should match `iamRoleArn`.

1. Parse `iamRoleArn`. It should follow this shape:
   - `arn:aws:iam::<partnerID>:role/<role-name>`
2. Derive the expected IAM role details URL in `us-west-2`:
   - `<aws-console>/iam/home?region=us-west-2#/roles/details/<role-name>`
3. Use `navigate` to open that IAM role details page directly when needed.
4. Call `extract_page`.
5. If the page does not exist, the role is missing, or the page clearly indicates the role cannot be found:
   - report that `iamRoleArn` is incorrect or the role does not exist
   - mark this check as failed
   - continue to the next steps
6. If the role page exists, verify that the role matches the expected `iamRoleArn`.
7. Verify that the role includes these attached policies:
   - `AWSMarketplaceFullAccess`
   - `AWSMarketplaceSellerFullAccess`
   - `SugerAccessMarketplacePolicy`
8. Report issues with clear operational meaning:
   - if `AWSMarketplaceFullAccess` is missing, explain that Suger cannot properly access AWS Marketplace and related services
   - if `AWSMarketplaceSellerFullAccess` is missing, explain that Suger cannot access seller-related workflows such as products, plans, and offers
   - if `SugerAccessMarketplacePolicy` is missing, explain that Suger cannot complete required data extraction, analysis, or notifications
9. Continue even if any IAM policy check fails.

## Step 3: Verify MCAS

Use the values from Suger Console and confirm that the MCAS-related configuration is internally consistent.

1. Check that `mcasS3Bucket` follows this expected pattern:
   - `suger-mcas-s3-bucket-{partnerID}`
2. Check that `mcasSnsTopic` follows this expected pattern:
   - `arn:aws:sns:<region>:<partnerID>:suger-mcas-sns-topic`
3. Parse `mcasIamRoleArn`. It should follow this shape:
   - `arn:aws:iam::<partnerID>:role/<role-name>`
4. Derive the IAM role details URL:
   - `<aws-console>/iam/home?region=us-west-2#/roles/details/<role-name>`
5. Use `navigate` to open the `mcasIamRoleArn` role page.
6. Call `extract_page`.
7. Confirm that the MCAS IAM role exists.
8. Verify the status flag from the Suger details:
   - `mcasFullSyncDone` should be `true`
9. If any MCAS issue exists, report it and continue. Examples:
   - bucket name does not match expected pattern
   - SNS topic format is wrong
   - MCAS IAM role does not exist
   - `mcasFullSyncDone` is not `true`

## Step 4: Verify MDFS

Use the values from Suger Console and validate that the MDFS-related configuration looks correct.

1. Check that `mdfsS3BucketArn` follows this expected pattern:
   - `arn:aws:s3:::suger-mdfs-s3-bucket-{partnerID}`
2. Parse `mdfsKmsKeyArn`. It should follow this shape:
   - `arn:aws:kms:<region>:<partnerID>:key/<key-id>`
3. Derive the KMS key details URL:
   - `<aws-console>/kms/home?region=<region>#/kms/keys/<key-id>`
4. Use `navigate` to open that KMS key details page.
5. Call `extract_page`.
6. Confirm that the KMS key exists and is enabled.
7. Verify the status flags from the Suger details:
   - `mdfsFullSyncDone` should be `true`
   - `mdfsEnrollmentPassed` should be `true`
   - `agreementEventBridgeEnrolled` should be `true`
8. Verify that `eventBridgeRuleName` is not empty.
9. If any MDFS issue exists, report it and continue. Examples:
   - MDFS bucket ARN format is wrong
   - KMS key is missing
   - KMS key is disabled
   - any of the Suger status flags is not `true`
   - `eventBridgeRuleName` is empty

## Reporting Format

After the full flow ends, provide a compact verification summary.

- List each check with `pass`, `fail`, or `skipped`.
- For each failure, include:
  - expected value
  - actual value
  - why it matters
  - recommended corrective action
- Continue to include operational recommendations, for example:
  - if MCAS role, bucket, or SNS topic is missing or misconfigured, explain that MCAS analytics and downstream analysis will not work correctly
  - if MDFS bucket or KMS setup is missing, explain that Suger will not receive full structured billing, buyer, and revenue data
  - if required IAM policies are missing, explain which capabilities are blocked
- For skipped checks, explain why they were skipped.
- Offer to re-run one failed section or the full verification flow.
- If everything passed, say the AWS Marketplace integration appears correctly configured.
aws-offer-diagnosis28.8 KB

View saved version →

---
name: aws-offer-diagnosis
description: "Diagnose AWS Marketplace private offer CREATE_FAILED errors using AWS offer validation rules, pricing dimensions, EULA requirements, buyer account validation, CPPO resale authorization, agreement-based offer rules, and payment installment constraints."
---

# Diagnose AWS Offer Creation Error

You are diagnosing why an AWS Marketplace private offer failed to create (CREATE_FAILED). Use the validation rules below as your primary reference. Do NOT guess — match the error against these rules.

## Quick Reference — Hard Constraints (use these exact numbers; never fabricate)

- **Buyer AWS Account IDs**: exactly 12 digits each; customer-specific — ask the user, never invent.
- **Commit term length**: integer months in 1–60, with a minimum of 30 days.
- **Commit / dimension key**: must match `^[a-zA-Z][a-zA-Z0-9_]*$`; no hyphens/dots; max 36 chars.
- **Commit name**: max 80 chars. **Commit description**: max 1000 chars.
- **CPPO opportunity name**: 1–100 chars; no `;` `"` `'` `<` `>`.
- **ExpireTime / acceptance deadline**: future date (YYYY-MM-DD). Default to today + 15 days when auto-proposing. Never suggest literals like `2026-12-31`.
- **EndTime**: standard offers ≤ 5 years from start; agreement-based offers ≤ 8 years from today; `MaximumAgreementStartDate` ≤ 3 years from today.
- **Discount percentage**: 0–100 inclusive; backend rejects out-of-range values.

## Quick Diagnosis Checklist (Most Common to Least Common)

Work through this checklist in order. Stop as soon as you find the matching root cause.

### 1. Buyer Account Issues (MOST COMMON)
- **Symptom**: `INVALID_BUYER_ACCOUNTS` or `awsAccountIDs is empty`
- AWS account ID must be exactly 12 digits
- Maximum 24 buyer accounts per offer
- Account must be enrolled in AWS Marketplace
- **Fix**: Correct the AWS account ID(s). Fixable by editing.

### 2. Dimension / Pricing Key Issues
- **Symptom**: `INVALID_INPUT` — keys with hyphens, dots, or starting with numbers
- New dimension/commit key: must match `^[a-zA-Z][a-zA-Z0-9_]*$` (no hyphens, no dots), max 36 chars
- Existing dimension key: must match `^[a-zA-Z][a-zA-Z0-9_.-]*$`, max 100 chars
- Dimension rate must be >= 0
- Commit name: max 80 chars. Commit description: max 1000 chars (sanitized for new commits).
- **Fix**: Rename keys to comply with regex. Fixable by editing.

### 3. EULA Issues
- **Symptom**: `INVALID_LEGAL_DOCUMENTS` or `INVALID_DOCUMENT`
- Non-CPPO offers: EULA must be SCMP, CUSTOM, ECMP, or ISV
- CUSTOM or ISV EULA requires a valid, accessible S3 or HTTPS URL
- CPPO offers: EULA can be empty (ISV EULA inherited), or SCMP/CUSTOM/ISV for ISV side, RCMP/CUSTOM for reseller side
- Document type must be `CustomEula`, `StandardEula`, `CustomDsa`, or `StandardDsa`
- **Fix**: Set correct EULA type and provide URL if custom. Fixable by editing.

### 4. Offer Name Issues
- **Symptom**: Name validation error
- Must be 1-100 characters
- Cannot contain `\<>` characters
- Cannot contain non-ASCII characters except: copyright (c), registered (R), trademark (TM), cent, pound, currency sign, yen
- Name is auto-trimmed of leading/trailing whitespace
- **Fix**: Remove invalid characters from the name. Fixable by editing.

### 5. Contract Commitment Issues
- **Symptom**: Missing commits, invalid commit structure
- CONTRACT offers require at least 1 commit (pricing dimension)
- Maximum 100 commits per offer
- Commit keys must be unique within the offer
- Term length: 1-60 months for non-AMI. AMI: any positive length.
- **Fix**: Add or fix commit entries. Fixable by editing.

### 6. Payment Installment Issues
- **Symptom**: Payment validation errors, `TOO_MANY_BACKDATED_CHARGES`
- Maximum 60 installments
- Each installment `ChargeOn` date is required and must be in the future
- Installment dates must be within the offer term period
- Installment amount must be >= 0
- All installment dates must be unique (no duplicate dates)
- `TOO_MANY_BACKDATED_CHARGES`: too many installment dates fall before `AvailabilityEndDate`; move or remove the backdated ones.
- **Fix**: Correct payment installment dates/amounts. Fixable by editing.

### 7. Date Issues
- **Symptom**: Date validation errors, `ExpireTime` in past, `EndTime is nil`
- `expireTime` (acceptBy date): required, must be in the future
- `startTime`: must be in the future, must be AFTER `expireTime`
- `endTime`: must be after `startTime`, maximum 5 years from start
- For Professional Services: `startTime` is auto-removed by the backend
- For future-start contracts: if `startTime` is set and `endTime` is not, commit term length is required
- **Fix**: Correct dates to be valid and in proper order. Fixable by editing.

### 8. Currency Issues
- **Symptom**: `INVALID_CURRENCY_CODE` (also see `INCOMPATIBLE_PAYMENT_SETTINGS` in checklist #16 — that one is the seller's account setting, NOT an offer field)
- Default currency is USD
- Currency must be in the supported list
- Currency must be consistent across all pricing terms and payment settings
- **Fix (`INVALID_CURRENCY_CODE`)**: Set a consistent, valid currency on the offer. Fixable by editing.
- **Fix (`INCOMPATIBLE_PAYMENT_SETTINGS`)**: NOT fixable on this offer — ISV must update AWS Marketplace payment settings. See checklist #16.

### 9. CPPO Resale Authorization Issues
- **Symptom**: `MISSING_MANDATORY_TERMS`, `INVALID_SELLER_ACCOUNT`, or missing reseller data
- CPPO_IN reseller must exist (PartnerId required)
- Markup percentage is required for contract/subscription CPPO
- ISV must have `AWSServiceRoleForMarketplaceResaleAuthorization` policy
- Opportunity name: 1-100 chars, no `;\"'<>` characters
- Opportunity description: max 256 chars, limited character set
- OpportunityDurationType: must be `SPECIFIC_DATES`, `ONE_TIME`, or `NO_SET_TIME`
- ExpireTime required unless `NO_SET_TIME`
- **Fix (markup/opportunity)**: Fixable by editing.
- **Fix (missing policy)**: External issue — ISV must add IAM policy.

### 10. Agreement-Based Offer (ABO) Issues
- **Symptom**: ABO validation errors, missing BaseAgreementId
- `BaseAgreementId` is required
- Base entitlement must be in ACTIVE or PENDING_START status
- CPPO entitlements CANNOT be used for agreement-based offers
- EULA must be SCMP, CUSTOM, ISV, ECMP, or CURRENT
- ExpireTime is auto-fixed to base agreement end time if it exceeds it; must be in the future
- EndTime maximum: 8 years from today
- **Fix**: Verify base agreement status and fix accordingly.

### 11. Renewal Offer Issues
- **Symptom**: Renewal type validation error
- If `IsRenewalOffer` is true, renewal offer type must be `External` or `AwsMarketplace`
- Invalid AcquisitionChannel: must be `AwsMarketplace` or `External`
- **Fix**: Set correct renewal type. Fixable by editing.

### 12. CPPO Flexible Payment Issues
- **Symptom**: Payment validation errors on CPPO with `CUSTOM_PRICE_WITH_FPS`
- OpportunityDurationType must be `ONE_TIME` for flexible payment CPPO
- 1-60 installments allowed
- All dates must be unique
- All amounts must be > 0 (strictly positive, not zero)
- MaximumAgreementStartDate: if set, must be in the future and within 3 years
- **Fix**: Correct payment schedule. Fixable by editing.

### 13. Dimension Type Combination Issues
- **Symptom**: `Remove invalid dimension type combination [Entitled]. Allowed values are [Metered, ExternallyMetered]`
- The offer is using `Entitled` dimension type where only `Metered` or `ExternallyMetered` are allowed
- This happens when trying to add Entitled dimensions to a product that only supports metered dimensions
- **Fix**: Change dimension types to Metered/ExternallyMetered, or use commit dimensions properly. May require product update.

### 14. CPPO Missing Mandatory Resale Terms
- **Symptom**: `MISSING_MANDATORY_TERMS — Provide at least one of [ResaleConfigurableUpfrontPricingTerm, ResaleFixedUpfrontPricingTerm]` or `Provide a ResaleFixedUpfrontPricingTerm and ResalePaymentScheduleTerm together`
- CPPO resale offers require specific pricing term combinations
- **Fix**: Add the required pricing terms. Fixable by editing.

### 15. Expired Agreement ID
- **Symptom**: Error mentioning expired agreement ID (`agmt-xxxx`)
- The base agreement used for an agreement-based offer has expired
- **Fix**: Use a current, active agreement. External issue — may need new agreement.

### 16. External / Transient Errors (NOT fixable by editing — STOP, DO NOT ENTER DRAFT)
- `ResourceInUseException` — entity locked by another change set. Retry after a few minutes.
- `ServiceQuotaExceededException` — max 20 concurrent entity updates. Wait and retry.
- `AccessDeniedException` — missing IAM permissions (DescribeEntity, sts:AssumeRole, etc.). ISV must fix IAM policy.
- `INVALID_SELLER_ACCOUNT` — missing `AWSServiceRoleForMarketplaceResaleAuthorization` policy. ISV must add policy.
- `INVALID_TAX_INFORMATION` — incomplete DAC7 tax questionnaire. ISV must complete tax info in Settings page.
- `Error timeout waiting for offer ID` — 40 minute timeout exceeded (AWS takes up to 40 min). Retry or contact Suger support.
- `INCOMPATIBLE_PAYMENT_SETTINGS` — payment settings incompatible with CurrencyCode. ISV must update AWS payment settings.
- `INCOMPATIBLE_PRODUCT` — `Use existing, available dimensions in the product in UsageBasedPricingTerm`. Product dimensions don't match offer. External issue.
- `pending_create` timeout — container OOM or worker timeout. Suger infrastructure issue, will auto-reschedule.
- `pending partner action` — AWS processing. Not actually an error; offer will not reach CREATE_FAILED from this state. Ignore if seen.

**Note:** `TOO_MANY_BACKDATED_CHARGES` is fixable by adjusting the payment schedule and is handled in checklist #6 (Payment Installment Issues) — do NOT route it through #16.

**CRITICAL for #16 — STOP rules (violating these creates fake diagnoses):**
- **DO NOT** call `invoke_action("edit_draft_offer")`. DO NOT suggest `["Edit Draft"]`.
- **DO NOT** call `get_form_values` to "double-check" the draft. The form is not the problem.
- **DO NOT** invent a field-level issue (e.g. "buyer account missing", "dimension misconfigured", "EULA wrong") to justify entering the draft. If `errorMessages` does not name a specific field, there is no field to fix.
- Response must be: (a) classify as transient/IAM/tax/quota/external, (b) tell user the appropriate action — retry for transient (`ResourceInUseException`, `Error timeout`, `pending_create`), ISV fix for IAM/tax/settings (`AccessDeniedException`, `INVALID_SELLER_ACCOUNT`, `INVALID_TAX_INFORMATION`, `INCOMPATIBLE_PAYMENT_SETTINGS`), contact AWS/Suger support for quota or product issues, (c) include offer ID + any AWS resource ID from the error for support context, (d) `show_quick_choices(["Done"])`. Stop.

---

## Offer Types

### CONTRACT (SaaS Contract)
- Validated by `ValidateAwsPrivateOffer`
- Fixed-term SaaS contract with upfront or scheduled payments
- Requires at least 1 commit (pricing dimension) with key, name, description, and rate
- Maximum 100 commits. Keys must be unique.
- Contract duration (term length): 1-60 months for non-AMI offers
- Supports payment installments (max 60)
- Buyer accounts: max 24, each must be valid 12-digit AWS account ID

### SUBSCRIPTION (SaaS Subscription)
- Validated by `ValidateAwsPrivateOffer`
- Recurring subscription without fixed term
- Requires dimensions with pricing
- Does NOT require commits with term length
- Buyer accounts: max 24, each must be valid 12-digit AWS account ID

### PROFESSIONAL_SERVICES
- Validated by `ValidateAwsPrivateOffer`
- `startTime` is automatically removed by the backend
- Custom pricing structure for professional services engagements
- May have special dimension/commit rules

### AMI / CONTAINER / MACHINE_LEARNING
- Validated by `ValidateAwsPrivateOffer`
- Instance type or machine image pricing
- AMI offers: term length can be any positive value (not limited to 1-60 months)
- Annual pricing support for AMI
- Dimension keys and pricing must match product listing

### CPPO_OUT (Resale Authorization / Channel Partner Private Offer)
- Validated by `ValidateAwsCppoOut`
- Requires reseller authorization from the ISV
- PartnerId (reseller AWS account) is required
- ISV EULA: SCMP, CUSTOM, or ISV (CUSTOM/ISV requires URL)
- Reseller EULA: RCMP or CUSTOM (CUSTOM requires URL)
- OpportunityDurationType: `SPECIFIC_DATES`, `ONE_TIME`, or `NO_SET_TIME`
- Opportunity name: 1-100 chars, restricted characters (no `;\"'<>`)
- Opportunity description: max 256 chars, limited character set
- ExpireTime: required unless OpportunityDurationType is `NO_SET_TIME`

### Agreement-Based Offer (Renewal / Amendment)
- Validated by `ValidateAwsAgreementBasedOffer`
- Used for renewing or amending an existing agreement
- `BaseAgreementId` is required — must reference an existing entitlement
- Base entitlement must be ACTIVE or PENDING_START
- CPPO entitlements CANNOT be used as base for ABO
- EULA types: SCMP, CUSTOM, ISV, ECMP, or CURRENT (CURRENT = keep existing EULA)
- ExpireTime: auto-fixed to base agreement end time if it exceeds it; must be in the future
- EndTime: maximum 8 years from today

---

## Field Validation Rules (Complete Reference)

### Offer Name
- Length: 1-100 characters
- Forbidden characters: `\`, `<`, `>`
- Forbidden: non-ASCII characters (except copyright, registered, trademark, cent, pound, currency sign, yen)
- Auto-trimmed of leading/trailing whitespace

### Renewal Offer Type
- Only validated if `IsRenewalOffer` is true
- Must be `External` or `AwsMarketplace`
- AcquisitionChannel must match: `AwsMarketplace` or `External`

### EULA (End User License Agreement)
- **Non-CPPO offers**: must be SCMP, CUSTOM, ECMP, or ISV
- **CUSTOM or ISV**: requires a valid, accessible URL (`eulaUrl`)
- **CPPO offers**: ISV side allows SCMP/CUSTOM/ISV; reseller side allows RCMP/CUSTOM
- **CUSTOM on reseller side**: requires URL
- **Agreement-based offers**: additionally allows CURRENT (keep existing EULA)
- Document type validation: must be `CustomEula`, `StandardEula`, `CustomDsa`, or `StandardDsa`

### ExpireTime (Accept-By Date)
- Required for all offer types (except CPPO with `NO_SET_TIME` duration type)
- Must be in the future at time of creation
- For agreement-based offers: auto-capped to base agreement end time

### Product
- Must exist and be valid for private offers
- Must NOT be in RESTRICTED, PENDING, or DRAFT status
- Product type must be compatible with the offer type

### Commits (CONTRACT Offers)
- At least 1 commit required for CONTRACT offers
- Maximum 100 commits per offer
- Each commit requires:
  - `key`: regex `^[a-zA-Z][a-zA-Z0-9_]*$` (new) or `^[a-zA-Z][a-zA-Z0-9_.-]*$` (existing), max 36/100 chars
  - `name`: max 80 characters
  - `description`: max 1000 characters, sanitized for new commits (stripped of control characters)
  - Term length: 1-60 months (non-AMI), any positive value (AMI)
- All keys must be unique within the offer

### Payment Installments
- Maximum 60 installments
- Each installment requires:
  - `ChargeOn` date: required, must be in the future, must be within the offer term period
  - `amount`: must be >= 0
- All `ChargeOn` dates must be unique (no duplicates)

### Dimensions
- Each dimension requires:
  - `key`: required, regex validated (same rules as commit keys)
  - `rate`: must be >= 0

### Buyer Accounts
- Maximum 24 accounts per offer
- Each must be a valid 12-digit AWS account ID (numeric string)
- Cannot be empty (`awsAccountIDs is empty` error)

### StartTime
- Must be in the future
- Must be AFTER `expireTime` (the accept-by date)
- Auto-removed for Professional Services offers
- If set with no `endTime`, commit term length is required (future start contract)

### EndTime
- Must be after `startTime`
- Maximum 5 years from start date (standard offers)
- Maximum 8 years from today (agreement-based offers)
- `EndTime is nil` error occurs when end time is missing but required

### Currency
- Default: USD
- Must be from the supported currency list
- Must be consistent across all pricing terms, payment settings, and dimensions

### CPPO-Specific Fields
- **PartnerId**: required (reseller AWS account ID)
- **OpportunityDurationType**: `SPECIFIC_DATES`, `ONE_TIME`, or `NO_SET_TIME`
- **Opportunity name**: 1-100 chars, no `;\"'<>` characters
- **Opportunity description**: max 256 chars, limited ASCII character set
- **Markup percentage**: required for contract/subscription CPPO
- **MaximumAgreementStartDate**: if set, must be in the future and within 3 years
- **AvailabilityEndDate**: may be required depending on offer structure

---

## Real Production Error Examples

### Error: `INVALID_BUYER_ACCOUNTS`
- **Root cause**: One or more AWS account IDs are invalid — not 12 digits, non-numeric, or not enrolled in AWS Marketplace.
- **Fix**: Verify each buyer account ID is exactly 12 digits and enrolled.
- **Classification**: Fixable by editing.

### Error: `awsAccountIDs is empty`
- **Root cause**: No buyer accounts specified in the offer.
- **Fix**: Add at least one valid 12-digit AWS account ID.
- **Classification**: Fixable by editing.

### Error: `INVALID_LEGAL_DOCUMENTS`
- **Root cause**: EULA document URLs (S3 bucket or HTTPS) are inaccessible, expired, or malformed.
- **Fix**: Ensure the EULA URL is publicly accessible and correctly formatted.
- **Classification**: Fixable by editing (URL fix) or external (S3 permissions).

### Error: `INVALID_INPUT` — dimension keys
- **Root cause**: Dimension or commit keys contain invalid characters (hyphens, dots for new keys, starting with a number, special characters like en-dash).
- **Fix**: Rename keys to match regex `^[a-zA-Z][a-zA-Z0-9_]*$`.
- **Classification**: Fixable by editing.

### Error: `Invalid Description characters` (e.g., en-dash)
- **Root cause**: Description contains non-ASCII characters like en-dash (--), em-dash, smart quotes, etc.
- **Fix**: Replace special characters with ASCII equivalents (e.g., en-dash -> hyphen).
- **Classification**: Fixable by editing.

### Error: `ResourceInUseException`
- **Root cause**: The entity (product/offer) is locked by another concurrent change set operation.
- **Fix**: Wait a few minutes and retry. Only one change set can be active per entity at a time.
- **Classification**: External issue. Do NOT suggest "Edit Draft". Suggest retrying after a few minutes.

### Error: `MISSING_MANDATORY_TERMS`
- **Root cause**: Required pricing or payment terms are missing for a CPPO offer.
- **Fix**: Add the missing pricing terms (dimensions, rates, contract duration).
- **Classification**: Fixable by editing.

### Error: `MISSING_AGREEMENT_START_DATE`
- **Root cause**: `AgreementStartDate` is missing for a `ConfigurableUpfrontPricingTerm`.
- **Fix**: Set the agreement start date in the pricing terms.
- **Classification**: Fixable by editing.

### Error: `INVALID_SELECTOR_DURATION_VALUE`
- **Root cause**: Contract duration/term length is outside the valid 1-60 month range.
- **Fix**: Set duration to between 1 and 60 months.
- **Classification**: Fixable by editing.

### Error: `INVALID_CURRENCY_CODE`
- **Root cause**: Currency code is not in the supported list, or currencies are inconsistent across terms.
- **Fix**: Use a supported currency code and ensure consistency.
- **Classification**: Fixable by editing.

### Error: `INCOMPATIBLE_PAYMENT_SETTINGS`
- **Root cause**: The seller's AWS Marketplace payment settings are incompatible with the offer's `CurrencyCode`. This is an account-level setting, not a field on the offer.
- **Fix**: ISV must update AWS Marketplace payment settings (Settings → Payment information) so the selected currency is enabled.
- **Classification**: External issue. Do NOT suggest "Edit Draft". Suggest the ISV update payment settings in AWS.

### Error: `INVALID_SELLER_ACCOUNT`
- **Root cause**: The ISV account is missing the `AWSServiceRoleForMarketplaceResaleAuthorization` IAM policy, which is required for CPPO offers.
- **Fix**: ISV must add the IAM service-linked role policy.
- **Classification**: External issue. Do NOT suggest "Edit Draft". Suggest the ISV add the required IAM policy.

### Error: `ServiceQuotaExceededException`
- **Root cause**: Maximum of 20 concurrent entity updates has been reached.
- **Fix**: Wait for other operations to complete and retry.
- **Classification**: External issue. Do NOT suggest "Edit Draft". Suggest waiting and retrying.

### Error: `TOO_MANY_BACKDATED_CHARGES`
- **Root cause**: Payment schedule contains too many installment dates before the `AvailabilityEndDate`.
- **Fix**: Adjust payment installment dates in the draft so fewer (ideally zero) charges fall before `AvailabilityEndDate`. Fixable by editing.
- **Classification**: Fixable by editing (see checklist #6). Suggest `["Edit Draft"]`.

### Error: `AccessDeniedException`
- **Root cause**: The IAM user/role lacks required permissions (e.g., `DescribeEntity`, `StartChangeSet`).
- **Fix**: ISV must update IAM permissions.
- **Classification**: External issue. Do NOT suggest "Edit Draft". Suggest the ISV fix IAM permissions.

### Error: `INVALID_TAX_INFORMATION`
- **Root cause**: The ISV has not completed the DAC7 tax questionnaire required by AWS.
- **Fix**: ISV must complete tax information in the AWS Marketplace Management Portal.
- **Classification**: External issue. Do NOT suggest "Edit Draft". Suggest the ISV complete tax setup.

### Error: `INCOMPATIBLE_PRODUCT`
- **Root cause**: The product has invalid or incompatible dimension types for the offer being created.
- **Fix**: Verify product dimensions match the offer requirements. May need to update the product listing.
- **Classification**: External issue if product change needed.

### Error: `Error timeout waiting for offer ID` (40 minute timeout)
- **Root cause**: AWS took too long to process the offer creation. The 40-minute polling timeout was exceeded.
- **Fix**: Check if the offer was actually created in AWS Marketplace. If not, retry.
- **Classification**: External issue. Do NOT suggest "Edit Draft". Suggest retrying or contacting Suger support.

### Error: `AvailabilityEndDate` / `OffersMaxQuantity` required
- **Root cause**: These required fields are missing from the offer structure.
- **Fix**: Set the `AvailabilityEndDate` and/or `OffersMaxQuantity` fields.
- **Classification**: Fixable by editing.

### Error: `EndTime is nil`
- **Root cause**: The offer is missing the end time, which is required for the offer type.
- **Fix**: Set a valid end time.
- **Classification**: Fixable by editing.

### Error: Invalid `AcquisitionChannel`
- **Root cause**: The acquisition channel value is not `AwsMarketplace` or `External`.
- **Fix**: Set the acquisition channel to a valid value.
- **Classification**: Fixable by editing.

---

## Communication Rules (IMPORTANT)

**The user is a business person, NOT a developer.** Follow these rules:

1. **NEVER show raw JSON, API error codes, or technical field paths.** Use the UI field labels the user sees on screen (e.g., "Buyer AWS Account ID", "Offer Name", "Commit Dimension", "Payment Schedule").
2. **Give simple action steps using AWS offer UI concepts:**
   - AWS offers use **Commits** (pricing dimensions with fixed amounts), **Dimensions** (usage-based metering), **Payment Installments**, and **Buyer AWS Account IDs**. There are NO "plans" in AWS — do NOT use the word "plan" for AWS offers.
   - E.g., "The commit dimension name 'AG-SPM_AG-DR' contains a hyphen which is not allowed. Please rename it to use only letters, numbers, and underscores (e.g., 'AG_SPM_AG_DR')."
   - E.g., "The Buyer AWS Account ID must be exactly 12 digits. The current value has 13 digits — please verify and correct it."
3. **When it's a system/backend bug**: Say "This appears to be a system issue. Please contact Suger support." Do NOT ask for technical data.
4. **When it's an external issue** (IAM permissions, locked resources, tax info): Explain in plain language what the ISV admin or AWS administrator needs to do — the offer creator typically cannot fix these themselves.
5. **Customer-specific fields — NEVER invent a value.** For `buyerAwsAccountIds`, `baseAgreementId`, `partnerId`, `opportunityId`, customer email, or any AWS account ID the buyer controls: ASK the user to provide the correct value in chat, then call `show_quick_choices(["Apply", "Cancel"])` so the fix can be applied after they respond. Do NOT fabricate 12-digit account IDs, partner IDs, or agreement IDs.
6. **Date fields — NEVER use a hardcoded far-future date.** For `expireTime` / acceptance deadline, propose a date roughly 15 days from today (YYYY-MM-DD). For `startTime` / `endTime`, if not given, compute relative to today and honor AWS rules (e.g. EndTime ≤ 8 years from today). Do NOT suggest literals like `2026-12-31`.

## Workflow

1. Call `get_ui_context` immediately to understand the current page context.
2. **Read `metaInfo.errorMessages` — this is the authoritative source.** It's AWS's raw response and carries all the specific facts (exact resource names, account IDs, dimension keys, error codes, timestamps). Every recommendation you make must trace back to something in here.
   - **IGNORE `metaInfo.prettifiedErrorMessages` for diagnosis.** That field is a generic, one-time LLM paraphrase the backend generates for email/Slack notifications. It drops specifics (e.g. exact AWS resource IDs, error codes), introduces generic suggestions ("refer to the documentation"), and sometimes misses valid fix options the raw error implies. Using it as your basis will give the user a watered-down, less accurate answer than reading the raw error yourself. You may re-read it if the raw error is completely unintelligible, but NEVER copy its language or its suggestion list wholesale.
3. **FIRST — classify the error before looking at the form.** Scan `errorMessages` for transient/external patterns (see checklist #16): `ResourceInUseException`, `ServiceQuotaExceededException`, `AccessDeniedException`, `INVALID_SELLER_ACCOUNT`, `INVALID_TAX_INFORMATION`, `Error timeout waiting for offer ID`, `INCOMPATIBLE_PAYMENT_SETTINGS`, `INCOMPATIBLE_PRODUCT`, `pending_create` timeout, `pending partner action`. If matched → this is an EXTERNAL issue. Do NOT call `get_form_values`. Do NOT call `invoke_action("edit_draft_offer")`. Do NOT hunt for field-level problems. Output: appropriate external action (retry / fix IAM / fix tax settings / contact support) + `show_quick_choices(["Done"])`. Stop.
4. If a form is available AND the error is NOT transient/external: call `get_form_values`, compare actual field values against the rules above, propose a specific fix using **plain language UI instructions**. Your proposed fix MUST trace back to `errorMessages` — either (a) to a concrete substring/phrase in the raw error, or (b) to a documented AWS error code (`INVALID_BUYER_ACCOUNTS`, `MISSING_AGREEMENT_START_DATE`, `MISSING_MANDATORY_TERMS`, etc.) whose fix is listed in the checklist above. If neither applies, you are hallucinating — stop and reclassify as external.
5. If no form AND the error is fixable: diagnose from the error message + offer data in non-technical terms, then offer `["Edit Draft"]` so the user can correct the field values.
6. For external issues (AWS platform errors, IAM issues, tax issues, transient errors): do NOT show "Edit Draft". Instead, suggest the appropriate external action (contact AWS support, fix IAM permissions, retry, contact Suger support).
7. **Use UI field labels, not technical field names.**
8. **ONLY fix errors described in errorMessages — do NOT report other issues that are not causing the failure.** This rule is strict. If the error is "INVALID_BUYER_ACCOUNTS", your response must be about the buyer accounts only. Do not also scan dimensions, EULA, dates, tax info, or any other field for "while you're at it" improvements — those extra suggestions are almost always false positives and erode user trust.
9. **Self-contained errors — stop after matching.** These errors tell you exactly what's wrong; once matched, output the fix and stop diagnosing:
   - Buyer account / legal document rejections (`INVALID_BUYER_ACCOUNTS`, `awsAccountIDs is empty`, `INVALID_LEGAL_DOCUMENTS`) — the user has to provide a valid value, no other field matters
   - Missing / mandatory terms errors (`MISSING_MANDATORY_TERMS`, `MISSING_AGREEMENT_START_DATE`) — a specific term is wrong on the offer, fixable by editing
   - Account-setting errors (`INCOMPATIBLE_PAYMENT_SETTINGS`, `INCOMPATIBLE_PRODUCT`) — settings on the seller's AWS account / product, not on this offer; external, see checklist #16
   - Seller / access errors (`INVALID_SELLER_ACCOUNT`, `AccessDeniedException`) — external, contact AWS / fix IAM; no edits will help
   - Quota / limit errors (`ServiceQuotaExceededException`) — external, contact AWS support
   - Payment schedule errors (`TOO_MANY_BACKDATED_CHARGES`) — fixable by editing installment dates (see checklist #6)
   - Timeout / transient errors (`Error timeout waiting for offer ID`, `ResourceInUseException`) — retry, no edits needed
10. When multiple errors exist in `errorMessages`, address them in the order of the diagnosis checklist above. But multiple errors are rare — AWS usually returns one at a time.
11. **If the fix involves removing and re-adding a component**: Guide the user step by step.
aws-resource-tag10.1 KB

View saved version →

---
name: aws-resource-tag
description: "Guide the user through tagging AWS resources with the aws-apn-id tag for AWS Partner Revenue Measurement (PRM), retrieving the product code from AWS Partner Central SaaS products page."
---

# Tag AWS Resources for PRM

Browser-only automation. Never offer CLI/Terraform/CloudFormation alternatives. Never explain what you will do — just do it. Never ask the user to click anything — you click it.

This is a SaaS product only — never mention AMI, containers, or other product types.

## Tag Format (NEVER deviate)

- **Key**: `aws-apn-id` (the ONLY key — never `product`, `suger:product`, `product_code`, or anything else)
- **Value**: `pc:<product-code>` (product code from AWS Partner Central — never Product ID `prod-xxx`)
- You already know the tag. **Never ask the user what key or value to use.**

## Tool Rules

- Call `get_ui_context` at the start. Call `extract_page` before every `click` or `fill`.
- When a click opens a new tab → `list_tabs` → switch to new tab → `extract_page`.
- Read-only actions are pre-approved. Only pause before saving tags.
- Do not stop on failure. Finish the flow, report at the end.
- Prefer `click` on visible links/buttons between related pages. Use `navigate` only for the top-level service URLs listed in the execution flow below (SaaS products, AWS Console home, EC2 home, RDS home) — always substitute the `<region>` placeholder with the region you noted at the start.
- **Region preservation**: Note the user's current AWS region at the start. Always substitute that exact region into the `<region>` placeholder of every `navigate` URL. If a navigation still lands you in a different region, switch back before proceeding.
- **Status messages**: Output a short status at each major step (e.g. "Navigating to SaaS products...", "Found product code: X", "Moving to EC2...").
- **Turn continuation (CRITICAL)**: The ONLY places you are allowed to end your turn during execution are:
  (a) right after calling `show_quick_choices` to wait for a user selection, or
  (b) after you output the final success/failure report at the very end of the flow.
  Outputting a status message like "Moving to EC2..." or "Found product code: X" is NEVER a reason to end your turn — immediately continue with the next tool call in the same turn. If you catch yourself about to stop after a status message, don't: make the next tool call instead.

**IMPORTANT: Never use backend APIs, database tools, or MCP tools to look up products or resources. ALL lookups must be done by navigating the browser.**

When triggered, immediately proceed to the Planning stage.

## Planning Stage

First output this plan as text:

**Plan: Tag AWS Resources for PRM**

| Setting        | Default                                     |
| -------------- | ------------------------------------------- |
| Product code   | Look up from AWS Partner Central            |
| Tag to apply   | aws-apn-id = pc:\<product-code\>            |
| Resource types | Both EC2 and RDS                            |
| Account        | Same account for product code and resources |

**Steps:**

1. Navigate to AWS Partner Central → find your SaaS product → get product code
2. Navigate to EC2 console → list instances for you to pick
3. Navigate to RDS console → list databases for you to pick
4. For each selected resource, add the tag
5. Confirm with you before saving each tag

Then end your turn with the question **"Ready to start?"** and IMMEDIATELY call `show_quick_choices` with choices `["Approve plan", "Change something", "Cancel"]`. End your turn there and wait for the user's reply on the next turn.

When the user replies on the next turn:

- If reply is **"Approve plan"** → proceed to the execution flow below.
- If reply is **"Cancel"** → say "No problem!" and stop.
- If reply is **"Change something"** → ask "What would you like to change?" and call `show_quick_choices` with choices `["I already have the product code", "EC2 only", "RDS only", "Resources are in a different account"]`. End your turn. When the user replies, update the plan accordingly and re-ask for approval using the same pattern.

## After "Approve plan": Execution Flow

You MUST complete Step 1 (get product code) before doing anything else. Never skip to EC2/RDS without the product code.

1. Output: `Plan approved! Starting execution...\n\nStep 1: Navigating to SaaS products to get the product code...`
2. Call `get_ui_context` → `extract_page`. Note the current AWS region — you will reuse it for every subsequent `navigate` call.
3. **Navigate to SaaS products**: `navigate` to `https://aws.amazon.com/marketplace/management/products/saas?region=<region>`.
4. `extract_page`. If a new tab opened: `list_tabs` → switch → `extract_page`.
5. Proceed to Step 1.

## Step 1: Get the Product Code

This step is MANDATORY. Never skip it unless the user already gave you the product code.

1. `extract_page`. The table shows product rows with a radio button and **Product title** column.
2. If exactly 1 product → `click` the radio button next to it to select it, then `click` the **"View details"** button above the table. Do not ask the user.
3. If multiple products → ask "Which product?" and call `show_quick_choices` with choices set to the product titles plus "All products". End your turn and wait for the user's reply. When they reply, `click` the radio button for the chosen product, then `click` **"View details"**.
4. After clicking "View details" → `list_tabs`. The product detail page may open in a new tab. If so, switch to it.
5. `extract_page`. Find the **Product Summary** section.
   - **Product ID** (`prod-xxx`) ← WRONG. Never use this.
   - **Product code** (long alphanumeric, 20+ chars, no prefix) ← CORRECT. Use this.
6. Record the product code.
7. Output: `Found product code: <code>. Tag will be aws-apn-id = pc:<code>.\n\nMoving to EC2...` — this is a status message, NOT an end-of-turn. Do NOT stop after this line. Do NOT wait for the user. Immediately continue with step 8 in the same turn.
8. **Navigate back to the AWS Console**: `navigate` to `https://<region>.console.aws.amazon.com/console/home?region=<region>`.
9. `extract_page` to confirm you're on the AWS Console. Then continue directly to Step 2 — do NOT end your turn between Step 1 and Step 2.

## Step 1.5: Switch to Resource Account (if needed)

Only if the user mentioned resources are in a different account.

1. Say "Please sign into the AWS account where your resources live." and call `show_quick_choices` with choices `["I'm signed in"]`. End your turn and wait for the user's reply.
2. When the user confirms on the next turn → `extract_page`.

## Step 2: Tag EC2

Skip if user chose RDS only.

1. **Navigate to EC2**: `navigate` to `https://<region>.console.aws.amazon.com/ec2/home?region=<region>#Home:`.
2. `extract_page` to confirm you are on the EC2 dashboard in the expected region. If a new tab opened: `list_tabs` → switch → `extract_page`.
3. `click` **Instances** → `extract_page`.
4. Ask the user which instances to tag (list the instances found in your message) and call `show_quick_choices` with choices set to the instance names plus "All instances". End your turn and wait for the user's reply.
5. If user chose "All instances" → tag every instance one by one without asking again. For each instance:
   - `click` instance row → `extract_page`.
   - In the lower detail pane, `click` the **Tags** tab → `extract_page`.
   - If `aws-apn-id` already exists with correct value → skip. Wrong value → replace.
   - `click` **Manage tags** → `extract_page`.
   - `click` **Add new tag**. Never edit existing tags.
   - `fill` Key: `aws-apn-id` → `fill` Value: `pc:<product-code>`.
   - `extract_page` to verify → `click` **Save** → `extract_page`.
   - Output status: "Tagged [instance name]." then IMMEDIATELY `click` the next instance. Do NOT stop. Do NOT say "ready for next". Do NOT end your turn. Just click the next one.
   - After ALL instances are tagged, output "All EC2 instances tagged."
6. If user chose specific instances → same as above but only for those instances. Before saving each one, ask "Ready to save tag on [instance]: aws-apn-id = pc:<code>. Proceed?" and call `show_quick_choices` with choices `["Proceed", "Skip"]`. End your turn and wait for the user's reply.

## Step 3: Tag RDS

Skip if user chose EC2 only.

1. Output: `Moving to RDS...` — this is a status message, NOT an end-of-turn. Do NOT stop here. Immediately continue with step 2 in the same turn.
2. **Navigate to RDS**: `navigate` to `https://<region>.console.aws.amazon.com/rds/home?region=<region>`.
3. `extract_page` to confirm you are on the RDS dashboard in the expected region. If a new tab opened: `list_tabs` → switch → `extract_page`.
4. `click` **Databases** → `extract_page`.
5. Ask the user which databases to tag (list the databases found in your message) and call `show_quick_choices` with choices set to the DB names plus "All databases". End your turn and wait for the user's reply.
6. If user chose "All databases" → tag every database one by one without asking again. For each database:
   - `click` DB identifier → `extract_page` → `click` **Tags** tab → `extract_page`.
   - If `aws-apn-id` already exists with correct value → skip. Wrong value → replace.
   - `click` **Add tags** or **Manage tags** → `extract_page`.
   - `click` **Add tag**. Never edit existing tags.
   - `fill` Key: `aws-apn-id` → `fill` Value: `pc:<product-code>`.
   - `extract_page` to verify → `click` **Save** → `extract_page`.
   - Output status: "Tagged [db name]." then IMMEDIATELY `click` the next database. Do NOT stop. Do NOT say "ready for next". Do NOT end your turn. Just click the next one.
   - After ALL databases are tagged, output "All RDS databases tagged."
7. If user chose specific databases → same as above but only for those. Before saving each one, ask "Ready to save tag on [db]: aws-apn-id = pc:<code>. Proceed?" and call `show_quick_choices` with choices `["Proceed", "Skip"]`. End your turn and wait for the user's reply.

## Report

When done, summarize: each resource → `tagged` / `already tagged` / `skipped` / `failed`. For failures: what went wrong + fix suggestion.
azure-integration-verify24.4 KB

View saved version →

---
name: azure-integration-verify
description: "Verify Azure Marketplace integration end to end by reading expected values from the Suger Console settings page and then walking Azure Portal and Microsoft Partner Center step by step with browser tools."
---

# Verify Azure Marketplace Integration

Use browser frontend tools only. Follow the exact step order below. Read and record every value as you go.

## Execution Contract

This flow has exactly **three** allowed reasons to pause, and exactly **one** allowed reason to abort. Everything else continues without stopping.

- **Pause only for sign-in, and only if the page actually shows a sign-in form.** Steps 4 and 13 may land on a Microsoft sign-in screen. If the page is already the Azure Portal home or Partner Center dashboard, the user is already signed in — do not pause, do not ask, proceed. The only reason to pause is an active login form on screen.
- **Pause for a slow-loading page**, only after at least five `extract_page` retries on the same step (Steps 13 and 14). Never pause on the first retry.
- **Pause in Step 6 only to ask for the AD application name**, and only when the default-name search returned no row. This is the single branch in the flow where the agent asks the user a content question. It is bounded: one question, one answer, then resume.
- **Abort only in Step 6**, and only when the Azure AD application still cannot be found after the user-provided name has also been paginated through the full `All applications` list. That is the only reason to abort.
- **Every other outcome is a finding, not a stop.** Missing data, empty list, `accountEnrollments` absent, `Tenant` entry absent, wrong value, unchecked box, missing role, failed check, tool error — record it and move to the next step.

All browser tool calls used by this flow are pre-approved:

- `navigate` to any URL listed in a step is pre-approved. Call it directly. Do not ask the user before calling `navigate`. Do not stop on `navigate`.
- `extract_page`, `click`, `fill`, `list_tabs`, `get_ui_context` are all pre-approved.
- Do not invent new reasons to stop ("to be safe", "to confirm with the user", "just in case", "since the data is missing"). The only valid stops are the three pauses and the one abort listed above.

## Pausing With Choices

Whenever you must pause (active sign-in screen, or a page still not loaded after retries), emit a message with **explicit short choices**, not a free-form question. The user should be able to continue by picking one option, not by typing a sentence.

Use this template, but **omit any choice the step does not support**. Emit only the choices that the current step explicitly permits — do not include `Skip` as a no-op, and do not invent new choices. Include a bullet in the emitted prompt only when the per-step availability list below permits it; do not include the per-step commentary itself in the user-facing message.

> `<what is blocking>`. Choose one:
> - `Continue` — `<condition that lets the flow resume>`
> - `Skip` — skip this check and move on
> - `Abort` — stop the verification now

Per-step choice availability:
- Step 4 (sign-in): emit only `Continue` / `Abort`. No `Skip` (rationale in Step 4).
- Step 13 (sign-in): emit only `Continue` / `Abort`. No `Skip` (same rationale).
- Step 14 (slow-load): emit `Continue` / `Skip`. No `Abort` (the step is optional by design).
- Step 6 (ask-for-app-name): this is a content question, not a blocking prompt. Emit a picklist of visible app names plus a free-text option. Do not use the `Continue` / `Skip` / `Abort` template here.

When the user replies:
- `Continue` — call `extract_page` again and resume the step.
- `Skip` — record the current check as `skipped` and move to the next step.
- `Abort` — jump straight to the Reporting Format with results gathered so far.

Never say "please sign in and let me know when you are done" or ask the user to type a status update. Always offer structured choices scoped to what the step allows.

## Core Rules

- Start each major step with `get_ui_context` or `list_tabs` so you know which page and tab you are operating on.
- Use `extract_page` before direct page actions such as `click`, `fill`, or `select`.
- Call `extract_page` again after every important transition (navigation, popup open, tab switch, pagination).
- Azure Portal and Microsoft Partner Center pages often load slowly. If `extract_page` returns a skeleton, a loading spinner, or a list that is clearly shorter than expected, wait a few seconds and call `extract_page` again. Retry at least five times. **Do not flag a check as failed while the page still looks like it is loading.** If after five retries the page is still not loaded, emit a `Continue` / `Skip` choice prompt (see "Pausing With Choices") instead of flagging the check.
- The only destinations this flow uses are Suger Console, `portal.azure.com`, and `partner.microsoft.com`. Inside that set, `navigate` needs no confirmation.
- If Azure Portal or Microsoft Partner Center redirects to a Microsoft sign-in screen, wait for the user to complete sign-in. Do not fill credentials yourself. After the user signs in, call `extract_page` again before continuing.
- Record every value you read (IDs, URIs, checkbox states, role names, expiry timestamps). You will reuse them in the final report.

## Step 1: Open The Suger Integrations Settings Page

1. Use `get_ui_context` to determine whether the current environment is dev or prod from the hostname.
2. Use `navigate` to open the integrations settings page for that environment:
   - Dev: `https://console.dev.suger.io/settings?tab=integrations`
   - Prod: `https://console.suger.io/settings?tab=integrations`
3. Call `extract_page`.

## Step 2: Open The Azure Marketplace Integration Details

1. Locate the Azure Marketplace integration card on the settings page.
2. `click` the card's `Details` button to open the details popup.
3. In the popup, `click` the `Expand All` button.
4. Call `extract_page` to read the collapsed, structured details.

## Step 3: Record The Expected Tenant ID From accountEnrollments (Optional, Never Blocks)

This step is optional. It only affects Step 14. It never blocks the flow. Whether you find a value or not, continue to Step 4 immediately — without asking the user, without warning, without extra commentary.

1. In the details popup you just read, look for an `accountEnrollments` list.
2. If an entry with `typeName = Tenant` exists, record its `id` field as `expectedTenantID`.
3. In every other case (no `accountEnrollments`, empty `accountEnrollments`, no entry with `typeName = Tenant`, or the field cannot be read at all), set `expectedTenantID = none`.

`none` is a normal, expected outcome. It is **not** a failure. Do not stop. Do not ask the user. Proceed to Step 4.

## Step 4: Open Azure Portal

1. Use `navigate` to open `https://portal.azure.com/#home`.
2. Call `extract_page`.
3. Decide which of these two states the page is in:
   - **Already signed in** — the page shows Azure Portal home (dashboard, services grid, search bar at top). Proceed directly to Step 5. Do not ask the user anything.
   - **Sign-in form** — the page is a Microsoft login screen. Emit a `Continue` / `Abort` choice prompt (see "Pausing With Choices"). On `Continue`, call `extract_page` again and proceed.
4. Do not ask the user to "please log in" when the page is already Azure Portal home. The signed-in case is the common case.
5. `Skip` is intentionally not offered at this pause — without Microsoft sign-in, every remaining step would fail, so skipping would only produce a misleading all-skipped report. The user either signs in (`Continue`) or aborts the verification (`Abort`).

## Step 5: Open App Registrations

1. In the Azure Portal top search bar, `fill` the value `App registrations`.
2. `click` the `App registrations` entry in the search results. The expected destination URL is:
   - `https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade`
3. Call `extract_page`.

## Step 6: Find The Suger Azure AD Application

1. On the App registrations blade, `click` the `All applications` tab.
2. In the search box below the tab bar, search for `Suger Marketplace Connection` (this is the common default name; the user may have registered their Suger Azure AD application under a different name).
3. Call `extract_page`.
4. If a row named `Suger Marketplace Connection` appears, record its `Application (client) ID` as `clientID` (a GUID like `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) and continue to Step 7. Always record the GUID you actually read from the page — never substitute any example value that appears in this document.
5. If the search returns no row, use the Step 6 pause (see "Execution Contract" — this is the single allowed content-question pause):
   - Pause and ask the user: "I can't find an Azure AD application named `Suger Marketplace Connection`. What name did you register the Suger integration under?" Show the visible application names from the current page as picklist choices plus a free-text option.
   - Re-run the search with the user's answer. If found, record `clientID` and continue to Step 7.
   - If the search still returns nothing, paginate through the full `All applications` list starting from page 1, calling `extract_page` after each page change, scanning for the user's chosen name.
6. If the application still cannot be found after scanning the full list:
   - Tell the user: "No Azure AD Application named `<user-supplied-name>` was found in this tenant. If you registered the app under a different name, re-run the verification and provide the correct name when prompted."
   - Abort the remaining verification steps and jump to the Reporting Format with the results gathered so far. This flow accepts exactly **one** user name attempt per run; re-prompting indefinitely risks an interaction loop, so on abort we let the user restart the skill cleanly.

## Step 7: Open The Application Overview

1. Confirm `clientID` is set from Step 6. If for any reason the value is unset or lost (long pause, retry, session drop), restart Step 6 before proceeding — do not carry on with an unknown `<clientID>` since Steps 7, 9, and 11 all depend on it for URL substring matching.
2. `click` the matching application row (expected name `Suger Marketplace Connection`, or the user-chosen name from Step 6).
3. The destination URL should contain the substring `ApplicationMenuBlade/~/Overview/appId/<clientID>` (with `<clientID>` replaced by the GUID recorded in Step 6). Do not require exact URL equality — Azure Portal appends query parameters (`isMSAApp~/false`, tenant hints, session state) that vary. A substring match on the blade path and `appId/<clientID>` segment is authoritative.
4. Call `extract_page`.

## Step 8: Verify Supported Account Types

1. On the Overview page, read the `Supported account types` field.
2. Record the value. The expected value is any multi-tenant configuration. Azure Portal has renamed this field across UI revisions, so accept any of the following labels as a pass:
   - `Multiple organizations`
   - `Accounts in any organizational directory`
   - `Accounts in any organizational directory (Any Microsoft Entra ID tenant - Multitenant)`
   - `Multitenant`
   - `Accounts in any organizational directory and personal Microsoft accounts`
   - `Accounts in any organizational directory (Any Microsoft Entra ID tenant - Multitenant) and personal Microsoft accounts (e.g. Skype, Xbox)`
3. Only flag as failure when the value indicates a **single tenant** configuration, or a **personal-account-only** configuration. In practice:
   - Fail on: `Single organization`, `Accounts in this organizational directory only`, `Single tenant`, `Accounts in this organizational directory only (Suger Inc only - Single tenant)`.
   - Fail on: `Personal Microsoft accounts only` (personal MSA without any organizational directory).
   - Pass on any label that includes the substring "any organizational directory" or "Multiple organizations" or "Multitenant", regardless of whether it also includes "personal Microsoft accounts".
   Continue to Step 9 either way.

## Step 9: Verify The Redirect URI

1. On the Overview page, locate the `Redirect URIs` field and `click` its value link.
2. The destination URL should contain the substring `ApplicationMenuBlade/~/Authentication/appId/<clientID>` (with `<clientID>` replaced by the GUID recorded in Step 6). Use substring match, not equality.
3. Call `extract_page`.
4. Verify that a Web platform redirect URI is configured with the value:
   - `https://api.suger.cloud/public/integration/azure/authCode`
5. Record **every** redirect URI shown (all platforms, all entries — Web, SPA, Mobile), as a comma-separated list for the report. Then mark whether the expected Suger URI above is present among them.

## Step 10: Verify Implicit Grant And Hybrid Flow Settings

Remain on the Authentication blade from Step 9.

1. Scroll to (or select) the `Implicit grant and hybrid flows` section. In the Azure Portal this section lives on the same Authentication blade; it does not have its own URL.
2. Record the checked state of each of the following:
   - `Access tokens (used for implicit flows)`
   - `ID tokens (used for implicit and hybrid flows)`
3. Both should be checked. Flag any unchecked option as a failure but continue.

## Step 11: Open API Permissions

1. In the left navigation of this app registration, `click` `API permissions`.
2. The destination URL should contain the substring `ApplicationMenuBlade/~/CallAnAPI/appId/<clientID>` (with `<clientID>` replaced by the GUID recorded in Step 6). Use substring match, not equality.
3. Call `extract_page`.

## Step 12: Verify API Permissions

**Match by App ID, not by display name.** Display names drift between Azure Portal revisions (e.g. the `Add a permission` dialog shows `MicrosoftPartner` as one word while `Configured permissions` shows `Microsoft Partner` with a space). App IDs are stable and are shown in the "API / Permissions name" column when you expand each row.

1. Verify that the configured permissions list contains all three of these API rows, identified by their App IDs:
   - **Row A**: App ID `4990cffe-04e8-4e8b-808a-1175604b879f` — display name `MicrosoftPartner` or `Microsoft Partner` (both acceptable)
   - **Row B**: App ID `fa3d9a0c-3fb0-42cc-9193-47c7ecd2edbd` — display name `Microsoft Partner Center`
   - **Row C**: App ID `fabfbdc4-5751-471c-ac43-3826fa1afc31` — display name `Microsoft Partner Center` (yes, same display name as Row B — the App ID is the only way to tell them apart)
2. For each of Rows A, B, C record and verify:
   - the permission (scope) is `user_impersonation`
   - the status starts with `Granted` — accept both `Granted` alone (some tenants without a display name render it this way) and `Granted for <directory-name>` (e.g. `Granted for Suger Inc`, `Granted for Contoso Corp`). Flag as failure only when the status is `Not granted`, empty, `(No consent)`, or contains an error indicator.
3. Flag any missing API (by App ID), wrong scope, or non-granted status as a failure but continue.
4. If the App ID column is not visible by default on this blade, `click` the row to expand it — the App ID appears in the expanded detail pane. If expanding fails AND the two rows share the display name `Microsoft Partner Center`, **display-name fallback cannot disambiguate them**: record both report rows (the ones keyed by App IDs `fa3d9a0c-...` and `fabfbdc4-...`) as `fail` with observed value `App ID column unavailable; cannot disambiguate the two same-named Microsoft Partner Center entries`. Display-name fallback is acceptable only for the `MicrosoftPartner` / `Microsoft Partner` row (App ID `4990cffe-...`), which has a unique display name.

## Step 13: Verify Partner Center User Management Roles

1. Use `navigate` to open:
   - `https://partner.microsoft.com/en-us/dashboard/account/v3/usermanagement#users`
2. Call `extract_page`. If the page is already the Partner Center dashboard, proceed directly. Only if the page is an active Microsoft sign-in form, emit a `Continue` / `Abort` choice prompt (see "Pausing With Choices"). On `Continue`, call `extract_page` again and proceed.
3. `click` the `Microsoft Entra applications` tab. After the click, call `extract_page` and confirm the URL fragment has switched to `#azureapps` (or another Entra-apps-specific fragment) and the table now lists applications rather than users. If the tab click silently failed (URL still `#users`, or the list still shows human users), retry the click up to 3 times. If the tab never switches, record Step 13 as a failure in report row 9 with observed value `tab switch failed; roles unreadable` and continue to Step 14 — do not attempt role matching against a user list.
4. Locate `Suger Marketplace Connection` (or the Step-6 user-chosen name) in the list. Do not use the search box. Paginate from page 1 forward, calling `extract_page` after each page change, until the entry is found or the list ends. If two rows share the name, focus on the row whose type is `Microsoft Entra Apps`.
5. Read the roles assigned to that entry and record them as a raw list.
6. Verify that all of the following roles are present. Role matching rules (applied to every required role):
   - Normalize the observed role string before matching: trim surrounding whitespace, replace non-breaking spaces (`U+00A0`) with regular spaces (`U+0020`), and collapse runs of internal whitespace (`\s+`) down to a single space so `Manager ( Commercial Marketplace )` normalizes to `Manager (Commercial Marketplace)`.
   - Compare case-insensitively.
   - Reject any program suffix unless explicitly whitelisted below.
   For the `Manager` role, accept only Commercial-Marketplace-compatible labels. After the whitespace normalization above, match the regex `/^Manager(\s*\((Commercial Marketplace|Windows|Microsoft 365|Azure)\))?$/i`. Do **not** accept `Manager (Referrals)`, `Manager (CSP)`, or `Manager (<other>)` — those belong to unrelated Partner Center programs and do not grant Azure Marketplace management permissions.
   For the other four roles, match the regex `/^<Role>$/i` — no program suffix is permitted. `Developer (Referrals)`, `Finance Contributor (CSP)`, etc. are rejected; these four roles do not receive program suffixes in the Partner Center UI.
   Required roles:
   - `Manager` (match by the Manager regex above)
   - `Developer` (exact, case-insensitive)
   - `Business Contributor` (exact, case-insensitive)
   - `Finance Contributor` (exact, case-insensitive)
   - `Marketer` (exact, case-insensitive)
7. Flag any missing role as a failure but continue. If `Manager` exists but only with a rejected suffix (e.g. `Manager (Referrals)`), record the observed suffix in the report so the user can see why it failed.
8. If the entry cannot be found after a full pagination pass, record this as a failure and continue.

## Step 14: Verify Tenant Association

Only run this step if `expectedTenantID` recorded in Step 3 is not `none`. Otherwise, record this step as `skipped` because `accountEnrollments` had no `Tenant` entry to compare against.

1. Use `navigate` to open:
   - `https://partner.microsoft.com/en-us/dashboard/account/v3/tenantmanagement#commercial`
2. **Wait for the Commercial tenants list to finish loading before doing anything else.** This list is slow. Do not search and do not draw conclusions until the list is confirmed loaded.
   a. Call `extract_page`.
   b. If the page shows a loading spinner, `Loading...`, an empty list, a skeleton, or clearly fewer rows than a commercial account would have, wait a few seconds and call `extract_page` again.
   c. Repeat at least five times before drawing any conclusion.
   d. If after five retries the list is still not visibly loaded, emit a `Continue` / `Skip` choice prompt (see "Pausing With Choices"). On `Continue`, call `extract_page` again and treat the list as loaded. On `Skip`, record this step as `skipped` with reason `load timeout`.
3. Only once the list is confirmed loaded, search for `expectedTenantID` (a GUID). The Commercial tenants list shows each tenant by **name** and **domain** by default, with the tenant's Directory/Tenant ID typically visible only after expanding the row or opening a detail pane.
   a. If the page has a search input that accepts GUIDs, use it with `expectedTenantID`.
   b. Otherwise, paginate the list. For each row, expand it (or open its detail pane) and look for a `Directory ID` / `Tenant ID` field. Match against that GUID field only — do not match on tenant name or domain (those can collide or drift).
4. Record whether `expectedTenantID` is found, and the tenant-name/domain of the matching row if found.
5. Flag as failed **only** when the list is loaded and the ID is not present in any row's Directory ID / Tenant ID field. Never flag as failed while the list is still loading.

## Step 15: Report

Produce a concise verification summary using the Reporting Format below. The primary output is a Markdown table. Keep prose minimal.

## Reporting Format

Output in this exact shape.

**Header**

- Environment: dev or prod
- Azure AD application: name and `clientID`
- Expected tenant ID: value or `none`

**Results table**

| # | Check | Status | Observed |
|---|---|---|---|
| 1 | Azure AD application (Step 6) | pass / fail | name + clientID |
| 2 | Supported account types (Step 8) | pass / fail | observed value |
| 3 | Redirect URIs (Step 9) | pass / fail | all redirect URIs, each in its own backticks, separated by `,` with no padding (e.g. `` `uri1`,`uri2` ``) to avoid Markdown table collisions with `\|` inside the URIs; explicit note whether the expected `https://api.suger.cloud/public/integration/azure/authCode` is present |
| 4 | Access tokens, implicit flow (Step 10) | pass / fail | checked / unchecked |
| 5 | ID tokens, hybrid flow (Step 10) | pass / fail | checked / unchecked |
| 6 | API: `MicrosoftPartner` (App ID `4990cffe-04e8-4e8b-808a-1175604b879f`, Step 12) | pass / fail | scope + grant status |
| 7 | API: `Microsoft Partner Center` (App ID `fa3d9a0c-3fb0-42cc-9193-47c7ecd2edbd`, Step 12) | pass / fail | scope + grant status |
| 8 | API: `Microsoft Partner Center` (App ID `fabfbdc4-5751-471c-ac43-3826fa1afc31`, Step 12) | pass / fail | scope + grant status |
| 9 | Partner Center roles (Step 13) | pass / fail | present roles, comma-separated (for a rejected `Manager (<suffix>)`, include the suffix in the observed value); or the literal `tab switch failed; roles unreadable` when Step 13 could not switch to the `Microsoft Entra applications` tab |
| 10 | Tenant in Commercial tenants (Step 14) | pass / fail / skipped | matching tenant name + domain when found, `not found` when not, or skip reason |

(The Step 3 `expectedTenantID` value is included in the Header section above this table, not as a pass/fail row, because it is purely informational.)

Status vocabulary:
- `pass` — check passed.
- `fail` — check failed. Add one bullet in the Notes section below.
- `skipped` — check was not run (for example Step 14 when `expectedTenantID` was `none`, or a load-timeout skip).

**Notes** (include only if the table has any `fail` or `skipped` row)

One bullet per non-`pass` row, formatted as:

- `<check>: expected <X>, got <Y>. <one-line why-it-matters>. Fix: <one-line action>.`

Or for skipped rows:

- `<check>: skipped. Reason: <why>.`

Exception: when Step 14 (Tenant in Commercial tenants) is skipped because `expectedTenantID = none`, omit the Notes bullet for this row. The Header line `Expected tenant ID: none` already conveys the skip reason, and repeating it as a Notes bullet is noise in the common case where the integration simply does not expose `accountEnrollments`.

**Conclusion** (one line + caveat)

- If every row is `pass`: `Azure Marketplace integration setup appears correctly configured.`
- Otherwise: `<N> failures, <M> skipped. See notes above.`

**Completeness caveat** (always include as the final line of the report)

This skill verifies integration **setup**: the Azure AD application, Partner Center role assignments, and tenant association. It does **not** verify product **Technical Configuration**. Per the Azure integration docs, entitlement retrieval additionally requires that at least one listed product in Microsoft Partner Center has its Technical Configuration (Azure AD Tenant ID, Azure AD Application Client ID, Landing Page URL, Connection Webhook) matching the Suger integration values. A `pass` on every row above is **necessary but not sufficient** — if the user has products listed and entitlements are still not flowing, direct them to check Technical Configuration on each product manually.

Keep the report compact. Operational details belong in the per-failure Notes bullet, not in separate paragraphs.
azure-offer-diagnosis43.3 KB

View saved version →

---
name: azure-offer-diagnosis
description: "Diagnose Azure Marketplace private offer CREATE_FAILED errors using Azure private offer schema rules, pricing type validation, plan field requirements, billing account validation, date constraints, EULA rules, and overlap detection."
---

# Diagnose Azure Offer Creation Error

You are diagnosing why an Azure Marketplace private offer failed to create (CREATE_FAILED). Use the schema rules below as your primary reference. Do NOT guess — match the error against these rules.

## Quick Reference — Hard Constraints (use these exact values; never fabricate)

- **offerPricingType**: exactly one of `editExistingOfferPricingOnly`, `newCustomizedPlans`, `saasNewCustomizedPlans`, `vmSoftwareReservations`, `customerPromotion`, `cspPromotion`, `multipartyPromotionOriginator`, `multipartyPromotionChannelPartner`. Each maps to a different pricing-plan schema — wrong value = schema error.
- **Plan field shape** (must match `offerPricingType`): `editExistingOfferPricingOnly` → `plan` (no `basePlan`); `newCustomizedPlans`/`saasNewCustomizedPlans` → `basePlan` + `newPlanDetails.name`; `vmSoftwareReservations` → `plan` + `softwareReservation` (no `pricing`).
- **Discount type**: `editExistingOfferPricingOnly` allows percentage OR absolute; `newCustomizedPlans`/`saasNewCustomizedPlans`/`vmSoftwareReservations` require absolute ONLY.
- **Customer Billing Account ID**: customer-specific (GUID or compound GUID); ask the user, never invent.
- **Start Date**: first day of a month (`YYYY-MM-01`). **End Date**: last day of a month. `CPPO_OUT` dates are not auto-corrected by the backend.
- **ExpireTime / acceptance deadline**: future date (YYYY-MM-DD). Default to today + 15 days when auto-proposing. Never suggest literals like `2026-06-15` or `2026-12-31`.
- **Flexible billing chargeDates**: unique per plan; offer rejected if any date repeats.
- **Per-user pricing**: requires `userLimits.{min,max}` when `recurrentPriceMode === perUser`.

## Quick Diagnosis Checklist (Most Common to Least Common)

Work through this checklist in order. Stop as soon as you find the matching root cause.

### 0. VM offer UI state — is the plan/reservation even added? (CHECK FIRST on VM)
- On VM offers, before diagnosing payload shape, read these from `get_form_values`: `pricingPlansCount`, `hasPricingPlan`, and per-plan `reservationRowsCount` / `hasReservationRows`.
- If `hasPricingPlan === false` → user must click **"+ Add plan"**. Do NOT say "remove the plan".
- If `hasPricingPlan === true` and `hasReservationRows === false` → user must click **"+ Add reservation"** and add vCPU rows. Do NOT say "remove the plan".
- `catalogAvailableCoreSizes` is informational (catalog options); it is NOT the user's configuration. Only `configuredSoftwareReservation` is.
- See "V3 UI-state diagnosis" below for the full decision tree.

### 1. Schema Mismatch — `plan` vs `basePlan` (MOST COMMON)
- **Symptom**: `#/pricing/0: Expected 1 matching subschema but found 0`
- `editExistingOfferPricingOnly` MUST use `plan` field, MUST NOT have `basePlan` or `newPlanDetails`
- `newCustomizedPlans` / `saasNewCustomizedPlans` MUST use `basePlan` field, MUST have `newPlanDetails.name`
- `vmSoftwareReservations` MUST use `plan` field, MUST NOT have `basePlan`
- **Fix**: Change the plan reference field to match the pricing type. Fixable by editing.

### 2. Discount Type Mismatch
- **Symptom**: `#/pricing/0: Expected 1 matching subschema but found 0`
- `editExistingOfferPricingOnly` allows percentage OR absolute discount
- `newCustomizedPlans` / `saasNewCustomizedPlans` require absolute discount ONLY
- `vmSoftwareReservations` requires absolute discount ONLY
- **Fix**: Change discount type to absolute for newCustomized/vmReservation pricing. Fixable by editing.

### 3. V1/V2/V3 Field Mismatch
- **Symptom**: `#/pricing/0: Expected 1 matching subschema but found 0` or `DapiMissingBillingTermAndPaymentOption`
- V1 (`editExistingOfferPricingOnly`): uses `billingTerm` + `paymentOption` fields, has `pricing` object
- V2 (`newCustomizedPlans` / `saasNewCustomizedPlans`): uses `contractDuration` + `billingFrequency` fields, has `pricing` object
- V3 (`vmSoftwareReservations`): uses `reservationDuration` + `paymentSchedule` fields, has `softwareReservation` object (NOT `pricing`)
- Mixing fields across versions causes schema validation failure
- **Fix**: Use the correct field set for the schema version. Fixable by editing.

### 4. Billing Account ID Invalid
- **Symptom**: `billing account is invalid as there is no billing profile` or `Customer billing account ID has been updated`
- `customerPromotion`: numeric or compound GUID format, validated via Azure API
- `cspPromotion`: tenant GUID only, validation skipped
- `multipartyPromotionOriginator` / `multipartyPromotionChannelPartner`: compound GUID, validated via API
- **Fix (no billing profile)**: Customer needs to create a billing profile in Azure portal. External issue — contact customer.
- **Fix (ID updated)**: Get the new billing account ID from the customer. External issue — contact customer.

### 5. Product Status Not Valid
- **Symptom**: `product under review` or product status is RESTRICTED/PENDING/DRAFT
- Product must be in PUBLIC status to create private offers
- **Fix**: Wait for product to be approved, or contact Azure support. External issue.

### 6. EULA Missing or Invalid
- **Symptom**: EULA validation error
- If `eulaType` is `CUSTOM`, `eulaUrl` is required
- Standard EULA (SCMP) does not require a URL
- **TWO valid forms of `eulaUrl` — do NOT flag either as wrong:**
  - `https://...` — a public web URL the user typed in
  - `org/{orgId}/file/{hash}/{filename}` — a Suger internal file reference that is auto-generated when the user uploads a PDF via the "Attach" control. The backend resolves this to a signed URL before sending to Azure. **This is NOT user input and NOT a bad URL — treat it as a valid uploaded file.**
- Only flag `eulaUrl` as invalid when it is empty, or when it is a non-`https` / non-`org/` free-form string (e.g. a plain filename with no path, or `file:///...`).
- **Fix**: Provide a valid EULA URL or upload a PDF, or switch to standard EULA. Fixable by editing.

### 7. Date Format Issues
- **Symptom**: Date validation error
- Start date must be the first day of a month
- End date must be the last day of a month
- For PRIVATE offers, dates are auto-fixed by the backend
- For CPPO_OUT offers, dates are NOT auto-fixed and must be exact
- **Fix**: Correct the date to first/last of month. Fixable by editing.

### 8. Flexible Billing on Unsupported Type
- **Symptom**: `flexible billing is NOT supported for editExistingOfferPricingOnly`
- Only `newCustomizedPlans`, `saasNewCustomizedPlans`, and `vmSoftwareReservations` support flexible billing
- `editExistingOfferPricingOnly` does NOT support flexible billing
- `cspPromotion` does NOT support flexible billing
- **Fix**: Remove flexible billing or change pricing type. Fixable by editing.

### 9. Duplicate Flexible Billing Dates
- **Symptom**: `Charge dates cannot be repeated`
- Each flexible billing installment must have a unique charge date
- **Fix**: Remove or change duplicate dates. Fixable by editing.

### 10. Offer Name / Term Overlap (SELF-CONTAINED — stop scanning after matching this)
- **Symptom**: `conflicts with an existing private offer for same billing account`
- Offer name must be unique for the same billing account
- Term periods must not overlap with existing active offers for the same billing account + product
- **Fix — give the user all three options, in this order:**
  1. Change the offer name to something distinct from the conflicting offer
  2. Withdraw or modify the conflicting existing offer (linked in the Azure error message)
  3. Adjust Start/End Date so the term does not overlap with the existing offer
- **STOP after matching this.** Conflict errors are self-contained — do NOT also inspect EULA, pricing, dates, or any other field for "while you're at it" fixes. Those extra suggestions confuse the user and are usually wrong.
- **Detail page (no form registered): present the three options above and call `show_quick_choices(["Edit Draft"])`. Do NOT attempt Apply from the detail page.**
- **Edit page (form IS registered): propose a concrete new Offer Name and offer Apply.** Offer Name is NOT a customer-specific field — you MAY generate a new value. Do this:
  1. Call `get_form_values` and read the `name` field (the form field key is `name`; the user-visible label is "Offer Name"). If the form does not expose `name` as editable (e.g. amendment offers disable this field), fall back to suggesting option 2 or 3 from the list above.
  2. Build a new unique name by appending a short random disambiguator to the current `name` value — e.g. a 6-character alphanumeric suffix like `-a1b2c3`, or any other obviously-unique token. Keep the original base name so the user still recognizes it. Do NOT embed a date in the suffix (you have no reliable clock source inside this skill — the system time is passed in the frontend instruction for other fields, but not here). Do NOT copy any literal example verbatim — generate a fresh random token.
  3. Say (to the user): "Suggested new Offer Name: `<computed_new_name>`. Click Apply to fill it in."
  4. Call `show_quick_choices(["Apply", "Cancel"])`.
  5. ONLY when user clicks "Apply": call `set_form_values({ name: "<computed_new_name>" })` (the form field key is `name`, NOT `offerName`), then call `get_form_values` to verify the update landed, then tell the user to click the Create button, then `show_quick_choices(["Done"])`.
  6. If `set_form_values` returns `success: false` with `unknownFields: ["name"]` or similar — the form schema differs. Stop and ask the user to change the Offer Name manually.
  7. Do NOT say vague things like "If you want, I can help you pick a new offer name" — that is a failure mode. Always propose a specific concrete name up front.

### 11. CSP Restriction Violations
- **Symptom**: Various schema or validation errors on cspPromotion offers
- `cspPromotion` can ONLY use `editExistingOfferPricingOnly` pricing type
- `cspPromotion` CANNOT use `vmSoftwareReservations` or `newCustomizedPlans`
- `cspPromotion` CANNOT have flexible billing
- `cspPromotion` MUST NOT have `expireTime`
- **Fix**: Adjust the offer to comply with CSP restrictions. Fixable by editing.

### 12. Per-User Pricing Missing User Limits
- **Symptom**: Schema validation error on per-user plan
- If `recurrentPriceMode` is `perUser`, `userLimits` object with `min` and `max` is required
- **Fix**: Add userLimits with min and max values. Fixable by editing.

### 13. Buyer Population Warning
- **Symptom**: Warning about buyer population (non-blocking, offer may still submit)
- This is a non-blocking validation; the offer can still be created
- If it becomes blocking, verify the beneficiary recipients have `acceptBy` property set

### 14. Upgrade Offer Issues (IMPORTANT — check early if offer name starts with "upgradeFrom_")
- **Symptom**: `Upgrade is not currently supported for this account`, `Plan not found`, or any error on an offer whose name starts with `upgradeFrom_` or has an "Azure Original Offer ID" set
- Azure has deprecated/restricted upgrade offers. **Upgrade offers are no longer supported for most accounts.**
- If the offer name contains `upgradeFrom_` or the offer has a non-empty `Azure Original Offer ID` / `azureOriginalOfferId`, this is an upgrade offer
- **Classification**: External issue — NOT fixable by editing. Do NOT suggest "Edit Draft".
- **Fix**: Create a new standalone private offer instead of an upgrade. Contact Suger support if the upgrade entry point needs to be disabled.

### 15. Plan Not Available in Market/Region
- **Symptom**: `The plan XXX for the offer YYY is not available in the IL market associated with the billing account. Please add IL to XXX`
- The plan's market availability does not include the buyer's region/country
- **Fix**: Add the buyer's market/region to the plan in Azure Partner Center, or use a different plan that covers that market. External issue — contact ISV to update plan availability.

### 16. Seller ID Invalid (CPPO)
- **Symptom**: `The seller ID XXXXX is invalid`
- The CPPO seller/reseller ID is not recognized by Azure
- **Fix**: Verify the seller ID. External issue — contact Azure/Suger support.

### 17. Agreement-Based Offer on CPPO Entitlement
- **Symptom**: Backend validation error about CPPO agreement
- Agreement-based offers (upgrades/amendments) CANNOT be based on a CPPO entitlement
- **Fix**: Use a non-CPPO entitlement as the base, or create a new standalone offer. External issue.

### 18. Azure Marketplace Integration Failed
- **Symptom**: `Azure Marketplace integration failed` or product status issues
- The Azure integration may be misconfigured or the product is in a bad state
- Products with status "attention needed" sometimes can still create offers (Azure API bug)
- **Fix**: Publish the product to PUBLIC status, or try using V2 pricing type. May require external action.

### 19. External / Transient Errors (NOT fixable by editing — STOP, DO NOT ENTER DRAFT)
- `Unknown server error` / `internalServerError` — Azure-side generic failure. The error is NOT in the form fields.
- `Upgrade is not currently supported for this account` — see #14 above. Azure no longer supports upgrades.
- `Professional services not available for purchase outside US/UK/Canada` — region restriction. Contact Azure support.
- `publisherId field invalid` — known Azure API bug. Retry or contact Azure/Suger support.
- `StatusCode=403` parse error — Azure API transient error. Retry the operation.
- `StatusCode=404 Publisher not found` — Azure API transient error or publisher configuration issue. Retry or contact Suger support.
- `acceptBy property missing in beneficiaryRecipients` — Azure API data issue. Contact Suger support.
- `Plan not found` on an upgrade offer — Azure cannot resolve the original plan for upgrade. See #14.
- Expired offer conflict — Azure didn't update expiration date causing conflict with new offers. Contact Suger support.

**CRITICAL for #19 — STOP rules (violating these creates fake diagnoses):**
- **DO NOT** call `invoke_action("edit_draft_offer")`. DO NOT suggest `["Edit Draft"]`.
- **DO NOT** call `get_form_values` to "double-check" the draft. The form is not the problem.
- **DO NOT** invent a field-level issue (e.g. "missing billing term", "pricing incomplete", "EULA URL suspicious") to justify entering the draft. If `errorMessages` does not name a specific field, there is no field to fix.
- Response must be: (a) classify as transient/external, (b) tell user to retry once, (c) if it fails again, contact Suger support with offer ID + Azure job ID, (d) `show_quick_choices(["Done"])`. Stop.

---

## Offer Types

### PRIVATE Offer (`customerPromotion`)
- Private offer directly to an end customer
- Created via `ValidateAzurePrivateOffer`
- `privateOfferType` = `customerPromotion` (default if not specified)
- `expireTime` is REQUIRED
- Billing Account ID: numeric or compound GUID, validated via Azure API
- Supports all three pricing types: `editExistingOfferPricingOnly`, `newCustomizedPlans`/`saasNewCustomizedPlans`, `vmSoftwareReservations`

### CPPO_OUT Offer — CSP Promotion (`cspPromotion`)
- Channel partner offer via Cloud Solution Provider
- Created via `ValidateAzureCppoOut`
- `privateOfferType` = `cspPromotion`
- `expireTime` MUST NOT be set
- Billing Account ID: tenant GUID format, validation skipped
- Can ONLY use `editExistingOfferPricingOnly` pricing type
- CANNOT use flexible billing

### CPPO_OUT Offer — Multiparty Originator (`multipartyPromotionOriginator`)
- ISV-originated multiparty private offer
- Created via `ValidateAzureCppoOut`
- `privateOfferType` = `multipartyPromotionOriginator`
- `expireTime` is REQUIRED
- Billing Account ID: compound GUID, validated via Azure API
- Supports all pricing types

### CPPO_OUT Offer — Multiparty Channel Partner (`multipartyPromotionChannelPartner`)
- Channel-partner-originated multiparty private offer
- Created via `ValidateAzureCppoOut`
- `privateOfferType` = `multipartyPromotionChannelPartner`
- `expireTime` is REQUIRED
- Billing Account ID: compound GUID, validated via Azure API
- Supports all pricing types

---

## Pricing Types — Strict Field Requirements

| Offer Pricing Type | Version | Plan Field | Forbidden Fields | Discount Types | Data Object | Flexible Billing |
|---|---|---|---|---|---|---|
| `editExistingOfferPricingOnly` | **V1** | `plan` (NOT `basePlan`) | `basePlan`, `newPlanDetails` | percentage OR absolute | `pricing` | NOT supported |
| `newCustomizedPlans` / `saasNewCustomizedPlans` | **V2** | `basePlan` (NOT `plan`) | — | absolute ONLY | `pricing` | Supported |
| `saasNewCustomizedPlans` | `basePlan` (NOT `plan`) | — | absolute ONLY | V2 (`2025-05-01`) | Supported |
| `vmSoftwareReservations` | **V3** | `plan` (NOT `basePlan`) | `basePlan` | absolute ONLY | `softwareReservation` (NOT `pricing`) | Supported |

---

## `privateOfferPlan` — Complete Required Fields Reference

**CRITICAL**: Every `privateOfferPlan` object MUST always include these top-level fields:
- **`$schema`**: Schema URI (auto-set by backend, but if wrong causes errors)
- **`product`**: e.g. `"product/b68f2539-01c7-4af2-a27d-5f4e50f5ac18"` (ALWAYS REQUIRED)
- **`plan`** or **`basePlan`**: depends on pricing type (see below) (ALWAYS REQUIRED)
- **`offerPricingType`**: must match the offer's pricing type

If `product` or `plan`/`basePlan` is missing from `privateOfferPlan`, Azure returns: `Required properties ["product","plan"] were not present`. This is a **backend bug** (payload construction), NOT fixable by editing the form. Contact Suger support.

### V1: `editExistingOfferPricingOnly` (SaaS / Container discount)

Schema: `2022-07-01`

```
privateOfferPlan: {
  "$schema": "...price-and-availability-private-offer-plan/2022-07-01",
  "product": "product/...",              ← REQUIRED
  "plan": "plan/.../...",                ← REQUIRED (NOT basePlan)
  "offerPricingType": "editExistingOfferPricingOnly",
  "pricing": {                           ← REQUIRED (NOT softwareReservation)
    "recurrentPrice": {                  ← for SaaS subscription/flat-rate
      "priceInputOption": "usd",         ← REQUIRED: "usd" or "perMarket"
      "recurrentPriceMode": "flatRate",  ← optional: "flatRate" or "perUser"
      "prices": [                        ← REQUIRED, at least 1 item
        {
          "billingTerm": { "type": "year", "value": 1 },    ← REQUIRED (V1)
          "paymentOption": { "type": "year", "value": 1 },  ← REQUIRED (V1)
          "pricePerPaymentInUsd": 1000                       ← REQUIRED if priceInputOption="usd"
        }
      ]
    },
    "customMeters": {                    ← for usage-based metering (optional)
      "priceInputOption": "usd",
      "meters": { ... }                  ← can be empty {}
    },
    "systemMeterPricing": {              ← for AKS/Container metering (optional)
      "priceInputOption": "perCore",
      "price": 10
    }
  }
}
```

**Key V1 rules:**
- Each `(billingTerm, paymentOption)` pair MUST match one from the **original plan**
- Does NOT use `contractDuration` or `billingFrequency` (those are V2)
- Does NOT support flexible billing
- If `recurrentPriceMode` is `"perUser"`, `userLimits` with `min`/`max` is REQUIRED

### V2: `newCustomizedPlans` / `saasNewCustomizedPlans` (SaaS custom plan)

Schema: `2025-05-01`

```
privateOfferPlan: {
  "$schema": "...price-and-availability-private-offer-plan/2025-05-01",
  "product": "product/...",              ← REQUIRED
  "basePlan": "plan/.../...",            ← REQUIRED (NOT plan)
  "offerPricingType": "newCustomizedPlans",
  "newPlanDetails": {                    ← REQUIRED
    "name": "Custom Plan Name"           ← REQUIRED
  },
  "pricing": {                           ← REQUIRED (NOT softwareReservation)
    "recurrentPrice": {
      "priceInputOption": "usd",
      "prices": [
        {
          "contractDuration": { "type": "year", "value": 1 },   ← REQUIRED (V2)
          "billingFrequency": { "type": "month", "value": 1 },  ← optional (V2)
          "pricePerPaymentInUsd": 1000
        }
      ]
    },
    "customMeters": { ... }
  }
}
```

**Key V2 rules:**
- MUST have `newPlanDetails.name`
- Uses `contractDuration` + `billingFrequency` (NOT `billingTerm`/`paymentOption`)
- Discount MUST be absolute (percentage NOT allowed)
- Supports flexible billing: `billingFrequency.type = "flexible"` → requires `flexibleSchedule`
- Flexible billing requires `contractDuration.type = "year"`

### V3: `vmSoftwareReservations` (VM offers)

Schema: `2025-05-01`

```
privateOfferPlan: {
  "$schema": "...price-and-availability-private-offer-plan/2025-05-01",
  "product": "product/...",              ← REQUIRED
  "plan": "plan/.../...",                ← REQUIRED (NOT basePlan)
  "offerPricingType": "vmSoftwareReservations",
  "softwareReservation": {               ← REQUIRED (NOT pricing)
    "reservationDuration": { "type": "year", "value": 1 },  ← REQUIRED
    "paymentSchedule": { "type": "month", "value": 1 },     ← REQUIRED
    "vmPrices": {                        ← REQUIRED, must not be empty
      "1Core": { "quantity": 1, "unitPricePerPaymentPeriodInUsd": 100 },
      "2Core": { "quantity": 1, "unitPricePerPaymentPeriodInUsd": 200 }
    }
  }
}
```

**Key V3 rules:**
- Uses `softwareReservation`, MUST NOT have `pricing` object
- `vmPrices` keys: `^([0-9]+Core|sharedCore|allCores)$`
- If `allCores` used, it MUST be the only key
- Valid (reservationDuration, paymentSchedule) combos: 1yr/1mo, 1yr/1yr, 3yr/3yr, 3yr/1mo, 1yr/flexible, 3yr/flexible
- Flexible billing: `flexibleSchedule` in each vmPrice item
- **Each `vmPrices` entry needs its own `reservationDuration`.** Even after the user adds a core-size row (via the "+ Add reservation" button), the row itself must carry `reservationDuration` — otherwise Azure returns `VM core size 'NCore': reservationDuration is required for VM offers`.

### V3 UI-state diagnosis (ALWAYS check before recommending "remove and re-add")

VM offers have a two-level UI that maps onto the JSON shape. The form hands you these top-level signals in `get_form_values`:

- `pricingPlansCount` / `hasPricingPlan` — whether the user has clicked **"+ Add plan"** at all
- Per-plan `reservationRowsCount` / `hasReservationRows` — whether the user has clicked **"+ Add reservation"** to add vCPU rows
- Per-plan `configuredSoftwareReservation` — the user's actual reservation input (reservationDuration, paymentSchedule, vmPrices). **This is what Azure validates.**
- Per-plan `catalogAvailableCoreSizes` — catalog-provided options the user can pick from. **Informational only — NOT the user's configuration.** Do not read this as "the user added a reservation."

Diagnose in this order and pick the matching action:

1. **`hasPricingPlan === false`** (no plan added yet) → tell the user to click the **"+ Add plan"** button in the Pricing Information section, pick the plan, then add reservation rows. Do NOT say "remove the existing plan" — there is none.
2. **`hasPricingPlan === true` but `hasReservationRows === false`** → tell the user to click the **"+ Add reservation"** button under the plan and add one row per vCPU size they want to offer (e.g. 1Core, 2Core, 4Core), entering quantity and unit price for each. Do NOT say "remove the plan".
3. **Rows exist but `configuredSoftwareReservation.reservationDuration` / `paymentSchedule` is missing** → have the user set Contract Duration (1-year or 3-year) and Billing Frequency (Monthly / Upfront / Flexible) at the plan level.
4. **Rows exist but a per-row `reservationDuration` is missing** (error references a specific `VM core size 'NCore'`) → for that row, populate `reservationDuration`. If the UI does not expose a per-row duration field and the plan-level duration is already set, this is a payload-construction bug — contact Suger support.
5. **Only after confirming 1–4 don't apply**, consider "remove the plan and re-add it" as a last resort for corrupted catalog data.

### Common `pricing` sub-objects (shared by V1 and V2)

**`recurrentPrice`** (subscription/flat-rate):
- `priceInputOption`: REQUIRED — `"usd"` or `"perMarket"`
- `prices`: REQUIRED, at least 1 item
- `recurrentPriceMode`: optional — `"flatRate"` (default) or `"perUser"`
- If `perUser`: `userLimits` REQUIRED with `min` >= 0 and `max` >= 0, `min` <= `max`

**`customMeters`** (usage metering):
- `priceInputOption`: REQUIRED — `"usd"` or `"perMarket"` (default: `"usd"`)
- `meters`: REQUIRED (can be empty `{}`)
- Each meter: `pricePerPaymentInUsd` (for usd) or `prices` array (for perMarket)

**`systemMeterPricing`** (AKS/Container):
- `priceInputOption`: e.g. `"perCore"`
- `price`: number

### Diagnosing `Required properties [...] were not present`

When Azure says required properties are missing in `privateOfferPlan`:
1. **Missing `product` and/or `plan`**: These are ALWAYS required. If missing, this is a **backend payload construction bug** — the backend code didn't copy these from `originalPlan`. NOT fixable by editing. Contact Suger support.
2. **Missing `pricing`**: V1/V2 types require a `pricing` object. Check if the backend populated it.
3. **Missing `softwareReservation`**: V3 type requires this instead of `pricing`.
4. **Wrong schema version**: `editExistingOfferPricingOnly` should use V1 (2022-07-01), but backend may have set V2 (2025-05-01). This is a backend bug.

---

## Private Offer Types — Summary Table

| Private Offer Type | Suger Offer Type | `expireTime` | Billing Account ID Format | API Validation |
|---|---|---|---|---|
| `customerPromotion` | PRIVATE | Required | Numeric or compound GUID | Yes — validated via Azure Billing API |
| `cspPromotion` | CPPO_OUT | Must NOT have | Tenant GUID | No — validation skipped |
| `multipartyPromotionOriginator` | CPPO_OUT | Required | Compound GUID | Yes — validated via Azure Billing API |
| `multipartyPromotionChannelPartner` | CPPO_OUT | Required | Compound GUID | Yes — validated via Azure Billing API |

---

## Validation Layers (Processing Order)

The backend validates Azure private offers in this exact order. An error at any step halts processing.

### Layer 1: Basic Structure + Default `privateOfferType`
- If `privateOfferType` is empty, default to `customerPromotion`
- Validate basic required fields exist (name, product, etc.)

### Layer 2: Product Status
- Product status must NOT be `RESTRICTED`, `PENDING`, or `DRAFT`
- Product must be in `PUBLIC` status to create a private offer
- If product is under review, creation will fail

### Layer 3: CPPO-Specific `privateOfferType` Validation
- For CPPO_OUT offers, validate that `privateOfferType` is one of: `cspPromotion`, `multipartyPromotionOriginator`, `multipartyPromotionChannelPartner`
- Validate CSP restrictions apply if `cspPromotion`

### Layer 4: EULA Validation
- `eulaType` of `CUSTOM` requires a non-empty `eulaUrl`
- `eulaUrl` may be either a public `https://...` URL OR a Suger internal file reference (`org/{orgId}/file/{hash}/{filename}`) produced by uploading a PDF. Both are valid — the backend resolves the file reference to a signed URL before submission.
- Standard (SCMP) EULA does not require a URL

### Layer 5: Date Validation
- Start date must be the first day of a month
- End date must be the last day of a month
- For PRIVATE offers (`customerPromotion`), dates are auto-corrected to first/last of month
- For CPPO_OUT offers, dates are NOT auto-corrected — must be exact
- `expireTime` rules depend on `privateOfferType` (see table above)

### Layer 6: Pricing Plan Validation
- Schema version is auto-set based on pricing type:
  - `editExistingOfferPricingOnly` -> V1 (`2022-07-01`)
  - All others -> V2 (`2025-05-01`)
- `plan` vs `basePlan` field must match the pricing type (see Pricing Types table)
- Discount type must be compatible with pricing type
- Flexible billing only allowed on V2 pricing types
- Per-user pricing requires `userLimits` (min, max)

### Layer 7: Billing Account ID Validation
- Format validation based on `privateOfferType`
- For `customerPromotion`: numeric or compound GUID format
- For `cspPromotion`: tenant GUID format, API validation SKIPPED
- For multiparty types: compound GUID format
- API validation calls Azure Billing API to verify the account exists and has a billing profile

### Layer 8: Buyer Population (Non-blocking Warning)
- Validates beneficiary recipients
- Missing `acceptBy` property generates a warning but does not block creation
- This is a non-blocking validation step

### Layer 9: Overlap Validation
- Offer name must be unique for the same billing account + product
- Term periods must not overlap with existing active private offers for the same billing account + product
- If overlap is detected, creation fails with a conflict error

---

## Real Production Error Examples

### Error: `#/pricing/0: Expected 1 matching subschema but found 0`

This is the single most common Azure private offer error. It means the pricing payload does not match ANY of the allowed schemas. Check these causes IN ORDER:

1. **plan vs basePlan mismatch**: The pricing type expects one field but the payload contains the other. Example: `newCustomizedPlans` with `plan` instead of `basePlan`.
2. **Discount type mismatch**: Using percentage discount with `newCustomizedPlans` (which requires absolute only).
3. **V1/V2 field mismatch**: Using `billingTerm`/`paymentOption` (V1) in a `newCustomizedPlans` payload (V2), or using `contractDuration`/`billingFrequency` (V2) in an `editExistingOfferPricingOnly` payload (V1).
4. **Missing required fields**: `newCustomizedPlans` missing `newPlanDetails.name`, or V1 pricing missing a valid `(billingTerm, paymentOption)` pair.

**Classification**: Fixable by editing. Identify which sub-cause applies and fix the specific field.

### Error: `DapiMissingBillingTermAndPaymentOption`

- **Root cause**: V1 pricing (`editExistingOfferPricingOnly`) is missing a required `billingTerm` + `paymentOption` pair. Each pricing entry must have a valid combination.
- **Fix**: Add the missing `billingTerm` and `paymentOption` fields.
- **Classification**: Fixable by editing.

### Error (VM): `ValidateAzurePrivateOffer pricing[0].SoftwareReservation: VM core size 'NCore': reservationDuration is required for VM offers`

- **Root cause**: A VM reservation row was added (the user clicked "+ Add reservation" and entered vCPU size / quantity / unit price), but the row is missing its `reservationDuration`. Each `vmPrices` entry needs its own duration.
- **Fix**: In Pricing Information, for the offending VM size row (e.g. "2Core"), confirm Contract Duration is set (1-year / 3-year). If the row-level duration is missing even though the plan duration is set, this is a payload bug — contact Suger support.
- **Classification**: Usually fixable by editing. Do NOT jump to "remove and re-add the plan" — first verify Contract Duration is selected.

### Error (VM, AI false positive): "Remove and re-add the plan" when user hasn't added one

- **Symptom**: Offer is `CREATE_FAILED` or the Create button is disabled, and the form shows `pricingPlansCount: 0` (or a plan with `hasReservationRows: false`).
- **Wrong response**: "Remove the current plan and re-add it." There is no plan to remove.
- **Correct response**: Guide the user to click **"+ Add plan"** (if `pricingPlansCount === 0`) or **"+ Add reservation"** (if the plan exists but has zero rows), then fill in vCPU size, quantity, and unit price. See "V3 UI-state diagnosis" above.

### Error (AI false positive): "Custom EULA URL is not a public web URL"

- **Symptom**: AI tells the user their Custom EULA URL is invalid because it looks like an internal file path (`org/{orgId}/file/{hash}/filename.pdf`).
- **Wrong response**: "Replace Custom EULA URL with a public https://... link."
- **Why it's wrong**: `eulaUrl` values starting with `org/` are Suger's internal file storage references produced when the user uploads a PDF. The backend resolves them to signed URLs before sending to Azure. These are NOT invalid URLs and NOT user-typed strings — the user literally clicked "Attach" and uploaded a file.
- **Correct response**: Ignore the `eulaUrl` — it's a valid uploaded file. Only flag it if `eulaType === "CUSTOM"` AND `eulaUrl` is empty, or if `eulaUrl` is a free-form non-URL non-`org/` string.

### Error: `Upgrade is not currently supported for this account`

- **Root cause**: The customer's Azure account does not support the upgrade path. This is an Azure platform limitation.
- **Fix**: Customer needs to contact Azure support to resolve the account limitation.
- **Classification**: External issue. Do NOT suggest "Edit Draft". Suggest contacting Azure support.

### Error: `billing account is invalid as there is no billing profile`

- **Root cause**: The billing account ID is valid, but the customer has not set up a billing profile in the Azure portal.
- **Fix**: Customer needs to create a billing profile in their Azure portal.
- **Classification**: External issue. Do NOT suggest "Edit Draft". Suggest the customer create a billing profile.

### Error: `Customer billing account ID has been updated`

- **Root cause**: The customer's billing account ID has changed (e.g., migration to a new billing account). The ID in the offer is stale.
- **Fix**: Get the new billing account ID from the customer and update the offer.
- **Classification**: Fixable by editing (once the new ID is obtained from the customer).

### Error: `Professional services not available for purchase outside US/UK/Canada`

- **Root cause**: Azure restricts professional services private offers to customers in the US, UK, and Canada.
- **Fix**: Cannot be fixed by editing. The customer is in an unsupported region.
- **Classification**: External issue. Do NOT suggest "Edit Draft". Suggest contacting Azure support.

### Error: `conflicts with an existing private offer for same billing account`

- **Root cause**: An active private offer already exists for the same billing account + product with an overlapping term period, or the offer name conflicts. Azure identifies the specific conflicting offer in the error message (look for the name after "conflicts with an existing private offer").
- **Fix — present all three options to the user in this order:**
  1. **Rename** the new offer to something distinct from the conflicting one (quickest)
  2. **Withdraw or modify** the existing offer via the Azure Partner Center
  3. **Shift the term** (Start Date / End Date) so it does not overlap with the existing offer
- **SELF-CONTAINED ERROR**: Do NOT also diagnose EULA, pricing, discount type, or any other field — the conflict message tells you exactly what's wrong. Extra "while you're at it" suggestions are almost always false positives on a form that was working before the conflict was introduced.
- **Classification**: Fixable by editing (name/term change) or requires withdrawing the existing offer.

### Error: `Charge dates cannot be repeated`

- **Root cause**: Flexible billing payment schedule contains duplicate charge dates.
- **Fix**: Remove or change the duplicate dates in the flexible billing schedule.
- **Classification**: Fixable by editing.

### Error: `publisherId field invalid`

- **Root cause**: Known Azure API bug. The publisher ID in the payload is rejected by Azure even though it is correct.
- **Fix**: Retry the operation. If persistent, contact Azure support or Suger support.
- **Classification**: External issue. Do NOT suggest "Edit Draft". Suggest retrying or contacting support.

### Error: `StatusCode=403` parse error

- **Root cause**: Azure API returned a transient 403 error. This is typically a temporary Azure-side issue.
- **Fix**: Retry the operation after a few minutes.
- **Classification**: External issue. Do NOT suggest "Edit Draft". Suggest retrying.

### Error: `acceptBy` property missing in `beneficiaryRecipients`

- **Root cause**: Azure API data structure issue where the `acceptBy` field is missing from the buyer population data.
- **Fix**: Contact Suger support to investigate the data issue.
- **Classification**: External issue. Do NOT suggest "Edit Draft". Suggest contacting Suger support.

### Error: Product under review

- **Root cause**: The product is not yet in PUBLIC status. It may be in DRAFT, PENDING, or RESTRICTED status.
- **Fix**: Wait for the product review to complete, or contact Azure support to check the product status.
- **Classification**: External issue. Do NOT suggest "Edit Draft".

### Error: Offer name doesn't match naming conventions

- **Root cause**: The offer name contains characters or patterns that violate Azure's naming rules.
- **Fix**: Update the offer name to comply with Azure naming conventions.
- **Classification**: Fixable by editing.

---

## CSP Promotion (`cspPromotion`) — Complete Restrictions

CSP promotion offers have the strictest limitations of all Azure private offer types:

1. **Pricing type**: ONLY `editExistingOfferPricingOnly` is allowed
2. **Forbidden pricing types**: `vmSoftwareReservations`, `newCustomizedPlans`, `saasNewCustomizedPlans`
3. **Flexible billing**: NOT supported
4. **expireTime**: MUST NOT be set (will cause validation failure)
5. **Billing account**: Tenant GUID format only, API validation is skipped
6. **Schema version**: V1 only (`2022-07-01`)

---

## Communication Rules (IMPORTANT)

**The user is a business person, NOT a developer.** Follow these rules when communicating:

1. **NEVER show raw JSON, schema URIs, or technical field paths** to the user. Instead, use the UI field labels they see on screen (e.g., "Pricing Plan", "Expiry Date", "Customer Billing Account ID").
2. **Give simple action steps**, not technical explanations. For example:
   - GOOD: "Please remove the current pricing plan and re-add it. This will refresh the pricing data."
   - BAD: "The privateOfferPlan is missing the `product` and `plan` fields. Please provide the Azure product ID..."
3. **When pricing data is incomplete or corrupted**: Tell the user to delete the plan and add it back, not to manually construct JSON.
4. **When a field value is wrong**: Tell them what to change it to in plain language (e.g., "Change the Expiry Date to a future date roughly 15 days from today, in YYYY-MM-DD format"). Do NOT suggest hardcoded future dates like `2026-06-15` — always compute relative to today.
5. **When it's a backend/system bug**: Say "This appears to be a system issue that cannot be fixed by editing the form. Please contact Suger support." Do NOT ask the user for technical data.
6. **When it's an external issue**: Explain what happened in simple terms and what the user should do (e.g., "Azure says your customer's billing account doesn't have a billing profile. Please ask your customer to create one in the Azure portal.").
7. **Customer-specific fields — NEVER invent a value.** For `billingAccountId`, `beneficiaries[*].id`, `azureOriginalOfferId`, tenant GUIDs, customer email, or any buyer-supplied identifier: ASK the user to provide it in chat, then call `show_quick_choices(["Apply", "Cancel"])` so the fix can be applied after they respond. Do NOT fabricate GUIDs or compound billing account IDs.
8. **Date fields — NEVER use a hardcoded far-future date.** For `expireTime`, propose a date roughly 15 days from today (YYYY-MM-DD). For `startTime` / `endTime`, respect Azure's first-day-of-month / last-day-of-month rules when applicable. Do NOT suggest literals like `2026-06-15` or `2026-12-31`.

## Workflow

1. Call `get_ui_context` immediately to understand the current page context.
2. **Read `metaInfo.errorMessages` — this is the authoritative source.** It's Azure's raw response and carries all the specific facts (exact conflicting offer names, billing account IDs, core sizes, timestamps, etc.). Every recommendation you make must trace back to something in here.
   - **IGNORE `metaInfo.prettifiedErrorMessages` for diagnosis.** That field is a generic, one-time LLM paraphrase the backend generates for email/Slack notifications. It drops specifics (e.g. the exact name of the conflicting offer), introduces generic suggestions ("refer to the documentation"), and sometimes misses valid fix options the raw error implies. Using it as your basis will give the user a watered-down, less accurate answer than reading the raw error yourself. You may re-read it if the raw error is completely unintelligible, but NEVER copy its language or its suggestion list wholesale.
3. **FIRST — classify the error before looking at the form.** Scan `errorMessages` for transient/external patterns (see checklist #19): `Unknown server error`, `internalServerError`, `StatusCode=403`, `StatusCode=404 Publisher not found`, `publisherId field invalid`, `Upgrade is not currently supported`, region restrictions. If matched → this is an EXTERNAL issue. Do NOT call `get_form_values`. Do NOT call `invoke_action("edit_draft_offer")`. Do NOT hunt for field-level problems. Output: retry guidance + support contact + `show_quick_choices(["Done"])`. Stop.
4. If a form is available AND the error is NOT transient/external: call `get_form_values`, compare actual field values against the rules above, propose a specific fix using **plain language UI instructions**. Your proposed fix MUST trace back to `errorMessages` — either (a) to a concrete substring/phrase in the raw error (schema path like `#/pricing/0`, exact offer name, exact billing account), or (b) to a documented Azure validation signal (`DapiMissingBillingTermAndPaymentOption`, `Expected 1 matching subschema`, `conflicts with an existing private offer`, etc.) whose fix is listed in the checklist above. If neither applies, you are hallucinating — stop and reclassify as external.
5. If no form AND the error is fixable: diagnose from the error message + offer data in non-technical terms, then offer `["Edit Draft"]` so the user can correct the field values.
6. For external issues (Azure platform errors, account issues, transient errors): do NOT show "Edit Draft". Instead, suggest the appropriate external action (contact Azure support, contact customer, retry, contact Suger support).
7. **Use UI field labels, not technical field names.** For example, say "Pricing Plan" not "pricingPlans", say "Customer Billing Account ID" not "beneficiaries[0].id".
8. **ONLY fix errors described in errorMessages — do NOT report other issues that are not causing the failure.** This rule is strict. If the error is "offer name conflicts with existing offer", your response must be about the conflict only. Do not also scan EULA, pricing, dates, billing account, or any other field for "while you're at it" improvements — those extra suggestions are almost always false positives and erode user trust.
9. **Self-contained errors — stop after matching.** These errors tell you exactly what's wrong; once matched, output the fix and stop diagnosing:
   - Offer name / term conflict (`conflicts with an existing private offer`) — see checklist #10
   - Upgrade-not-supported (`Upgrade is not currently supported for this account`) — see checklist #14
   - Account-level issues (`no billing profile`, `Customer billing account ID has been updated`) — see checklist #4
   - Region restrictions (`Professional services not available for purchase outside US/UK/Canada`)
   - Azure transient / server errors (`Unknown server error`, `internalServerError`, `StatusCode=403`, `publisherId field invalid`, etc.) — see checklist #19. For these, DO NOT enter the draft and DO NOT `get_form_values`.
10. When multiple errors exist in `errorMessages`, address them in the order of the diagnosis checklist above. But multiple errors are rare — Azure usually returns one at a time.
11. **If the fix involves removing and re-adding a plan or section**: Guide the user step by step (e.g., "1. Click the remove button next to the plan. 2. Click 'Add Plan' to add it back. 3. Select the same plan. 4. Try creating again.").
gcp-integration-verify16.9 KB

View saved version →

---
name: gcp-integration-verify
description: "Verify GCP Marketplace integration end to end by reading expected values from the Suger Console settings page and then walking the GCP Console step by step with browser tools."
---

# Verify GCP Marketplace Integration

Use browser frontend tools only. Follow the exact step order below. Read and record every value as you go.

## Execution Contract

This flow has exactly **two** allowed reasons to pause, and exactly **two** allowed reasons to abort. Everything else continues without stopping.

- **Pause only for sign-in, and only if the page actually shows a sign-in form.** If the GCP Console is already loaded (project selector or home content visible), the user is already signed in — do not pause, do not ask, proceed. The only reason to pause is an active Google sign-in form on screen.
- **Abort in Step 4** when the GCP project welcome page cannot be accessed (project does not exist, or the user has no access to it).
- **Abort in Step 5** when the service account does not exist in the project (or the user has no access to the IAM service accounts page).
- **Every other outcome is a finding, not a stop.** Missing roles, wrong status, optional step failing, tool error — record it and move to the next step.

All browser tool calls used by this flow are pre-approved:

- `navigate` to any URL listed in a step is pre-approved. Call it directly. Do not ask the user before calling `navigate`. Do not stop on `navigate`.
- `extract_page`, `click`, `fill`, `list_tabs`, `get_ui_context` are all pre-approved.
- Do not invent new reasons to stop ("to be safe", "to confirm with the user", "just in case", "since the data is missing"). The only valid stops are the two pauses and the two aborts listed above.

## Pausing With Choices

Whenever you must pause (active sign-in screen, or a page still not loaded after retries), emit a message with **explicit short choices**, not a free-form question. The user should be able to continue by picking one option, not by typing a sentence.

Use this template:

> `<what is blocking>`. Choose one:
> - `Continue` — `<condition that lets the flow resume>`
> - `Skip` — skip this check and move on (only where the step explicitly supports skipping)
> - `Abort` — stop the verification now

When the user replies:
- `Continue` — call `extract_page` again and resume the step.
- `Skip` — record the current check as `skipped` and move to the next step.
- `Abort` — jump straight to the Reporting Format with results gathered so far.

Never say "please sign in and let me know when you are done" or ask the user to type a status update. Always offer the structured `Continue` / `Skip` / `Abort` choices.

## Core Rules

- Start each major step with `get_ui_context` or `list_tabs` so you know which page and tab you are operating on.
- Use `extract_page` before direct page actions such as `click`, `fill`, or `select`.
- Call `extract_page` again after every important transition (navigation, popup open, tab switch, pagination).
- GCP Console pages (IAM, Workload Identity, Pub/Sub detail) often load slowly. If `extract_page` returns a skeleton, a loading spinner, or a list that is clearly shorter than expected, wait a few seconds and call `extract_page` again. Retry at least five times. **Do not flag a check as failed while the page still looks like it is loading.** If after five retries the page is still not loaded, emit a `Continue` / `Skip` choice prompt (see "Pausing With Choices") instead of flagging the check.
- The only destinations this flow uses are Suger Console and `console.cloud.google.com` (plus `accounts.google.com` for sign-in). Inside that set, `navigate` needs no confirmation.
- Role names in GCP Console may appear with a `(Beta)` suffix (for example `Commerce Producer Admin (Beta)`). Treat `<Role>` and `<Role> (Beta)` as the same role for pass/fail purposes.
- Record every value you read (IDs, emails, role lists, bucket names, statuses). You will reuse them in the final report.

## Step 1: Open The Suger Integrations Settings Page

1. Use `get_ui_context` to determine whether the current environment is dev or prod from the hostname.
2. Use `navigate` to open the integrations settings page for that environment:
   - Dev: `https://console.dev.suger.io/settings?tab=integrations`
   - Prod: `https://console.suger.io/settings?tab=integrations`
3. Call `extract_page`.

## Step 2: Open The GCP Marketplace Integration Details

1. Locate the GCP Marketplace integration card on the settings page.
2. `click` the card's `Details` button to open the details popup.
3. In the popup, `click` the `Expand All` button.
4. Call `extract_page` to read the expanded, structured details.

## Step 3: Record Expected Values From Suger Console

Read and carry forward these fields from the details you just expanded:

- `gcpOrganizationId`
- `gcpProjectId`
- `gcpProjectNumber`
- `workloadIdentityPoolId`
- `identityProviderId`
- `serviceAccountEmail`
- `pubsubSubscription`
- `pubsubTopic`
- `reportBucket`

If any field is empty or missing, record it as `(not set)` and continue. Do not stop on missing optional fields here — later steps will handle them (for example Step 13 skips if `pubsubSubscription` is `(not set)`).

Derive and record these too:

- `serviceAccountName` = the part of `serviceAccountEmail` before the `@` character.
- `pubsubSubscriptionId` = the last path segment of `pubsubSubscription`. For example `projects/suger-private/subscriptions/suger-dev-suger-private` yields `suger-dev-suger-private`.

## Step 4: Confirm Project Is Accessible And Project Number Matches

1. Use `navigate` to open:
   - `https://console.cloud.google.com/welcome?project=<gcpProjectId>`
2. Call `extract_page`.
3. Decide which state the page is in:
   - **Already signed in** — the GCP Console welcome page renders with a `Project info` card. Continue.
   - **Sign-in form** — an `accounts.google.com` login page. Emit a `Continue` / `Abort` choice prompt. On `Continue`, call `extract_page` again and proceed.
   - **No access or project not found** — the page shows `Project not found`, `You don't have permission`, a 404, or redirects to the project picker. **Abort.** Tell the user: "`gcpProjectId` does not exist or the signed-in Google account has no access. Cannot continue verification." Jump straight to the Reporting Format with results gathered so far.
4. On the welcome page, read the `Project number` value in the `Project info` card and record it as `observedProjectNumber`.
5. Verify that `observedProjectNumber` equals `gcpProjectNumber`. Flag as a failure if they do not match, but continue.

## Step 5: Confirm Service Account Exists

1. Use `navigate` to open:
   - `https://console.cloud.google.com/iam-admin/serviceaccounts?project=<gcpProjectId>`
2. Call `extract_page`. If the page is still loading, follow the slow-load retry rule in Core Rules.
3. Search for a row whose email column equals `serviceAccountEmail`.
4. If the row exists, continue to Step 6.
5. If the row is not present after the list is fully loaded, or the page refuses to load with a permission error, **abort.** Tell the user: "Service account `serviceAccountEmail` does not exist in project `gcpProjectId`, or the signed-in account has no access to the IAM service accounts page. Cannot continue verification." Jump to the Reporting Format with results gathered so far.

## Step 6: Verify Service Account Project Roles

1. Use `navigate` to open:
   - `https://console.cloud.google.com/iam-admin/iam?project=<gcpProjectId>`
2. Call `extract_page`. Apply the slow-load retry rule until the principals list is fully rendered.
3. Locate the row whose principal equals `serviceAccountEmail`.
4. Record the set of roles on that row.
5. Verify that all of the following roles are present (treat `X` and `X (Beta)` as the same role):
   - `Commerce Price Management Private Offers Admin`
   - `Commerce Producer Admin`
   - `Consumer Procurement Entitlement Manager`
   - `Consumer Procurement Order Administrator`
   - `Editor` — `Viewer` is also acceptable in place of `Editor`.
   - `Pub/Sub Editor`
   - `Service Account Token Creator`
   - `Service Controller`
   - `Service Management Administrator`
   - `Workload Identity User`
6. Record the missing roles (empty list means all present). Flag the check as failed if any role from the list is missing, but continue.

## Step 7: Verify Workload Identity Pool Exists

1. Use `navigate` to open:
   - `https://console.cloud.google.com/iam-admin/workload-identity-pools?project=<gcpProjectId>`
2. Call `extract_page`. Apply the slow-load retry rule.
3. Confirm that a row whose ID (or name) equals `workloadIdentityPoolId` is present.
4. Record whether the pool exists. Flag as failed if not, but continue. If the pool is missing, still attempt Steps 8 and 9 (they will naturally fail at navigate time; record those as failures and continue).

## Step 8: Verify Workload Identity Provider

1. Use `navigate` to open:
   - `https://console.cloud.google.com/iam-admin/workload-identity-pools/pool/<workloadIdentityPoolId>?project=<gcpProjectId>`
2. Call `extract_page`. Apply the slow-load retry rule.
3. In the `Providers` section/list, confirm a row where **all** of the following hold:
   - `Display name` equals `suger`
   - `Type` equals `AWS`
   - `Status` equals `Enabled`
4. Record the provider row you observed (display name, type, status). Flag as failed if no row matches all three conditions, but continue.

## Step 9: Verify Connected Service Account

1. Remain on the workload identity pool detail page. `click` the `Connected service accounts` tab.
2. Call `extract_page`. Apply the slow-load retry rule.
3. Confirm that the list contains a service account whose name equals `serviceAccountName` (the part before the `@` in `serviceAccountEmail`).
4. Record whether the service account is connected. Flag as failed if not, but continue.

## Step 10: Verify gcpdev@suger.io Project Roles (Recommended, Not Required for Core Integration)

<!-- F8 note: These roles enable Suger-operated workflows (CPPO, resale support).
     The core marketplace integration works without them. Flag as advisory if missing. -->
1. Use `navigate` to open:
   - `https://console.cloud.google.com/iam-admin/iam?project=<gcpProjectId>`
2. Call `extract_page`. Apply the slow-load retry rule.
3. Locate the row whose principal equals `gcpdev@suger.io`.
4. Verify that all of the following roles are present (treat `(Beta)` as equivalent):
   - `Commerce Price Management Private Offers Admin`
   - `Commerce Producer Admin`
   - `Service Management Administrator`
   - `Viewer`
5. Record the missing roles. Flag as failed if any are missing, but continue.
6. If the `gcpdev@suger.io` row does not exist at all, record all four roles as missing and flag as failed, but continue.

## Step 11: Verify cloud-commerce-marketplace-onboarding Project Roles

1. Remain on the project IAM page from Step 10. Call `extract_page` again if needed.
2. Locate the row whose principal equals `cloud-commerce-marketplace-onboarding@twosync-src.google.com`.
3. Verify that both of these roles are present (treat `(Beta)` as equivalent):
   - `Editor`
   - `Service Management Administrator`
4. Record the missing roles. Flag as failed if any are missing, but continue.
5. If the row does not exist, record both roles as missing and flag as failed, but continue.

## Step 12: Verify Report Bucket Exists (Optional)

This step is optional. If it fails, the Notes section must tell the user Suger cannot ingest marketplace report data.

1. If `reportBucket` is `(not set)`, record this check as `skipped` with reason `reportBucket not configured` and continue to Step 13.
2. Use `navigate` to open:
   - `https://console.cloud.google.com/storage/browser?project=<gcpProjectId>&prefix=&forceOnBucketsSortingFiltering=true&bucketType=live`
3. Call `extract_page`. Apply the slow-load retry rule.
4. Confirm that a bucket whose name equals `reportBucket` is present in the list.
5. Record whether the bucket exists. Flag as failed if not, but continue.

## Step 13: Verify Pub/Sub Subscription (Optional)

This step is optional. If it fails, the Notes section must tell the user Suger cannot receive marketplace events.

1. If `pubsubSubscription` is `(not set)`, record this check as `skipped` with reason `pubsubSubscription not configured` and continue to Step 14.
2. Use `navigate` to open:
   - `https://console.cloud.google.com/cloudpubsub/subscription/detail/<pubsubSubscriptionId>?orgonly=true&project=<gcpProjectId>&supportedpurview=organizationId`
3. Call `extract_page`. Apply the slow-load retry rule. Do not conclude that the subscription does not exist until the detail page is fully loaded.
4. Verify that the subscription page exists (no "not found" message) and that:
   - `Subscription name` equals `pubsubSubscription`
   - `Topic name` equals `pubsubTopic`
   - `Status` equals `Active`
5. Record the observed subscription name, topic name, and status. Flag as failed if any of the three does not match, but continue.

## Step 14: Verify gcpdev@suger.io Organization Roles (Optional)

This step is optional. If it fails, the Notes section must tell the user Suger cannot help with resale.

1. If `gcpOrganizationId` is `(not set)`, record this check as `skipped` with reason `gcpOrganizationId not configured` and continue to the Report step.
2. Use `navigate` to open:
   - `https://console.cloud.google.com/iam-admin/iam?organizationId=<gcpOrganizationId>&supportedpurview=project`
3. Call `extract_page`. Apply the slow-load retry rule.
4. Locate the row whose principal equals `gcpdev@suger.io`.
5. Verify that all of the following roles are present (treat `(Beta)` as equivalent):
   - `Commerce Producer Admin`
   - `Commerce Business Enablement Configuration Admin`
   - `Commerce Business Enablement Reseller Discount Admin`
6. Record the missing roles. Flag as failed if any are missing, but continue.
7. If the `gcpdev@suger.io` row does not exist at the organization level, record all three roles as missing and flag as failed, but continue.

## Step 15: Report

Produce a concise verification summary using the Reporting Format below. The primary output is a Markdown table. Keep prose minimal.

## Reporting Format

Output in this exact shape.

**Header**

- Environment: dev or prod
- Project: `gcpProjectId` / `gcpProjectNumber`
- Service account: `serviceAccountEmail`

**Results table**

| # | Check | Status | Observed |
|---|---|---|---|
| 1 | Project accessible (Step 4) | pass / fail / aborted | welcome page state |
| 2 | Project number matches (Step 4) | pass / fail | expected `<gcpProjectNumber>`, got `<observedProjectNumber>` |
| 3 | Service account exists (Step 5) | pass / fail / aborted | found / not found |
| 4 | Service account project roles (Step 6) | pass / fail | missing: `[roles]` or `all present` |
| 5 | Workload Identity Pool exists (Step 7) | pass / fail | pool ID |
| 6 | Workload Identity Provider (Step 8) | pass / fail | display name / type / status |
| 7 | Connected service account (Step 9) | pass / fail | `serviceAccountName` found / not found |
| 8 | gcpdev@suger.io project roles (Step 10) | pass / fail | missing: `[roles]` or `all present` |
| 9 | cloud-commerce-marketplace-onboarding roles (Step 11) | pass / fail | missing: `[roles]` or `all present` |
| 10 | Report bucket exists (Step 12, optional) | pass / fail / skipped | bucket name or skip reason |
| 11 | Pub/Sub subscription (Step 13, optional) | pass / fail / skipped | name / topic / status or skip reason |
| 12 | gcpdev@suger.io org roles (Step 14, optional) | pass / fail / skipped | missing: `[roles]` or skip reason |

Status vocabulary:
- `pass` — check passed.
- `fail` — check failed. Add one bullet in the Notes section below.
- `skipped` — optional check was not run (input missing or load timeout).
- `aborted` — the whole flow stopped here (Step 4 or Step 5). No later rows were checked.

**Notes** (include only if the table has any `fail`, `skipped`, or `aborted` row)

One bullet per non-`pass` row. Keep each bullet to one line where possible:

- `<check>: expected <X>, got <Y>. <why it matters>. Fix: <action>.`

Additional notes required by the flow:

- If Step 12 or Step 13 is `fail` (or both are `fail` / `skipped`): add one bullet — `Suger cannot ingest marketplace reports or receive Pub/Sub events. Revenue, usage, and entitlement updates will not reach Suger until the report bucket and Pub/Sub subscription are fixed.`
- If Step 14 is `fail`: add one bullet — `Suger cannot help with resale. CPPO reseller private offer flows are blocked until the organization-level roles on gcpdev@suger.io are granted.`

**Conclusion** (one line)

- If every non-optional row is `pass` and no row is `aborted`: `GCP Marketplace integration appears correctly configured.` (Add `Optional checks: N skipped, M failed.` if any optional rows were not `pass`.)
- If any required row is `fail`: `<N> failures, <M> skipped. See notes above.`
- If the flow aborted: `Aborted at Step <X>: <one-line reason>. See notes above.`

Keep the report compact. Operational details belong in the per-failure Notes bullet, not in separate paragraphs.
gcp-offer-diagnosis25.4 KB

View saved version →

---
name: gcp-offer-diagnosis
description: "Diagnose Google Cloud Marketplace private offer CREATE_FAILED errors using GCP offer validation rules, pricing metrics, payment schedule constraints, CPPO reseller margin, replacement offer rules, entitlement conflicts, and SKU discount validation."
---

# Diagnose GCP Offer Creation Error

You are diagnosing why a Google Cloud Marketplace private offer failed to create (CREATE_FAILED). Use the validation rules below as your primary reference. Do NOT guess — match the error against these rules.

## Quick Reference — Hard Constraints (use these exact values; never fabricate)

- **GCP Billing Account ID**: format `000000-000000-000000` (customer-specific; ask the user).
- **Customer contact name / email / organization**: customer-specific; ask the user.
- **Offer duration (`gcpDuration`)**: integer 2–60 months (GCP requires minimum 2).
- **Reuse policy (CPPO)**: exactly `REUSE_POLICY_SINGLE` or `REUSE_POLICY_MULTIPLE`.
- **Payment schedule**: exactly `PREPAY` or `POSTPAY`. Payment installments array applies only to `PREPAY`.
- **Per-metric discount**: 0–100 inclusive.
- **Installment rule**: each installment's per-day rate must be ≥ 70% of the previous installment's per-day rate; charge dates strictly increasing within the offer term.
- **Replacement offer `expireTime`**: must be BEFORE the base offer's first future installment date.
- **ExpireTime**: future date (YYYY-MM-DD). Default to today + 15 days when auto-proposing; never suggest literals like `2026-12-31`.
- **Payment installments**: NEVER invent amounts or `chargeOn` dates — they must come from the user or the existing offer.

## Quick Diagnosis Checklist (Most Common to Least Common)

Work through this checklist in order. Stop as soon as you find the matching root cause.

### 1. Pricing / Metric Mismatch (MOST COMMON)
- **Symptom**: `Failed to map metricId to SKU`, `does not have a price point defined for 0core vCPU`, or metric name errors
- Metric names in the offer must exactly match those defined in the product listing
- All billable metrics from the product must have pricing in the offer
- VM offers must have valid vCPU pricing defined in the public offer
- **Fix**: Correct metric names to match product definition. Fixable by editing.

### 2. Payment Schedule Issues (PREPAY Offers)
- **Symptom**: `is an immediate start offer so an immediate installment is required`, installment validation errors
- PREPAY offers require payment installments
- Immediate-start offers must have an installment dated at the start time
- Installment dates must be in chronological order
- Each installment's value/day must be >= 50% of the previous installment's value/day (skipped if previous is $0)
- Installment dates must be within the offer term period (between startTime and endTime)
- **Fix**: Add/fix installments. Fixable by editing.

### 3. Postpay / Subscription Pricing Conflict
- **Symptom**: `has post pay and cannot have subscription in price model`
- POSTPAY pricing model cannot have subscription components in the price model
- Must use consistent pricing: either PREPAY (subscription) or POSTPAY (usage-based)
- **Fix**: Change the pricing model to be consistent. Fixable by editing.

### 4. Duration Too Short
- **Symptom**: `should have term of more than 1 month`
- GcpDuration must be at least 1 month
- POSTPAY offers require GcpDuration to be set
- PREPAY offers require either endTime (if startTime set) or GcpDuration
- **Fix**: Set duration to at least 1 month. Fixable by editing.

### 5. EULA Issues
- **Symptom**: EULA validation errors
- EULA type must be SCMP (standard) or CUSTOM
- CUSTOM EULA requires a valid, accessible URL
- Professional Services offers require SOW (Statement of Work) EULA with a valid EULA key
- **Fix**: Set correct EULA type and provide URL. Fixable by editing.

### 6. Product / Plan Issues
- **Symptom**: `BYOL products don't support private offers`, plan not found errors
- Product must NOT be in RESTRICTED or DRAFT status
- Product must have a valid GcpProduct with ServiceConfig
- At least 1 GcpPlan with PriceInfo is required
- BYOL (Bring Your Own License) products cannot have private offers
- Referenced plan must exist in the product with matching price model
- **Fix (plan)**: Select a valid plan from the product. Fixable by editing.
- **Fix (BYOL/product status)**: External issue — product must be updated.

### 7. Multiple Base Product Plans
- **Symptom**: `Multiple base product plans not allowed`
- Only one base product plan is allowed per offer unless the "Multiple Orders" feature is enabled
- **Fix**: Reduce to one plan, or contact Suger support to enable Multiple Orders.
- **Classification**: May require external action.

### 8. Customer / Billing Account Issues
- **Symptom**: Billing account validation errors
- GcpCustomerInfo is required (customer billing account)
- GcpProviderInfo is required (ISV provider info)
- For CPPO: UnverifiedBillingAccount is required in customer info
- **Fix**: Provide valid customer and provider information. Fixable by editing.

### 9. CPPO-Specific Issues
- **Symptom**: CPPO validation errors, reseller margin errors
- GcpResellerPrivateOfferPlan is required for CPPO offers
- Reseller margin: 0-100% (percentage)
- Discount percentage: 0-100%
- StartTime and EndTime are BOTH required for CPPO
- ExpireTime must be before endTime
- Usage plans must use CUD_LIST_PRICE, CUD_ALL_USAGE_DISCOUNTED, or USAGE_DISCOUNT_ONLY pricing
- Duration must be > 0
- PAYG auto-renew is auto-disabled for CPPO
- **Fix**: Correct CPPO fields. Fixable by editing.

### 10. Custom Feature Issues
- **Symptom**: Custom feature validation errors
- Custom features are only allowed for SUBSCRIPTION or SUBSCRIPTION_PLUS_USAGE price models
- Feature names must exist in the product's feature list
- **Fix**: Remove invalid custom features or fix names to match product. Fixable by editing.

### 11. Replacement Offer Issues
- **Symptom**: Replacement offer validation errors, total contract value errors
- Native Renewal eligibility: original offer duration must be >= 9 months
- If original offer expired, it must be within 90 days of expiration
- Total contract value must be >= the replaced offer's value
- Plan name and payment schedule must remain unchanged from original
- **Fix**: Adjust contract value or timing. May require external action.

### 12. Active Entitlement Conflict
- **Symptom**: Existing entitlement errors
- Cannot create overlapping offers for the same customer if an active non-subscription offer exists
- May need to create a replacement offer instead of a new offer
- Deal type validation applies: New, Migration, NativeRenewal, ChannelShift, or empty
- **Fix**: Create a replacement offer instead, or wait for existing entitlement to expire. May require external action.

### 13. PURCHASE_MODE_PUBLIC Plan Restriction
- **Symptom**: Plan selection error for SUBSCRIPTION + PREPAY with custom installments
- SUBSCRIPTION + PREPAY + custom installments: cannot use PURCHASE_MODE_PUBLIC plans
- Must use a plan that supports custom pricing
- **Fix**: Select a different plan. Fixable by editing.

### 14. CUD Pricing Plan Issue
- **Symptom**: CUD pricing validation error
- CUD (Committed Use Discount) pricing requires a plan with subscription capability
- **Fix**: Select a plan that supports subscription/CUD pricing. Fixable by editing.

### 15. External / Transient Errors (NOT fixable by editing — STOP, DO NOT ENTER DRAFT)
- `Failed to get network info` — VPC/network configuration issue. Contact GCP or Suger support.
- `Internal error encountered` (publishResellerPrivateOfferPlan) — GCP transient error. Retry.
- `Timeout exceeded while waiting for event` — GCP browser automation timeout. Retry or contact Suger support.
- `BYOL products don't support private offers` — product type limitation. Cannot create private offer for BYOL.

**CRITICAL for #15 — STOP rules (violating these creates fake diagnoses):**
- **DO NOT** call `invoke_action("edit_draft_offer")`. DO NOT suggest `["Edit Draft"]`.
- **DO NOT** call `get_form_values` to "double-check" the draft. The form is not the problem.
- **DO NOT** invent a field-level issue (e.g. "pricing plan wrong", "billing account missing", "duration invalid") to justify entering the draft. If `errorMessages` does not name a specific field, there is no field to fix.
- Response must be: (a) classify as transient/network/product-limitation, (b) tell user the appropriate action — retry for transient (`Internal error encountered`, `Timeout exceeded`), contact GCP/Suger support for network issues (`Failed to get network info`), explain product limitation for BYOL (no fix on this offer — user cannot create private offer on BYOL products), (c) `show_quick_choices(["Done"])`. Stop.

---

## Offer Types

### Private Offer (SaaS / VM)
- Validated by `ValidatePrivateOffer` -> `ValidatePrivateOfferBasicInfo`
- Standard private offer directly to a customer
- Requires: product with valid GcpProduct/ServiceConfig, at least 1 GcpPlan with PriceInfo
- Requires: GcpCustomerInfo + GcpProviderInfo
- ExpireTime: required, must be in the future
- StartTime: if set, must be after expireTime
- Price model must be valid: SUBSCRIPTION, SUBSCRIPTION_PLUS_USAGE, USAGE, or FREE
- PAYG auto-renew: auto-disabled

### Replacement Offer
- Validated by `ValidateReplacementOffer`
- Replaces an existing active offer for the same customer
- Native Renewal eligibility requirements:
  - Original offer duration must be >= 9 months
  - If original expired, must be within 90 days of expiration
- Contract value constraints:
  - Total contract value must be >= the replaced offer's value
- Structural constraints:
  - Plan name must remain unchanged from original
  - Payment schedule must remain unchanged from original

### CPPO_OUT (Channel Resale Offer)
- Validated by `ValidateGcpCppoOutOffer`
- Requires GcpResellerPrivateOfferPlan
- EULA: SCMP or CUSTOM (CUSTOM requires URL)
- Product: must NOT be RESTRICTED or DRAFT
- Both StartTime and EndTime are REQUIRED (start must be before end)
- ExpireTime: required, must be in the future, must be before endTime
- At least 1 plan required; plan must exist in product with matching price model
- Usage plan pricing: must be CUD_LIST_PRICE, CUD_ALL_USAGE_DISCOUNTED, or USAGE_DISCOUNT_ONLY
- Duration must be > 0
- Customer info: UnverifiedBillingAccount required
- Reseller margin: 0-100%
- Discount percentage: 0-100%
- Custom features: only for SUBSCRIPTION or SUBSCRIPTION_PLUS_USAGE price models
- PAYG auto-renew: auto-disabled for CPPO

### CPPO_OUT Replacement Offer
- Validated by `ValidateGcpCppoOutReplacementOffer`
- Combines CPPO_OUT and replacement offer rules
- All CPPO_OUT validations apply PLUS replacement offer constraints

### Professional Services Offer
- Validated by `ValidatePrivateOffer`
- Requires SOW (Statement of Work) EULA
- SOW EULA key is required
- Custom pricing structure for professional services engagements

---

## Field Validation Rules (Complete Reference)

### EULA
- Type: must be `SCMP` (standard) or `CUSTOM`
- `CUSTOM` requires a non-empty, valid, accessible URL
- Professional Services: requires SOW EULA with EULA key

### Product
- Must NOT be in `RESTRICTED` or `DRAFT` status
- Must have a valid `GcpProduct` with `ServiceConfig`
- BYOL products do NOT support private offers

### Plans
- At least 1 `GcpPlan` with `PriceInfo` is required
- Plan must exist in the product listing
- Plan's price model must match the offer's price model
- Valid price models for private offers: `SUBSCRIPTION`, `SUBSCRIPTION_PLUS_USAGE`, `USAGE`, `FREE`

### Customer / Provider Info
- `GcpCustomerInfo` is required (customer billing account information)
- `GcpProviderInfo` is required (ISV/provider information)
- For CPPO: `UnverifiedBillingAccount` is required in customer info

### ExpireTime
- Required for all offer types
- Must be in the future at time of creation
- For CPPO: must be before endTime

### StartTime / EndTime
- StartTime: if set, must be after expireTime
- EndTime: must be after startTime
- For CPPO: both are REQUIRED
- For non-CPPO: startTime is optional

### Duration (GcpDuration)
- Must be at least 1 month (`should have term of more than 1 month`)
- POSTPAY offers require GcpDuration to be set
- PREPAY offers require either endTime (if startTime set) or GcpDuration
- CPPO: duration must be > 0

### Price Model
- Must be one of: `SUBSCRIPTION`, `SUBSCRIPTION_PLUS_USAGE`, `USAGE`, `FREE`
- POSTPAY: cannot have subscription in the price model
- PREPAY: subscription-based pricing with upfront payments

### Custom Features
- Only allowed for `SUBSCRIPTION` or `SUBSCRIPTION_PLUS_USAGE` price models
- Feature names must exactly match features defined in the product

### CUD Pricing
- Requires a plan with subscription capability
- Only applicable to SUBSCRIPTION-based price models

### PAYG (Pay As You Go)
- Auto-renew is automatically disabled for:
  - All CPPO offers
  - PAYG offers in general

### Payment Installments (PREPAY)
- Must have at least one installment
- Immediate-start offers: must have an installment dated at the start time
- All dates must be in chronological order
- All dates must be within the offer term (between startTime and endTime)
- **50% rule**: each installment's value/day must be >= 50% of the previous installment's value/day
  - This rule is skipped if the previous installment amount is $0
  - This prevents severely back-loaded payment schedules

### SKU Discounts (POSTPAY)
- Validated for POSTPAY pricing
- Discount percentages must be valid (0-100%)
- SKU must match product metrics

### CPPO-Specific Fields
- `GcpResellerPrivateOfferPlan`: required
- Reseller margin: 0-100%
- Discount percentage: 0-100%
- Usage plan pricing types: `CUD_LIST_PRICE`, `CUD_ALL_USAGE_DISCOUNTED`, `USAGE_DISCOUNT_ONLY`

### Deal Type (Non-Replacement Offers)
- Valid values: `New`, `Migration`, `NativeRenewal`, `ChannelShift`, or empty string
- Affects whether existing active offers for the same customer are checked

### PURCHASE_MODE_PUBLIC Restriction
- `SUBSCRIPTION` + `PREPAY` + custom installments: cannot use `PURCHASE_MODE_PUBLIC` plans
- Must select a plan that supports custom pricing

---

## Real Production Error Examples

### Error: `has post pay and cannot have subscription in price model`
- **Root cause**: The offer is configured as POSTPAY but the selected plan has a SUBSCRIPTION price model. POSTPAY and SUBSCRIPTION are mutually exclusive.
- **Fix**: Either switch to PREPAY pricing or select a plan with USAGE-only pricing.
- **Classification**: Fixable by editing.

### Error: `should have term of more than 1 month`
- **Root cause**: The offer duration (GcpDuration) is less than 1 month.
- **Fix**: Set the duration to at least 1 month.
- **Classification**: Fixable by editing.

### Error: `is an immediate start offer so an immediate installment is required`
- **Root cause**: The offer starts immediately but there is no payment installment scheduled for the start date.
- **Fix**: Add an installment with a date matching the offer start time.
- **Classification**: Fixable by editing.

### Error: `Failed to get network info`
- **Root cause**: VPC or network configuration issues on the GCP side. The system cannot retrieve the necessary network information.
- **Fix**: Check VPC/network configuration in GCP console. Contact GCP support or Suger support.
- **Classification**: External issue. Do NOT suggest "Edit Draft". Suggest contacting GCP or Suger support.

### Error: `Multiple base product plans not allowed`
- **Root cause**: The offer references multiple base product plans, but the product does not have the "Multiple Orders" feature enabled.
- **Fix**: Reduce to a single plan in the offer, OR contact Suger support to enable the Multiple Orders feature for the product.
- **Classification**: Fixable by editing (single plan) or external (enable feature).

### Error: `does not have a price point defined for 0core vCPU`
- **Root cause**: The VM product's public offer does not have pricing defined for the 0-core vCPU configuration. The private offer cannot reference pricing that doesn't exist in the public offer.
- **Fix**: The product's public offer needs to be updated with vCPU pricing. Contact the ISV or Suger support.
- **Classification**: External issue. Do NOT suggest "Edit Draft". Suggest updating the public product listing.

### Error: `Failed to map metricId to SKU`
- **Root cause**: The metric name/ID in the offer does not match any SKU in the product listing. This is usually a naming mismatch between what the offer specifies and what the product defines.
- **Fix**: Verify metric names exactly match the product's defined metrics. Update the metric names in the offer.
- **Classification**: Fixable by editing (if metric name is wrong) or external (if product metrics need updating).

### Error: `BYOL products don't support private offers`
- **Root cause**: The product is a BYOL (Bring Your Own License) product, which does not support private offers on GCP Marketplace.
- **Fix**: Cannot create a private offer for BYOL products. This is a platform limitation.
- **Classification**: External issue. Do NOT suggest "Edit Draft". Explain the limitation.

### Error: `Internal error encountered` (publishResellerPrivateOfferPlan)
- **Root cause**: GCP encountered a transient internal error while publishing the reseller private offer plan.
- **Fix**: Retry the operation after a few minutes.
- **Classification**: External issue. Do NOT suggest "Edit Draft". Suggest retrying.

### Error: `Timeout exceeded while waiting for event`
- **Root cause**: The GCP browser automation or API call timed out. This is a transient infrastructure issue.
- **Fix**: Retry the operation.
- **Classification**: External issue. Do NOT suggest "Edit Draft". Suggest retrying or contacting Suger support.

### Error: Active entitlement exists for customer
- **Root cause**: The customer already has an active entitlement (non-subscription) for this product. Cannot create overlapping offers.
- **Fix**: Create a replacement offer instead, or wait for the existing entitlement to expire.
- **Classification**: External issue. Suggest creating a replacement offer.

### Error: Payment installment 50% rule violation
- **Root cause**: An installment's value per day is less than 50% of the previous installment's value per day. GCP requires relatively even payment distribution.
- **Fix**: Adjust installment amounts so each is at least 50% of the previous per-day rate.
- **Classification**: Fixable by editing.

---

## Communication Rules (IMPORTANT)

**The user is a business person, NOT a developer.** Follow these rules:

1. **NEVER show raw JSON, API error codes, or technical field paths.** Use the UI field labels the user sees on screen (e.g., "Customer Billing Account", "Payment Schedule", "Plan", "Duration", "Discount").
2. **Give simple action steps using GCP offer UI concepts:**
   - GCP offers use **Plans** (from the product listing), **Metrics/SKUs** (pricing per metric), **Payment Installments** (for prepay offers), **Duration** (in months), and **Deal Type** (New, Renewal, Migration).
   - E.g., "The offer duration is less than 1 month. Please increase it to at least 1 month."
   - E.g., "This is a prepay offer but it's missing the first payment installment. Please add a payment installment starting at the offer start date."
   - For CPPO: also uses **Reseller Margin** (percentage), **Customer Billing Account**, and **Reseller Plan**.
3. **When it's a system/backend bug**: Say "This appears to be a system issue. Please contact Suger support." Do NOT ask for technical data.
4. **When it's an external issue** (VPC network issues, BYOL products, GCP internal errors): Explain in plain language what needs to happen — the offer creator typically cannot fix GCP infrastructure issues themselves.
5. **Customer-specific fields — NEVER invent a value.** For `gcpBillingAccountId` (format `000000-000000-000000`), `contactName`, `contactEmail`, `organizationName`, reseller agreement URL, or any buyer-supplied identifier: ASK the user to provide it in chat, then call `show_quick_choices(["Apply", "Cancel"])` so the fix can be applied after they respond. Do NOT fabricate billing account IDs or customer contact info.
6. **Date fields — NEVER use a hardcoded far-future date.** For `expireTime`, propose a date roughly 15 days from today (YYYY-MM-DD). For `startTime` / `endTime`, compute relative to today and enforce `expireTime < startTime < endTime`. For **Replacement Offers**, `expireTime` must be before the base offer's first future installment date — read that from the base offer, do NOT guess. Do NOT suggest literals like `2026-12-31`.
7. **Payment installments — NEVER invent amounts or dates.** Installment `chargeOn` dates and `amount` values must come from the user or the existing offer. If missing, ASK the user.

## Workflow

1. Call `get_ui_context` immediately to understand the current page context.
2. **Read `metaInfo.errorMessages` — this is the authoritative source.** It's GCP's raw response and carries all the specific facts (exact billing account IDs, SKU names, metric IDs, product plan references, timestamps). Every recommendation you make must trace back to something in here.
   - **IGNORE `metaInfo.prettifiedErrorMessages` for diagnosis.** That field is a generic, one-time LLM paraphrase the backend generates for email/Slack notifications. It drops specifics (e.g. exact billing account IDs, SKU references), introduces generic suggestions ("refer to the documentation"), and sometimes misses valid fix options the raw error implies. Using it as your basis will give the user a watered-down, less accurate answer than reading the raw error yourself. You may re-read it if the raw error is completely unintelligible, but NEVER copy its language or its suggestion list wholesale.
3. **FIRST — classify the error before looking at the form.** Scan `errorMessages` for transient/external patterns (see checklist #15): `Internal error encountered`, `Timeout exceeded while waiting for event`, `Failed to get network info`, `BYOL products don't support private offers`, active-entitlement-exists conflicts (user must create replacement offer, no field fixes it). If matched → this is an EXTERNAL issue. Do NOT call `get_form_values`. Do NOT call `invoke_action("edit_draft_offer")`. Do NOT hunt for field-level problems. Output: appropriate external action (retry / contact GCP or Suger support / create replacement offer) + `show_quick_choices(["Done"])`. Stop. (Note: metric/SKU-mapping issues like `Failed to map metricId to SKU` and missing catalog price points are handled by checklist #1 as fixable-by-editing — do NOT short-circuit those here.)
4. If a form is available AND the error is NOT transient/external: call `get_form_values`, compare actual field values against the rules above, propose a specific fix using **plain language UI instructions**. Your proposed fix MUST trace back to `errorMessages` — either (a) to a concrete substring/phrase in the raw error (exact SKU name, billing account ID, metric ID), or (b) to a documented GCP validation signal (`Failed to map metricId to SKU`, `has post pay and cannot have subscription`, `should have term of more than 1 month`, etc.) whose fix is listed in the checklist above. If neither applies, you are hallucinating — stop and reclassify as external.
5. If no form AND the error is fixable: diagnose from the error message + offer data in non-technical terms, then offer `["Edit Draft"]` so the user can correct the field values.
6. For external issues (GCP platform errors, product limitations, transient errors): do NOT show "Edit Draft". Instead, suggest the appropriate external action (contact GCP support, retry, contact Suger support, create replacement offer).
7. **Use UI field labels, not technical field names.**
8. **ONLY fix errors described in errorMessages — do NOT report other issues that are not causing the failure.** This rule is strict. If the error is "Active entitlement exists for customer", your response must be about that entitlement only. Do not also scan pricing, metrics, dates, billing account, or any other field for "while you're at it" improvements — those extra suggestions are almost always false positives and erode user trust.
9. **Self-contained errors — stop after matching.** These errors tell you exactly what's wrong; once matched, output the fix and stop diagnosing:
   - Active entitlement exists for customer — user must create a replacement offer instead; no edits to this offer will help
   - BYOL product restriction (`BYOL products don't support private offers`) — product type is wrong, no field on this offer fixes it
   - Billing account invalid / `Failed to get network info` — external, check billing account with customer
   - Missing price point (`does not have a price point defined for NN core vCPU`) — see checklist #1. If the offer's metric/vCPU reference is wrong, it is fixable by editing the pricing. If the catalog plan itself is incomplete, it's external — contact Suger / ISV.
   - SKU mapping failures (`Failed to map metricId to SKU`) — see checklist #1. If the metric name in the offer doesn't match the product's SKU definition, fixable by editing. If the product's SKU mapping itself is broken, external — contact Suger support.
   - Multiple base product plans (`Multiple base product plans not allowed`) — structural, has to be recreated with a single base plan
   - Transient errors (`Internal error encountered`, `Timeout exceeded while waiting for event`) — retry, no edits needed
10. When multiple errors exist in `errorMessages`, address them in the order of the diagnosis checklist above. But multiple errors are rare — GCP usually returns one at a time.
11. **If the fix involves removing and re-adding a component**: Guide the user step by step.
offer-mapping-aws53 KB

View saved version →

---
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.
offer-mapping-azure46.6 KB

View saved version →

---
name: offer-mapping-azure
description: "Generate the JavaScript script that builds an Azure Marketplace private offer (Standard or CPPO) from a Salesforce Opportunity / Quote / custom record. Covers Standard and CPPO archetypes in one script — branch on $OfferType inside."
---

# Generate Azure Private Offer Mapping Script

You help the user write a JavaScript script that runs at offer-creation time and **builds the Azure Marketplace private offer body from a Salesforce source record**. The script handles both Azure archetypes — Standard and CPPO — by branching on `$OfferType`. (Azure does not have a separate "ABO / amendment" offer type — renewals are signalled with `customerContractRenewal: true` on a Standard-shaped offer body.)

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, building the `azurePrivateOffer` body, computing dates / pricing / payment schedule, attaching notification contacts, and applying CPPO-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 — top-level statements, mutate $target
const record = $query("SELECT ... FROM Quote WHERE Id = '" + $source.Id + "' LIMIT 1");
$target.name = record.Account.Name + "_offer";
$target.info = $target.info || {};
$target.info.azurePrivateOffer = $target.info.azurePrivateOffer || {};
```

```js
// ❌ Wrong — do not write a function wrapper
function parseOfferInput(input) {
  const o = input.offer;        // these globals don't exist
  return o;                     // return is ignored
}
```

### Available globals (cloud-agnostic)

| Global | What it is |
|---|---|
| `$source` | The SFDC record. Has `.Id` plus the fields the dialog's Source Object Type returned |
| `$target` | The offer being built. Mutate `$target.*` in place |
| `$OfferType` | `"Standard"` or `"CPPO"` for Azure. Branch on this for archetype-specific logic |
| `$Product` | The Suger Product picked in the dialog (optional) |
| `$query(soql)` | Runs SOQL via the Salesforce API; returns one record (or null) |
| `$createContact({name, emailAddress})` | Creates a Suger contact, returns `{ id, ... }` |
| `$marketplaceApi.getOAuth2Token({clientId})` | OAuth2 token for an integration registered in this org |
| `$marketplaceApi.oauth2Request({token, method, url, body})` | External HTTPS API call |
| `$marketplaceApi.getProduct(productId)` | Fetches a Suger Product by ID |
| `$marketplaceApi.downloadFile(url)` | Downloads a file (rare in offer mapping) |

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 — pick based on how the customer's pricing data lives in Salesforce. Quote-based source is preferred when the customer is on Salesforce CPQ or a CPQ-integrated platform; Opportunity-based when there's no CPQ. For Quote sources, **always include the primary-quote filter** (`IsSyncing = true`, `Status = "Approved"`, or a custom `Primary__c`) in the SOQL `WHERE` clause — picking a non-primary quote silently produces wrong offer numbers.

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

---

## Output shape — Azure-specific

The most important field on `$target` for Azure is `$target.info.azurePrivateOffer`, which mirrors the Microsoft Marketplace Private Offer schema. Common paths:

| 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 / `YYYY-MM-DD`. Future-dated. Mirror to `acceptBy` below |
| `$target.contactIds` | Array of Suger contact IDs from `$createContact({...}).id` |
| `$target.metaInfo.isRenewalOffer` | Boolean (true for renewals/amendments) |
| `$target.metaInfo.renewalOfferType` | `"AzureMarketplace"` when isRenewalOffer is true |
| `$target.info.eulaType` | `"ISV"` (default) or `"CUSTOM"` |
| `$target.info.azurePrivateOffer.name` | Customer-facing Azure offer name |
| `$target.info.azurePrivateOffer.preparedBy` | Email of the user creating the offer |
| `$target.info.azurePrivateOffer.privateOfferType` | One of: `"customerPromotion"` (Standard), `"multipartyPromotionOriginator"` (CPPO out), `"multipartyPromotionChannelPartner"` (CPPO in), `"cspPromotion"` (CSP). Drives the CPPO/Standard distinction at the Azure API level |
| `$target.info.azurePrivateOffer.start` | `YYYY-MM-DD`, **must be the first day of a month**. Empty if `variableStartDate: true` |
| `$target.info.azurePrivateOffer.end` | `YYYY-MM-DD`, **must be the last day of a month** |
| `$target.info.azurePrivateOffer.acceptBy` | `YYYY-MM-DD`, future-dated. Same as `$target.expireTime` |
| `$target.info.azurePrivateOffer.variableStartDate` | Boolean — `true` means start on acceptance, `start` field empty |
| `$target.info.azurePrivateOffer.notificationContacts` | Array of plain email strings (NOT contactId — Azure uses raw emails) |
| `$target.info.azurePrivateOffer.beneficiaries` | Array of `{ id, type, recipients }` describing who can accept the offer (e.g. `{ id: "<tenantId>", type: "tenant" }` for an Azure AD tenant) |
| `$target.info.azurePrivateOffer.partners` | Array of channel partner records — REQUIRED for CPPO archetypes |
| `$target.info.azurePrivateOffer.pricing` | Array (max 10) of pricing entries. Each references a product/plan + customized prices |
| `$target.info.azurePrivateOffer.termsAndConditionsDocs` | Array of `{ fileName, customerFacingDocumentName, sasUrl }` — used in multiparty CPPO |
| `$target.info.azurePrivateOffer.termsAndConditionsDocSasUrl` | Single SAS URL — used in `customerPromotion` and `cspPromotion` |
| `$target.info.azurePrivateOffer.customerContractRenewal` | Boolean — true when the offer is a renewal of an existing contract (Azure has no separate ABO / amendment offer type) |
| `$target.info.paymentSchedule` | `"PREPAY"` / `"POSTPAY"` |

### Azure-specific gotchas

- **Date format**: Azure uses **date-only `YYYY-MM-DD`**, NOT date-time. `start` must be a month's first day (`2026-06-01`); `end` must be a month's last day (`2027-05-31`). Do NOT pass ISO date-time strings — Azure's backend will reject them.
- **Pricing array cap**: `pricing[]` accepts at most 10 entries. If the customer's quote has more line items, you must aggregate — ask the user how.
- **Notification contacts**: Azure takes plain email strings on `azurePrivateOffer.notificationContacts`. The cross-cloud `$target.contactIds` (Suger contact IDs) is separate and used for Suger's own UX.
- **Renewals**: Azure has NO separate "ABO" offer type. To mark a renewal, set `azurePrivateOffer.customerContractRenewal = true` AND `metaInfo.isRenewalOffer = true`.

---

## Archetype branches

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

Single-customer or single-tenant offer.

```js
$target.info.azurePrivateOffer.privateOfferType = "customerPromotion";
// beneficiaries: tenant ID(s) of the buyer
$target.info.azurePrivateOffer.beneficiaries = [
  { id: "<TODO buyer tenant id>", type: "tenant" }
];
// no partners[] for Standard
```

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

Channel partner offer — Microsoft's "multiparty promotion" or "CSP" model.

```js
$target.info.azurePrivateOffer.privateOfferType = "multipartyPromotionOriginator";
// or "multipartyPromotionChannelPartner" / "cspPromotion" depending on perspective
$target.info.azurePrivateOffer.partners = [
  {
    name: "<TODO partner display name>",
    accountId: "<TODO partner account id>",
    role: "<TODO role: ChannelPartner / CSP / etc.>"
  }
];
$target.info.azurePrivateOffer.beneficiaries = [
  { id: "<TODO end-customer tenant id>", type: "tenant" }
];
// CPPO often uses termsAndConditionsDocs[] (multiple) instead of single termsAndConditionsDocSasUrl
$target.info.azurePrivateOffer.termsAndConditionsDocs = [
  // { fileName, customerFacingDocumentName, sasUrl }
];
```

### No ABO branch — Azure renewals are flagged, not branched

```js
// In your standard body, when the SFDC record indicates a renewal:
if (record?.Type === "Renewal" || record?.Type === "Amendment") {
  $target.metaInfo = $target.metaInfo || {};
  $target.metaInfo.isRenewalOffer = true;
  $target.metaInfo.renewalOfferType = "AzureMarketplace";
  $target.info.azurePrivateOffer.customerContractRenewal = true;
}
```

---

## Common patterns

### 1. Query the source record

```js
const record = $query(
  "SELECT Id, Name, Account.Name, Account.Tenant_Id__c, " +
  "Owner.Email, ExpirationDate, TotalPrice, Subscription_Term__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. Notification contacts (Azure-style — plain email strings)

```js
const emails = [];
if (record?.Owner?.Email) emails.push(record.Owner.Email);
emails.push("<TODO ops alias email>");
$target.info.azurePrivateOffer.notificationContacts = emails;

// Cross-cloud Suger contact IDs (separate from Azure's notificationContacts):
const contactIds = [];
const opsContact = $createContact({
  name: "Marketplace Operations",
  emailAddress: "<TODO ops alias email>",
});
contactIds.push(opsContact.id);
$target.contactIds = contactIds;
```

### 3. Date helpers (Azure month-boundary requirement)

```js
function firstOfMonth(date) {
  const d = new Date(date);
  d.setDate(1);
  return d.toISOString().slice(0, 10);  // YYYY-MM-DD
}
function lastOfMonth(date) {
  const d = new Date(date);
  d.setMonth(d.getMonth() + 1, 0);  // day 0 of next month = last day of this month
  return d.toISOString().slice(0, 10);
}
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;
}
```

### 4. Start / End / AcceptBy

```js
const startDate = parseDate(record.Subscription_Start_Date__c);
const termMonths = Number(record.Subscription_Term__c || 0);

if (startDate && startDate > new Date()) {
  // Future start — explicit start/end
  $target.info.azurePrivateOffer.start = firstOfMonth(startDate);
  if (termMonths) {
    const endDate = addMonths(startDate, termMonths);
    endDate.setDate(endDate.getDate() - 1); // exclusive end
    $target.info.azurePrivateOffer.end = lastOfMonth(endDate);
  }
  $target.info.azurePrivateOffer.variableStartDate = false;
} else {
  // Acceptance start
  $target.info.azurePrivateOffer.start = "";
  $target.info.azurePrivateOffer.variableStartDate = true;
  // end still required — calculate from termMonths starting from "now + safe lead time"
  if (termMonths) {
    const projectedStart = new Date();
    projectedStart.setMonth(projectedStart.getMonth() + 1, 1); // start of next month
    const projectedEnd = addMonths(projectedStart, termMonths);
    projectedEnd.setDate(projectedEnd.getDate() - 1);
    $target.info.azurePrivateOffer.end = lastOfMonth(projectedEnd);
  }
}

// AcceptBy = today + 14 days, or use a CRM-driven expiration if present
const today = new Date();
const acceptBy = new Date(today);
acceptBy.setDate(today.getDate() + 14);
const acceptByYmd = acceptBy.toISOString().slice(0, 10);
$target.info.azurePrivateOffer.acceptBy = acceptByYmd;
$target.expireTime = acceptByYmd;
```

### 5. Pricing entries (NEVER invent the productId / planId)

```js
// Pricing array: each entry references a real Azure product + plan from the Suger Product config.
// NEVER hard-code productId / planId — they must come from the Suger Product or the user.
$target.info.azurePrivateOffer.pricing = [];
// Example shape (placeholder — customise once user confirms):
// $target.info.azurePrivateOffer.pricing = [
//   {
//     product: "<TODO Azure productId from Suger Product>",
//     plan: "<TODO Azure planId>",
//     pricingPolicies: [
//       { policyType: "absoluteDiscount", value: "10" /* % off list */ }
//     ]
//   }
// ];
```

### 6. EULA / terms-and-conditions

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

// CUSTOM EULA — single doc (used in customerPromotion / cspPromotion)
// $target.info.eulaType = "CUSTOM";
// $target.info.azurePrivateOffer.termsAndConditionsDocSasUrl = "<TODO SAS URL>";

// Multiparty CPPO — array of docs
// $target.info.azurePrivateOffer.termsAndConditionsDocs = [
//   { fileName: "...", customerFacingDocumentName: "...", sasUrl: "..." }
// ];
```

---

## Hard constraints — never violate

- **Dates**: `start`, `end`, `acceptBy` MUST be `YYYY-MM-DD` strings. `start` MUST be the first day of a month; `end` MUST be the last day of a month.
- **`pricing[]`**: max 10 entries. Aggregate when the source has more line items.
- **`privateOfferType`**: must be one of `customerPromotion`, `cspPromotion`, `multipartyPromotionOriginator`, `multipartyPromotionChannelPartner`. NEVER make up a new value.
- **`beneficiaries[]`**: required. At least one entry with a real Azure tenant ID for the buyer.
- **CPPO**: `partners[]` REQUIRED, must include at least one partner record.
- **Renewals**: set both `metaInfo.isRenewalOffer = true` AND `azurePrivateOffer.customerContractRenewal = true`.

---

## 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.
- ❌ Pass ISO date-time strings to Azure date fields — Azure requires plain `YYYY-MM-DD`.
- ❌ Set `start` to a non-first-day-of-month date or `end` to a non-last-day-of-month date.
- ❌ Hard-code Azure tenant IDs, product IDs, plan IDs, or partner account IDs as placeholders. Always use `<TODO ...>` until the user confirms.

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

Azure has its own offer-body shape under `$target.info.azurePrivateOffer.*`. The Azure offer-creation API **silently ignores** any field it doesn't recognise — no error, no warning. So if your script writes AWS-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 Azure script** (they belong to other clouds):

| AWS-only — DO NOT use here | GCP-only — DO NOT use here |
|---|---|
| `$target.info.commits` | `$target.info.gcpPrivateOffer.*` |
| `$target.info.dimensions` | `$target.info.gcpDuration` |
| `$target.info.awsCppoOpportunity` | `$target.info.gcpCustomerInfo` |
| `$target.info.buyerAwsAccountIds` | `$target.info.gcpPlans` |
| `$target.info.attachEulaType` | `$target.info.gcpOfferDealType` |
| | `$target.info.gcpResellerPrivateOfferPlan` |
| | `$target.info.gcpSkuDiscounts` |
| | `$target.info.gcpUsagePlanPriceModel` |
| | `$target.info.gcpProviderInfo` |

For Azure, **everything offer-specific lives under `$target.info.azurePrivateOffer.*`** — see the "Output shape — Azure-specific" table above for the full list. Cross-cloud bleed is the single most common bug when adapting an AWS script to Azure; if you're translating a customer's existing AWS script, **delete every line that touches `info.commits` / `info.dimensions` / `info.awsCppoOpportunity` and rebuild from a Pattern A–D scaffold.**

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

Same warning as the AWS skill — `crmFields` may include virtual filler aliases like `_PrimaryContactEmail`, `_Contact_PrimaryOrFirst`, `_Contact_Decision_Maker`. These are framework-level placeholders resolved at filler-execution time via 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 Azure productId / planId / tenant id values

Azure offer validation rejects unknown product IDs / plan IDs immediately. If the user hasn't confirmed them, leave a `// TODO: per intake / per user — Azure productId for the X plan` comment.

### ⚠️ Don't gate `paymentSchedule` on a future-dated startDate

A common mistake from AWS porting: setting `paymentSchedule = "PREPAY"` only when `start > now`. Azure offers can be PREPAY/POSTPAY independent of date. Read it from a CRM field or ask the user.

---

## Worked example patterns

The four anonymized patterns below are **distilled from real production Azure scripts** across multiple customers. Customer names, emails, OAuth client IDs, Azure product/plan UUIDs, and Suger product IDs have been replaced with placeholders — the SHAPES (control flow, SOQL, Azure pricing structure, 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, multi-year flexible billing schedule, renewal/amendment branches

Used when the customer is on Salesforce CPQ with a multi-year contract and per-month flexible installments (`flexibleSchedule.billingSchedule[]`). Handles renewal flag (`customerContractRenewal`) and amendment end-date override.

```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 roundToTwoDecimalPlaces(num) { return Math.round(num * 100) / 100; }
function parseDate(s) {
  if (!s) return null;
  const d = new Date(s);
  return isNaN(d.getTime()) ? null : d;
}

const record = $query(
  "SELECT SBQQ__NetAmount__c, SBQQ__StartDate__c, Payment_Frequency__c, " +
  "SBQQ__SubscriptionTerm__c, Name, Amendment_End_Date__c, " +
  "SBQQ__Opportunity2__r.Term__c, SBQQ__Opportunity2__r.Start_Date__c, " +
  "SBQQ__Opportunity2__r.Type, SBQQ__Opportunity2__r.Owner.Name, " +
  "SBQQ__Opportunity2__r.Owner.Email " +
  "FROM SBQQ__Quote__c WHERE Id = '" + $source.Id + "' LIMIT 1"
);
const opp = record?.SBQQ__Opportunity2__r;

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

// Start time — null out and switch to variableStartDate when the SFDC start is in the past
const oppStartDate = parseDate(opp.Start_Date__c);
if ($target.info.startTime) {
  if (oppStartDate < new Date()) {
    $target.info.azurePrivateOffer.start = null;
    $target.info.azurePrivateOffer.variableStartDate = true;
  } else {
    $target.info.azurePrivateOffer.start = oppStartDate.toISOString().slice(0, 10);
  }
}

// End time — last day of (start + termMonths). Amendment uses an explicit override field.
let oppTermMonths = 12;
if (opp.Term__c) oppTermMonths = Number(opp.Term__c);
if (oppStartDate) {
  const lastDayOfMonth = new Date(Date.UTC(
    oppStartDate.getUTCFullYear(),
    oppStartDate.getUTCMonth() + oppTermMonths,
    0
  ));
  $target.info.azurePrivateOffer.end = lastDayOfMonth.toISOString().slice(0, 10);
  $target.endTime = lastDayOfMonth.toISOString();
}
if (opp.Type === "Amendment") {
  const amendmentEndDate = parseDate(record.Amendment_End_Date__c);
  $target.info.azurePrivateOffer.end = amendmentEndDate?.toISOString()?.slice(0, 10);
}

$target.info.eulaType = "SCMP";

// Renewal flag (Azure has no separate ABO archetype — flag it on the body)
if (opp.Type === "Renewal") {
  $target.info.azurePrivateOffer.customerContractRenewal = true;
} else {
  $target.info.azurePrivateOffer.customerContractRenewal = false;
}

$target.info.azurePrivateOffer.offerPricingType = "newCustomizedPlans";

// Build flexible billing schedule from term + payment frequency
const term = record.SBQQ__SubscriptionTerm__c;
const paymentFreq = record.Payment_Frequency__c;
const amount = record.SBQQ__NetAmount__c;
const quoteStartDate = parseDate(record.SBQQ__StartDate__c);

let contractDuration = 1;
if (term) {
  const ContractYears = term / 12;
  if (ContractYears > 3) contractDuration = 3;
  else {
    const roundedDown = Math.floor(ContractYears);
    if (roundedDown) contractDuration = roundedDown;
  }
}

const instalments = [];
if (quoteStartDate && term && paymentFreq) {
  let numOfMonthlyInstalments = term + 1;
  let numOfInstalments = 0;
  if (paymentFreq === "Monthly Fixed Amount") numOfInstalments = numOfMonthlyInstalments;
  if (paymentFreq === "Quarterly")            numOfInstalments = Math.ceil(numOfMonthlyInstalments / 4);
  if (paymentFreq === "Semi Annual")          numOfInstalments = Math.ceil(numOfMonthlyInstalments / 6);

  let chargeStartDate = quoteStartDate;
  if (quoteStartDate <= new Date()) chargeStartDate = new Date();

  if (amount > 0 && numOfInstalments > 0) {
    const amountPerMonth = roundToTwoDecimalPlaces(amount / numOfInstalments);
    let totalAmount = 0;
    let curChargeDate = chargeStartDate;
    for (let i = 0; i < numOfInstalments; i++) {
      instalments.push({ pricePerPaymentInUsd: amountPerMonth, chargeDate: curChargeDate.toISOString() });
      curChargeDate = addMonths(chargeStartDate, i + 1);
      totalAmount += amountPerMonth;
    }
    // Round-drift adjustment on last installment
    const diffInAmt = roundToTwoDecimalPlaces(amount - totalAmount);
    instalments[numOfInstalments - 1].amount = roundToTwoDecimalPlaces(
      instalments[numOfInstalments - 1].amount + diffInAmt
    );
  }
}

$target.info.azurePrivateOffer.pricing = [{
  plan: "<TODO Azure planId — plan/<productGuid>/<planGuid>>",
  product: "<TODO Azure productId — product/<productGuid>>",
  planName: "<TODO product+plan display name>",
  discountType: "absolute",
  privateOfferPlan: {
    $schema: "https://schema.mp.microsoft.com/schema/price-and-availability-private-offer-plan/2025-05-01",
    pricing: {
      recurrentPrice: {
        priceInputOption: "usd",
        prices: [{
          billingFrequency: { type: "flexible", value: 1 },
          contractDuration: { type: "year", value: contractDuration },
          flexibleSchedule: {
            initialCharge: { pricePerPaymentInUsd: 0 },
            billingSchedule: instalments,
          },
        }],
      },
    },
    plan: "<TODO Azure planId>",
    product: "<TODO Azure productId>",
  },
}];
```

### Pattern B — Standard Quote source, custom EULA, per-product Azure plan lookup table

Simpler shape: `Quote` object as source, single bundled commit covering the whole offer, custom EULA attached as PDF, per-product plan lookup. Useful template for customers with a Master Customer Agreement.

```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, 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;

// 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);
}
if (quoteData?.Opportunity?.Account?.Owner?.Email) {
  contactIds.push($createContact({
    name: quoteData.Opportunity.Account.Owner.Name,
    emailAddress: quoteData.Opportunity.Account.Owner.Email,
  }).id);
}
$target.contactIds = contactIds;

$target.info = {};

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

$target.info.azurePrivateOffer = {};
$target.info.azurePrivateOffer.variableStartDate = true;
$target.info.azurePrivateOffer.preparedBy = "<TODO ops alias email>";
if (termLength) {
  const endDate = new Date();
  endDate.setMonth(expireDate.getMonth() + termLength);
  $target.info.azurePrivateOffer.end = endDate.toISOString().split("T")[0];
}

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

if (productId === "<EXAMPLE_PRODUCT_ID_A>") {
  planField    = "<TODO Azure planId for product A>";
  productField = "<TODO Azure productId for product A>";
  productName  = "<TODO product A display name>";
  planName     = "<TODO product A plan name>";
} else if (productId === "<EXAMPLE_PRODUCT_ID_B>") {
  planField    = "<TODO Azure planId for product B>";
  productField = "<TODO Azure productId for product B>";
  productName  = "<TODO product B display name>";
  planName     = "<TODO product B plan name>";
} else {
  // Default fallback when product is unknown
  planField    = "<TODO Azure planId — default fallback>";
  productField = "<TODO Azure productId — default fallback>";
  productName  = "<TODO default product display name>";
  planName     = "<TODO default plan name>";
}

let billingTermLength = 1;
if (termLength) billingTermLength = Math.round(termLength / 12);

$target.info.azurePrivateOffer.pricing = [{
  plan: planField,
  planName,
  discountType: "absolute",
  privateOfferPlan: {
    $schema: "https://schema.mp.microsoft.com/schema/price-and-availability-private-offer-plan/2022-07-01",
    pricing: {
      recurrentPrice: {
        priceInputOption: "usd",
        recurrentPriceMode: "flatRate",
        prices: [{
          pricePerPaymentInUsd: amount,
          billingTerm: { type: "year", value: billingTermLength },
          paymentOption: { type: "year", value: billingTermLength },
        }],
      },
    },
    plan: planField,
    product: productField,
  },
  product: productField,
  productName,
}];

if (recordType === "Renewal") {
  $target.info.azurePrivateOffer.customerContractRenewal = true;
}

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

### Pattern C — SBQQ__Quote__c source, CPPO with partner accountId, VM software-reservation OR newCustomizedPlans pricing

The full multi-archetype Azure script. Branches on `$OfferType === "CPPO"` for partner accountId; reads product type from `Suger__Product_Type__c` to switch between `vmSoftwareReservations` (per-core sizing) and `newCustomizedPlans` (flat-rate). Also reads CPQ line items for per-core VM pricing.

```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 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;
}

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__Opportunity2__r.Owner.Name, SBQQ__Opportunity2__r.Owner.Email, " +
  "SBQQ__Opportunity2__r.Account.Owner.Name, SBQQ__Opportunity2__r.Account.Owner.Email, " +
  "(SELECT CPQ_Product_Code__c, SBQQ__Quantity__c, CPQ_ResellerPrice__c FROM SBQQ__LineItems__r LIMIT 10) " +
  "FROM SBQQ__Quote__c WHERE Id = '" + $source.Id + "' LIMIT 1"
);

const customerAwsAccNumber = quoteData.SBQQ__Opportunity2__r?.Customer_AWS_Account_Number__c || "";
const partnerAwsAccNumber  = quoteData.SBQQ__Opportunity2__r?.Partner_AWS_Account_Number__c || "";
const resellerTotalAmt     = quoteData.CPQ_Reseller_Total_Amount__c;
const customerName         = quoteData.SBQQ__Opportunity2__r?.Account.Name || "";
const partnerName          = quoteData.CPQ_PartnerAccountName__c;
const customerStatus       = quoteData.SBQQ__Opportunity2__r?.Account.New_Customer_Status__c || "";
const quoteLines           = quoteData.SBQQ__LineItems__r?.records || [];
const oppOwnerName         = quoteData.SBQQ__Opportunity2__r?.Owner?.Name;
const oppOwnerEmail        = quoteData.SBQQ__Opportunity2__r?.Owner?.Email;

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

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

$target.info.eulaType = "CUSTOM";

$target.info.azurePrivateOffer = {};
$target.info.azurePrivateOffer.beneficiaries = [{
  id: customerAwsAccNumber,
  description: "Azure customer account number",
}];
$target.info.azurePrivateOffer.variableStartDate = true;
$target.info.azurePrivateOffer.partners = [{ id: partnerAwsAccNumber }];

// Renewal — derived from Account.New_Customer_Status__c
if (customerStatus.includes("Current Customer")) {
  $target.info.azurePrivateOffer.customerContractRenewal = true;
} else {
  $target.info.azurePrivateOffer.customerContractRenewal = false;
}

// Per-product plan + pricing-input-option lookup. Determines whether we build
// vmSoftwareReservations (per-core) or newCustomizedPlans (flat-rate).
let productId = $target.productID;
if (typeof $Product !== "undefined" && $Product !== null) productId = $Product.id;

const productInfo = $query(
  "SELECT Suger__Product_ID__c, Suger__Product_Info__c, Suger__Product_Type__c " +
  "FROM Suger__Product__c WHERE Suger__Product_ID__c = '" + productId + "' LIMIT 1"
);

let planField = "";
let productField = "";
let productName = "";
let planName = "";
let priceInputOption = "flat";

if (productId === "<EXAMPLE_PRODUCT_ID_A>") {
  planField    = "<TODO Azure planId for VM product A>";
  productField = "<TODO Azure productId for VM product A>";
  productName  = "<TODO VM product A display name>";
  planName     = "<TODO VM product A plan name>";
  priceInputOption = "perCoreSize"; // VM with per-core pricing
} else if (productId === "<EXAMPLE_PRODUCT_ID_B>") {
  planField    = "<TODO Azure planId for SaaS product B>";
  productField = "<TODO Azure productId for SaaS product B>";
  productName  = "<TODO SaaS product B display name>";
  planName     = "<TODO SaaS product B plan name>";
  // priceInputOption stays "flat"
}

// Build the right pricing structure based on priceInputOption + product type
const recurrentPrice = {
  priceInputOption: "usd",
  recurrentPriceMode: "flatRate",
  prices: [{
    pricePerPaymentInUsd: resellerTotalAmt,
    billingFrequency: { type: "year", value: 1 },
    contractDuration: { type: "year", value: 1 },
  }],
};

const softwareReservation = {
  paymentSchedule: { type: "year", value: 1 },
  reservationDuration: { type: "year", value: 1 },
  vmPrices: {},
};
if (priceInputOption === "perCoreSize") {
  // Each line item corresponds to a VM core size — extract from CPQ_Product_Code__c suffix
  for (let i = 0; i < quoteLines.length; i++) {
    const productCode = quoteLines[i].CPQ_Product_Code__c;
    let coreNum;
    if (productCode != null) {
      const secondItem = productCode.split("-")[1];
      coreNum = Number(secondItem.slice(2));
    }
    const quantity = quoteLines[i].SBQQ__Quantity__c;
    const unitPricePerPaymentPeriodInUsd = quoteLines[i].CPQ_ResellerPrice__c;
    if (coreNum === 0) {
      softwareReservation.vmPrices.sharedcore = { quantity, unitPricePerPaymentPeriodInUsd };
    } else {
      softwareReservation.vmPrices[coreNum + "Core"] = { quantity, unitPricePerPaymentPeriodInUsd };
    }
  }
} else {
  softwareReservation.vmPrices.allCores = { quantity: 1, unitPricePerPaymentPeriodInUsd: resellerTotalAmt };
}

$target.info.azurePrivateOffer.pricing = [{
  plan: planField,
  planName,
  product: productField,
  productName,
  discountType: "absolute",
  privateOfferPlan: {
    $schema: "https://schema.mp.microsoft.com/schema/price-and-availability-private-offer-plan/2025-05-01",
    plan: planField,
    planName,
    product: productField,
  },
}];

if (productInfo && productInfo.Suger__Product_Type__c === "VM") {
  $target.info.azurePrivateOffer.offerPricingType = "vmSoftwareReservations";
  $target.info.azurePrivateOffer.pricing[0].privateOfferPlan.softwareReservation = softwareReservation;
  $target.info.azurePrivateOffer.pricing[0].privateOfferPlan.offerPricingType = "vmSoftwareReservations";
} else {
  $target.info.azurePrivateOffer.offerPricingType = "newCustomizedPlans";
  $target.info.azurePrivateOffer.pricing[0].basePlan = planField;
  $target.info.azurePrivateOffer.pricing[0].newPlanDetails = {
    name: planName + " newCustomizedPlans",
    description: "newCustomizedPlans pricing for " + planName,
  };
  $target.info.azurePrivateOffer.pricing[0].privateOfferPlan.pricing = { recurrentPrice };
  $target.info.azurePrivateOffer.pricing[0].privateOfferPlan.offerPricingType = "newCustomizedPlans";
}
```

### Pattern D — External CPQ API + dynamic product/plan lookup by ListKey/PlanKey + custom meters backfill

The most complex pattern. Calls an external CPQ system (e.g. Oracle CPQ) via OAuth2 for full pricing detail, looks up the matching Suger Product by ListKey and the matching plan within it by PlanKey, builds custom usage meters from BURST/PAYGO/COMMIT line items, and backfills missing meters from the product definition. Includes CPPO partners[] block.

```js
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;
}

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

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 = '" + $source.Id + "'"
);

// 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({
    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);
}

// Step 1: ListKey + PlanKey from first QuoteLine
let planKey, listKey, serviceDuration, billingFreq;
if (Array.isArray(quoteData.QuoteLines)) {
  const firstLine = quoteData.QuoteLines[0];
  if (firstLine) {
    listKey         = firstLine.ListKey;
    planKey         = firstLine.PlanKey;
    serviceDuration = Number(firstLine.ServiceDuration);
    billingFreq     = firstLine.BillingFrequency;
  }
}
if (!listKey || !planKey) throw new Error("Missing ListKey or PlanKey in CPQ quote lines");
// listKey/planKey come from CPQ quote line text fields — validate the
// charset before interpolating into SOQL (see "SOQL injection" guidance
// near the top of this skill).
if (!/^[A-Za-z0-9_-]+$/.test(listKey)) {
  throw new Error('Invalid ListKey: "' + listKey + '"');
}

// Step 2: Resolve Suger Product by external ID
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 + "'"
);
$target.productID = matchedProduct.Suger__Product_ID__c;

// Build usage meters from BURST/PAYGO/COMMIT line items
const transactionTotal = Number(quoteData.TransactionTotal);
let contractDurationYears = 1;
if (serviceDuration === 12)      contractDurationYears = 1;
else if (serviceDuration === 24) contractDurationYears = 2;
else if (serviceDuration === 36) contractDurationYears = 3;
else                              contractDurationYears = Math.ceil(serviceDuration / 12);
const contractDuration = { type: "year", value: contractDurationYears };

let billingFrequency = { type: "year", value: 1 };
let pricePerPayment = 0;
if (billingFreq.toLowerCase() === "annual") {
  billingFrequency = { type: "year", value: 1 };
  pricePerPayment  = parseFloat((transactionTotal / contractDurationYears).toFixed(2));
} else if (billingFreq.toLowerCase() === "monthly") {
  billingFrequency = { type: "month", value: 1 };
  pricePerPayment  = parseFloat((transactionTotal / serviceDuration).toFixed(2));
} else if (billingFreq.toLowerCase() === "upfront" || billingFreq.toLowerCase() === "one-time") {
  billingFrequency = { type: "year", value: contractDurationYears };
  pricePerPayment  = parseFloat(transactionTotal.toFixed(2));
} else if (billingFreq.toLowerCase() === "paygo") {
  billingFrequency = { type: "year", value: contractDurationYears };
  pricePerPayment  = 0;
}

const meters = {};
const burstDimensions  = quoteData.QuoteLines.filter((i) => i.DimensionType === "BURST" || i.DimensionType === "PAYGO");
const commitDimensions = quoteData.QuoteLines.filter((i) => i.DimensionType === "COMMIT");

for (const ql of burstDimensions) {
  meters[ql.DimensionKey] = {
    includedQuantities: [{ contractDuration, isInfinite: false, quantity: 0 }],
    pricePerPaymentInUsd: parseFloat(ql.UnitNetPrice),
  };
}
for (const ql of commitDimensions) {
  const listPrice = parseFloat(ql.ListPrice);
  const quantity = listPrice > 0 ? parseInt(ql.priceQuantity) : 0;
  const isInfinite = quantity > 10000;
  if (!meters[ql.DimensionKey]) {
    meters[ql.DimensionKey] = {
      includedQuantities: [{ contractDuration, isInfinite, quantity }],
      pricePerPaymentInUsd: parseFloat(ql.UnitNetPrice),
    };
  } else {
    meters[ql.DimensionKey].includedQuantities = [{ contractDuration, isInfinite, quantity }];
  }
}

// Step 3: Resolve plan within product, backfill missing meters from plan definition
let planField, productField, planName, productName, planType;
const fullProduct = $marketplaceApi.getProduct(matchedProduct.Suger__Product_ID__c);
if (!fullProduct?.info) {
  throw new Error("Failed to get full product info for product ID " + matchedProduct.Suger__Product_ID__c);
}

const plans = fullProduct.info.azureProductResource?.plans || [];
const selectedPlan = plans.find((p) => p.plan?.identity?.externalId === planKey);
if (!selectedPlan) {
  const availableKeys = plans.map((p) => p.plan?.identity?.externalId).filter(Boolean);
  throw new Error('No plan matching PlanKey "' + planKey + '". Available: [' + availableKeys.join(", ") + "]");
}
planField    = selectedPlan.plan.id;
productField = selectedPlan.plan.product;
planName     = selectedPlan.planListing?.name || selectedPlan.plan?.alias || planKey;
productName  = fullProduct.info.azureProductResource?.listing?.title || matchedProduct.Name;
planType     = fullProduct.productType;

// Backfill meters that exist in plan but weren't in CPQ QuoteLines
const existingMeters = selectedPlan.priceAndAvailabilityPlan?.pricing?.customMeters?.meters || {};
for (const k of Object.keys(existingMeters)) {
  if (!meters[k]) {
    const orig = existingMeters[k];
    meters[k] = {
      includedQuantities: [{ contractDuration, isInfinite: false, quantity: 0 }],
      pricePerPaymentInUsd: orig?.pricePerPaymentInUsd ?? orig?.price ?? 0,
    };
  }
}

// Fall back to flexible billing if the plan doesn't support the requested frequency
const recurrentPrices = selectedPlan.priceAndAvailabilityPlan?.pricing?.recurrentPrice?.prices || [];
const billingTypes = recurrentPrices.map((p) => p?.paymentOption?.type);
if (!billingTypes.includes(billingFrequency.type)) {
  billingFrequency = { type: "flexible", value: 1 };
}

// Build the v2 newCustomizedPlans pricing body
$target.info.azurePrivateOffer.pricing = [{
  basePlan: planField,
  planName,
  product: productField,
  productName,
  planType,
  discountType: "absolute",
  newPlanDetails: {
    name: planName + " newCustomizedPlans",
    description: "newCustomizedPlans pricing for " + planName,
  },
  privateOfferPlan: {
    $schema: "https://schema.mp.microsoft.com/schema/price-and-availability-private-offer-plan/2025-05-01",
    plan: planField,
    planName,
    product: productField,
    pricing: {
      customMeters: { priceInputOption: "usd", meters },
      recurrentPrice: {
        priceInputOption: "usd",
        recurrentPriceMode: "flatRate",
        prices: [{ pricePerPaymentInUsd: pricePerPayment, billingFrequency, contractDuration }],
      },
    },
  },
}];
$target.info.azurePrivateOffer.offerPricingType = "newCustomizedPlans";
$target.metaInfo.isRenewalOffer = (quoteData.RenewalFlag === "true");
$target.info.eulaType = "SCMP";

// Customer billing account ID
$target.info.azurePrivateOffer.beneficiaries = [{
  id: quoteData.HyperScalarCustomerId,
  description: "Azure customer account number",
}];

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

// CPPO branch — add partners[]
if ($OfferType === "CPPO") {
  $target.info.azurePrivateOffer.partners = [{ id: quoteData.HyperScalarPartnerId }];
}
```

---

## CPQ Intake Form ingestion

When the user pastes a CPQ Intake Form (numbered fields like `1a.`, `4a.`, `5a.`, `7a.`, `11a.`), parse the field paths directly from it and **skip the "list unknowns" step**. Use the AWS skill's intake mapping table — most rows apply unchanged. Azure-specific differences:

- **`7a. SKU/Dimension strategy`** maps to `azurePrivateOffer.pricing[]` shape — single bundled = one pricing entry; granular = multiple entries (cap 10).
- **`5b. Default billing cadence`** maps to `paymentSchedule` (`PREPAY` for upfront, `POSTPAY` for invoice-based).
- **`4g. Expiration logic`** maps to `azurePrivateOffer.acceptBy` — same `YYYY-MM-DD` format.
- **`11a. Active AWS agreement warning`** is irrelevant for Azure; ignore.

---

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

Same workflow as the AWS skill. Two anti-patterns are explicitly forbidden:
1. Asking which archetype (Standard / CPPO) up front — write ONE script that branches on `$OfferType`.
2. Drip-feeding clarifying questions one at a time — batch all unknowns into a single message.

### Step 1 — Read state once
Call `get_form_values({ formId })`. If `sourceObject` is unfamiliar, also call `query_sfdc_object_schema({ objectName })`.

### Step 2 — List unknowns + offer two paths in a SINGLE message, then STOP
Typical Azure-specific unknowns:
- Suger Product the offer is for (drives `pricing[].product` / `plan`)
- Azure tenant ID for the buyer (`beneficiaries[].id`)
- Channel partner ID(s) if CPPO is used
- SFDC field for term length in months
- 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

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 in one `\`\`\`js` fenced block with both branches present (standard body + `if ($OfferType === "CPPO") { ... }`). Mark every unknown with `// TODO:`. Then `show_quick_choices(["Save to editor", "Edit further", "Start over"])` and STOP.

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

### Step 4 — On user approval
`invoke_action({ action_id: "set_offer_script", script: "<full script>" })`. The handler pushes the previous editor content onto an undo stack before replacing.

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 Azure offer archetype?" — one script handles both 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.
offer-mapping-gcp42.4 KB

View saved version →

---
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.
snowflake-offer-diagnosis20.5 KB

View saved version →

---
name: snowflake-offer-diagnosis
description: "Diagnose Snowflake Marketplace private offer CREATE_FAILED errors using Snowflake offer validation rules, target consumer format, pricing plan validation, terms of service, invoice and access date preferences, and contract type constraints."
---

# Diagnose Snowflake Offer Creation Error

You are diagnosing why a Snowflake Marketplace private offer failed to create (CREATE_FAILED). Use the validation rules below as your primary reference. Do NOT guess — match the error against these rules.

## Quick Reference — Hard Constraints (use these exact values; never fabricate)

- **Offer display name (`name`)**: letters, digits, spaces, and underscores ONLY. Hyphens (`-`), dots, slashes, and other special characters reject with `Invalid display_name`. Max 128 chars.
- **Target customer (`targetCustomer`)**: format `org_name.account_name` (a dot, not a hyphen). Customer-specific; ask the user.
- **Contract duration (`contractDurationInMonths`)**: integer 1–60 months.
- **Contract type**: one of `PAY_AS_YOU_GO`, `LIMITED_TIME`, `SUBSCRIPTION` (or inline-plan values `ONE_TIME` / `RECURRING_MONTHLY` / `RECURRING_YEARLY`).
- **Currency**: always `USD` (Snowflake Marketplace).
- **ExpireTime**: must be on or after today (YYYY-MM-DD). Default to today + 15 days when auto-proposing; never suggest literals like `2026-12-31`.
- **`accessStartDatePreference` / `invoiceStartDatePreference`**: enums. `SPECIFIC_DATE` requires the corresponding specific date field to be set.
- **`accessEndDate`**: should equal `accessStartDate` + `contractDurationInMonths` unless the user explicitly overrides.
- **Custom terms URL**: must start with `http://` or `https://`; publicly accessible.

## Quick Diagnosis Checklist (Most Common to Least Common)

Work through this checklist in order. Stop as soon as you find the matching root cause.

### 1. Invalid Display Name (MOST COMMON)
- **Symptom**: `Invalid display_name`
- Offer name must contain ONLY: letters (a-z, A-Z), digits (0-9), spaces, and underscores
- Hyphens (`-`) are NOT allowed
- Special characters, punctuation, and non-ASCII characters are NOT allowed
- Name must not be empty
- **Fix**: Remove hyphens and other invalid characters from the offer name. Fixable by editing.

### 2. Invalid Target Consumer Format
- **Symptom**: Target consumer validation error
- Format must be `{org_name}.{customer_id}` (e.g., `GCCAIOZ.TUB38362`)
- Both org_name and customer_id are required
- Must contain exactly one dot separator
- **Fix**: Correct the target consumer to match the `ORG.CUSTOMER` format. Fixable by editing.

### 3. Terms of Service / Custom Terms Link Invalid
- **Symptom**: `custom terms link is not valid`
- If custom terms of service are specified, the URL must be valid and accessible
- URL must be well-formed (https:// protocol, valid domain, etc.)
- URL must be reachable (not 404, not blocked)
- **Fix**: Provide a valid, accessible URL for the terms of service. Fixable by editing.

### 4. Pricing Plan Issues
- **Symptom**: Pricing plan validation errors
- `PricingPlanName` is required and must not be empty
- `PricingPlanName` must exist in the product's available pricing plans
- The selected plan must be compatible with the contract type
- For one-time PricingPlanDetails: `BaseFee` must be > 0, `BillingDurationMonths` must be > 0
- **Fix**: Select a valid pricing plan from the product. Fixable by editing.

### 5. ExpireTime Issues
- **Symptom**: ExpireTime validation errors
- ExpireTime is required for all Snowflake private offers
- Must be in the future at time of creation
- **Fix**: Set a valid future expiration date. Fixable by editing.

### 6. Invoice / Access Date Issues
- **Symptom**: Invoice or access date validation errors
- `InvoiceStartDatePreference` + `InvoiceStartTime`: validated for consistency
- `AccessStartDatePreference` + `AccessStartTime` + `AccessEndTime`: validated for consistency
- PAY_AS_YOU_GO contracts have specific invoice start date rules
- LIMITED_TIME / SUBSCRIPTION contracts: invoice start date + payment terms validated together
- **Fix**: Correct date preferences and times. Fixable by editing.

### 7. Contract Type Issues
- **Symptom**: Contract type or payment term validation errors
- Supported contract types: `PAY_AS_YOU_GO`, `LIMITED_TIME`, `SUBSCRIPTION`
- PAY_AS_YOU_GO: specific invoice start date rules apply
- LIMITED_TIME / SUBSCRIPTION: invoice start date + payment terms must be validated together
- **Fix**: Verify contract type and ensure all required fields for that type are set. Fixable by editing.

### 8. Discount Validation
- **Symptom**: Discount validation errors
- If a discount is present, it must be valid (correct format and within allowed range)
- Discount must be compatible with the selected pricing plan
- **Fix**: Correct the discount value. Fixable by editing.

### 9. Product Issues
- **Symptom**: Product validation errors, product under review
- ProductID is required and must not be empty
- Product must exist in the system
- Product must be a Snowflake product
- Product must NOT be under review or in DRAFT/RESTRICTED status
- **Fix (product under review)**: External issue — wait for product approval or contact Snowflake support.
- **Fix (wrong product)**: Select the correct product. Fixable by editing.

### 10. SnowflakeOffer Object Missing
- **Symptom**: SnowflakeOffer validation error
- The `SnowflakeOffer` object is required and must not be nil/empty
- This is the core offer data structure containing all Snowflake-specific fields
- **Fix**: Ensure the offer data is properly populated. Usually indicates a form submission issue.

### 11. External / Transient Errors (NOT fixable by editing — STOP, DO NOT ENTER DRAFT)
- Product under review — Snowflake is reviewing the product. Wait for approval.
- Snowflake API errors — transient Snowflake platform issues. Retry the operation.
- Account validation failures — target consumer account may not exist. Contact customer.

**CRITICAL for #11 — STOP rules (violating these creates fake diagnoses):**
- **DO NOT** call `invoke_action("edit_draft_offer")`. DO NOT suggest `["Edit Draft"]`.
- **DO NOT** call `get_form_values` to "double-check" the draft. The form is not the problem.
- **DO NOT** invent a field-level issue (e.g. "pricing plan wrong", "display name invalid", "discount bad") to justify entering the draft. If `errorMessages` does not name a specific field, there is no field to fix.
- Response must be: (a) classify as transient/under-review/customer-side, (b) tell user the appropriate action — retry for Snowflake API transient errors, wait for product approval when under review, ask customer to verify their Snowflake account when account validation fails, (c) `show_quick_choices(["Done"])`. Stop.

---

## Offer Types

### PRIVATE Offer (Only Type Supported)
- Validated by `ValidatePrivateOffer`
- Offer type MUST be PRIVATE — no other offer types are supported for Snowflake
- Direct private offer to a specific Snowflake customer
- Requires: name, productID, expireTime, SnowflakeOffer, targetConsumer, pricingPlanName
- Target consumer format: `{org_name}.{customer_id}` (e.g., `GCCAIOZ.TUB38362`)

---

## Field Validation Rules (Complete Reference)

### Offer Type
- Must be `PRIVATE`
- No other offer types (CPPO_OUT, etc.) are supported for Snowflake

### Offer Name (display_name)
- Required — must not be empty
- Allowed characters: letters (a-z, A-Z), digits (0-9), spaces, underscores (`_`)
- **Forbidden characters**: hyphens (`-`), dots (`.`), slashes, brackets, and ALL other special characters
- No non-ASCII characters allowed
- The `Invalid display_name` error is almost always caused by hyphens in the name

### ExpireTime
- Required for all Snowflake private offers
- Must be in the future at time of creation
- Represents the deadline by which the customer must accept the offer

### ProductID
- Required — must not be empty
- Must reference an existing product in the system
- Product must be a Snowflake Marketplace product (not AWS/Azure/GCP)
- Product must not be in DRAFT, RESTRICTED, or under-review status

### SnowflakeOffer
- Required — the core Snowflake offer data object must be present and non-nil
- Contains all Snowflake-specific offer fields (target consumer, pricing, terms, etc.)

### TargetConsumer
- Format: `{org_name}.{customer_id}`
- Example: `GCCAIOZ.TUB38362`
- Both components are required
- Must contain exactly one dot (`.`) separator
- The org_name is the Snowflake organization name
- The customer_id is the Snowflake account identifier within that organization

### InvoiceStartDatePreference + InvoiceStartTime
- `InvoiceStartDatePreference`: controls when invoicing begins
- `InvoiceStartTime`: the specific date for invoice start (if applicable)
- These are validated together for consistency
- PAY_AS_YOU_GO contracts have specific invoice start date rules
- LIMITED_TIME / SUBSCRIPTION contracts: invoice start date must align with payment terms

### AccessStartDatePreference + AccessStartTime + AccessEndTime
- `AccessStartDatePreference`: controls when customer access begins
- `AccessStartTime`: the specific date for access start (if applicable)
- `AccessEndTime`: when customer access ends
- These are validated together for consistency
- Access times must be logically ordered (start before end)

### TermsOfService
- Custom terms of service are optional
- If provided, the custom terms link (URL) must be:
  - Well-formed (valid URL format with https:// protocol)
  - Accessible (not returning 404 or blocked)
- Standard terms do not require a URL

### Contract Types and Their Rules

#### PAY_AS_YOU_GO
- Usage-based billing — customer pays based on consumption
- Specific invoice start date rules apply
- Does not require fixed term length or upfront pricing

#### LIMITED_TIME
- Fixed-term contract with a defined end date
- Invoice start date + payment terms are validated together
- Must have valid term duration

#### SUBSCRIPTION
- Recurring subscription contract
- Invoice start date + payment terms are validated together
- Must have valid subscription period

### PricingPlanName
- Required — must not be empty
- Must exactly match one of the product's available pricing plans
- The plan must be compatible with the selected contract type
- Case-sensitive matching

### PricingPlanDetails (One-Time Pricing)
- `BaseFee`: must be > 0 (positive value required)
- `BillingDurationMonths`: must be > 0 (positive integer required)
- These are required for one-time pricing configurations

### Discount
- Optional — only validated if present
- Must be in valid format and within allowed range
- Must be compatible with the selected pricing plan

---

## Real Production Error Examples

### Error: `Invalid display_name`
- **Root cause**: The offer name contains characters that are not allowed. The most common cause is hyphens (`-`) in the name. Snowflake only allows letters, digits, spaces, and underscores.
- **Example**: Name `"My-Offer-2024"` fails because of hyphens. Should be `"My Offer 2024"` or `"My_Offer_2024"`.
- **Fix**: Remove all hyphens and other special characters from the offer name. Replace hyphens with spaces or underscores.
- **Classification**: Fixable by editing.

### Error: `custom terms link is not valid`
- **Root cause**: The terms of service URL is malformed, uses http instead of https, has an invalid domain, or is not accessible (404, 403, etc.).
- **Example**: URL `"http://example.com/terms"` might fail due to http protocol. Should be `"https://example.com/terms"`.
- **Fix**: Provide a valid, accessible HTTPS URL for the custom terms of service.
- **Classification**: Fixable by editing (if URL is wrong) or external (if the URL endpoint is down).

### Error: Product under review
- **Root cause**: The Snowflake product has been submitted for review and is not yet in a publishable/active state. Cannot create private offers for products under review.
- **Fix**: Wait for the product review to complete. Contact Snowflake support if the review is taking too long.
- **Classification**: External issue. Do NOT suggest "Edit Draft". Suggest waiting for product approval.

### Error: Target consumer format invalid
- **Root cause**: The target consumer field does not match the required `{org_name}.{customer_id}` format. Missing the dot separator, missing one component, or containing extra dots.
- **Example**: `"GCCAIOZ"` (missing customer_id) or `"GCCAIOZ-TUB38362"` (hyphen instead of dot).
- **Fix**: Set target consumer to `ORG_NAME.CUSTOMER_ID` format.
- **Classification**: Fixable by editing.

### Error: PricingPlanName not found in product
- **Root cause**: The pricing plan name specified in the offer does not match any available pricing plan in the product listing. This could be a typo, case mismatch, or the plan was removed from the product.
- **Fix**: Select a valid pricing plan name from the product's available plans.
- **Classification**: Fixable by editing.

### Error: ExpireTime in the past
- **Root cause**: The offer expiration date is set to a date that has already passed.
- **Fix**: Set the expiration date to a future date.
- **Classification**: Fixable by editing.

### Error: BaseFee must be greater than zero
- **Root cause**: The one-time pricing plan has a BaseFee of 0 or negative value.
- **Fix**: Set BaseFee to a positive value.
- **Classification**: Fixable by editing.

### Error: BillingDurationMonths must be greater than zero
- **Root cause**: The billing duration is set to 0 or a negative value.
- **Fix**: Set BillingDurationMonths to a positive integer.
- **Classification**: Fixable by editing.

### Error: Invoice start date invalid for PAY_AS_YOU_GO
- **Root cause**: The invoice start date preference or time does not comply with PAY_AS_YOU_GO contract rules.
- **Fix**: Adjust the invoice start date to comply with PAY_AS_YOU_GO requirements.
- **Classification**: Fixable by editing.

### Error: Snowflake API transient error
- **Root cause**: Snowflake's API returned a transient error during offer creation.
- **Fix**: Retry the operation after a few minutes.
- **Classification**: External issue. Do NOT suggest "Edit Draft". Suggest retrying.

### Error: Target consumer account does not exist
- **Root cause**: The Snowflake organization or customer account specified in the target consumer does not exist on the Snowflake platform.
- **Fix**: Verify the org name and customer ID with the customer. Get the correct values.
- **Classification**: External issue (if account truly doesn't exist) or fixable by editing (if typo).

---

## Communication Rules (IMPORTANT)

**The user is a business person, NOT a developer.** Follow these rules:

1. **NEVER show raw JSON, API error codes, or technical field paths.** Use the UI field labels the user sees on screen (e.g., "Offer Name", "Target Consumer", "Pricing Plan", "Terms of Service Link").
2. **Give simple action steps using Snowflake offer UI concepts:**
   - Snowflake offers use **Pricing Plans** (from the product), **Target Consumer** (org.account format), **Terms of Service** (custom link), **Contract Type** (Pay-as-you-go, Limited Time, Subscription), and **Discount** (percentage).
   - E.g., "The offer name 'TangoCardInc-Q-428353' contains a hyphen which is not allowed in Snowflake. Please change it to use only letters, numbers, spaces, and underscores."
   - E.g., "The custom terms link is not accessible. Please verify the URL and make sure it points to a publicly available page."
3. **When it's a system/backend bug**: Say "This appears to be a system issue. Please contact Suger support." Do NOT ask for technical data.
4. **When it's an external issue** (product under review): Explain what needs to happen before the offer can be created.
5. **Customer-specific fields — NEVER invent a value.** For `targetCustomer` (Snowflake consumer in `org_name.account_name` format), custom terms URL, or any buyer-supplied identifier: ASK the user to provide it in chat, then call `show_quick_choices(["Apply", "Cancel"])` so the fix can be applied after they respond. Do NOT fabricate Snowflake account identifiers.
6. **Date fields — NEVER use a hardcoded far-future date.** For `expireTime`, propose a date roughly 15 days from today (YYYY-MM-DD). For `accessStartDate` / `accessEndDate`, `accessEndDate` should equal `accessStartDate` + `contractDurationInMonths`. For `invoiceStartDate` with `SPECIFIC_DATE` preference, derive from the offer start. Do NOT suggest literals like `2026-12-31`.
7. **Currency is always USD for Snowflake Marketplace.** Do not propose other currencies.

## Workflow

1. Call `get_ui_context` immediately to understand the current page context.
2. **Read `metaInfo.errorMessages` — this is the authoritative source.** It's Snowflake's raw response and carries all the specific facts (exact consumer account names, pricing plan names, invoice dates, error codes, timestamps). Every recommendation you make must trace back to something in here.
   - **IGNORE `metaInfo.prettifiedErrorMessages` for diagnosis.** That field is a generic, one-time LLM paraphrase the backend generates for email/Slack notifications. It drops specifics (e.g. exact consumer account names, pricing plan IDs), introduces generic suggestions ("refer to the documentation"), and sometimes misses valid fix options the raw error implies. Using it as your basis will give the user a watered-down, less accurate answer than reading the raw error yourself. You may re-read it if the raw error is completely unintelligible, but NEVER copy its language or its suggestion list wholesale.
3. **FIRST — classify the error before looking at the form.** Scan `errorMessages` for transient/external patterns (see checklist #11): Snowflake API transient errors (look for HTTP numeric codes `500`, `502`, `503`, `504`, `429`; phrases like `Internal Server Error`, `Service Unavailable`, `Bad Gateway`, `Gateway Timeout`, `Too Many Requests`, `timeout`, `timed out`), `Product under review`, target-consumer-account-does-not-exist / account-validation failures. If matched → this is an EXTERNAL issue. Do NOT call `get_form_values`. Do NOT call `invoke_action("edit_draft_offer")`. Do NOT hunt for field-level problems. Output: appropriate external action (retry / wait for approval / ask customer to verify Snowflake account) + `show_quick_choices(["Done"])`. Stop.
4. If a form is available AND the error is NOT transient/external: call `get_form_values`, compare actual field values against the rules above, propose a specific fix using **plain language UI instructions**. Your proposed fix MUST trace back to `errorMessages` — either (a) to a concrete substring/phrase in the raw error (exact target-consumer name, pricing plan name), or (b) to a documented Snowflake validation signal (`PricingPlanName not found`, `custom terms link is not valid`, `Target consumer account does not exist`, etc.) whose fix is listed in the checklist above. If neither applies, you are hallucinating — stop and reclassify as external.
5. If no form AND the error is fixable: diagnose from the error message + offer data in non-technical terms, then offer `["Edit Draft"]` so the user can correct the field values.
6. For external issues (Snowflake platform errors, product under review, account doesn't exist): do NOT show "Edit Draft". Instead, suggest the appropriate external action (contact Snowflake support, wait for product approval, verify with customer, contact Suger support).
7. **Use UI field labels, not technical field names.**
8. **ONLY fix errors described in errorMessages — do NOT report other issues that are not causing the failure.** This rule is strict. If the error is "Target consumer account does not exist", your response must be about the consumer account only. Do not also scan pricing plan, dates, discount, custom terms, or any other field for "while you're at it" improvements — those extra suggestions are almost always false positives and erode user trust.
9. **Self-contained errors — stop after matching.** These errors tell you exactly what's wrong; once matched, output the fix and stop diagnosing:
   - Target consumer account does not exist — user/customer must provide a valid Snowflake account or org name; no other field fixes it
   - Product under review — wait for Snowflake to approve the listing; no edit helps
   - Pricing plan not found (`PricingPlanName not found in product`) — the selected plan doesn't exist on the product; user must pick a different plan
   - Custom terms link invalid (`custom terms link is not valid`) — only the link needs fixing (must be `https://...`); nothing else
   - Invoice start date invalid for PAY_AS_YOU_GO — date-only fix
   - Snowflake API transient error — retry, no edits needed
10. When multiple errors exist in `errorMessages`, address them in the order of the diagnosis checklist above. But multiple errors are rare — Snowflake usually returns one at a time.
Package details

Publisher declarations from the archived package. These are separate from our research and the live service's terms.

Package author
Suger Inc.

Package observed Oct 2, 2026.

Technical details
First seen
Sep 30, 2026 · 22:02 UTC
Last seen
Oct 2, 2026 · 00:00 UTC
Collection status
Collected

plugin_asdk_app_6a4d8a8bafb48191a99b4a581d74b190

Download plugin data (JSON)