diff --git a/.gitignore b/.gitignore index 2bd1f43..8e0d37e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,10 @@ workflows/webhooks-migration/ready-prompt.md # Node node_modules/ + +# Local test harnesses & Postman collections (not distributed with the prompt packages) +discover/*/postman/ +discover/*/test/ + +# Claude Code — local tooling, not shipped in this public repo. +.claude/ diff --git a/discover/README.md b/discover/README.md index 05ec81e..dc624e1 100644 --- a/discover/README.md +++ b/discover/README.md @@ -15,48 +15,84 @@ A dynamic prompt generation toolkit that helps developers build Intuit Enterpris git clone cd Prompt-Library/discover -# 2. Configure your settings +# 2. Configure your settings as per your requirements # Edit prompt-config.json (see Configuration section below) -# 3. Generate a prompt (interactive — choose 1–4) +# 3. Generate prompts node merge-prompt.js +# Or specify a language directly (overrides language_framework in config) +node merge-prompt.js --language java +node merge-prompt.js --language python +node merge-prompt.js --language dotnet + # Or use a custom config profile node merge-prompt.js --config config-java-bill.json + +# Drop-in mode — for pasting the prompt into an existing codebase (no new folder scaffolded) +node merge-prompt.js --config prompt-config-existing.json + +# Combine both +node merge-prompt.js --config config-typescript-invoice.json --language typescript ``` -The script prompts you to pick which template to generate. Run it once per template you need: +This produces ready-to-use prompt files: -| Choice | Generated File | Use Case | -|---|---|---| -| `1` | `generated-prompts/dimensions-ready-prompt.md` | Dimensions API — tag transactions with custom dimensions | -| `2` | `generated-prompts/projects-ready-prompt.md` | Projects API — create projects and project estimates | -| `3` | `generated-prompts/custom-fields-ready-prompt.md` | Custom Fields API — discover, attach, and read custom field values | -| `4` | `generated-prompts/sales-tax-ready-prompt.md` | Sales Tax API — calculate sale transaction tax | +| Generated File | Use Case | +|---|---| +| `generated-prompts/dimensions-ready-prompt.md` | Dimensions API — tag transactions with custom dimensions | +| `generated-prompts/projects-ready-prompt.md` | Projects API — create projects and project estimates | +| `generated-prompts/custom-fields-ready-prompt.md` | Custom Fields API — attach custom metadata to transactions | +| `generated-prompts/sales-tax-ready-prompt.md` | Sales Tax GraphQL API — calculate jurisdiction-aware tax for transactions built outside QBO | +| `generated-prompts/project-budgets-ready-prompt.md` | Project Budgets API — CRUD PROJECT-type budgets via Business Planning GraphQL (IES / QBO Advanced) | +| `generated-prompts/project-change-orders-ready-prompt.md` | Project Change Orders API — CRUD project-scoped change orders via REST V3 (IES / QBO Advanced + Construction Pack) | +| `generated-prompts/oauth-setup-ready-prompt.md` | OAuth 2.0 setup — authorization-code flow, token exchange, refresh-token rotation, and secure storage (the prerequisite for every other prompt) | Copy the contents of a generated prompt into your preferred AI coding assistant (e.g., Copilot, Cursor, ChatGPT, Windsurf) to generate a complete, runnable integration project. ## Project Structure ``` -Prompt-Library/discover/ -├── prompt-config.json # Configuration — your customizable settings +discover/ +├── prompt-config.json # Configuration — defaults to integration_mode "new" +├── prompt-config-existing.json # Drop-in config — integration_mode "existing" for brownfield repos ├── prompt-config.schema.json # JSON Schema — validates config before merge ├── merge-prompt.js # Prompt generator script ├── instructions.md # Reference — supported values & examples ├── dimensions/ │ └── prompt-template-dimensions.md # Template — Dimensions API workflow ├── projects/ -│ └── prompt-template-projects.md # Template — Projects & Estimates workflow +│ └── prompt-template-projects.md # Template — Projects & Estimates workflow ├── custom-fields/ -│ └── prompt-template-custom-fields.md # Template — Custom Fields API workflow +│ ├── prompt-template-custom-fields.md # Template — Custom Fields API workflow +│ ├── postman/ # Reference Postman collection + env for manual testing +│ └── sdk-notes/ # Per-language SDK guidance (injected at merge time) +│ ├── java.md +│ ├── dotnet.md +│ ├── php.md +│ ├── nodejs.md +│ ├── python.md +│ └── ruby.md ├── sales-tax/ -│ └── prompt-template-sales-tax.md # Template — Sales Tax calculation workflow +│ ├── prompt-template-sales-tax.md # Template — Sales Tax GraphQL workflow +│ ├── postman/ # Reference Postman collection +│ └── sdk-notes/ # Per-language SDK guidance (injected at merge time) +├── project-budgets/ +│ ├── README.md # Project Budgets prompt overview & .env setup +│ └── prompt-template-project-budgets.md # Template — Project Budgets (Business Planning GraphQL) +├── project-change-orders/ +│ ├── README.md # Project Change Orders prompt overview & .env setup +│ └── prompt-template-project-change-orders.md # Template — Project Change Orders (REST V3) +├── oauth-setup/ +│ └── prompt-template-oauth-setup.md # Template — OAuth 2.0 authorization-code flow ├── generated-prompts/ │ ├── dimensions-ready-prompt.md # Generated — Dimensions API prompt │ ├── projects-ready-prompt.md # Generated — Projects API prompt │ ├── custom-fields-ready-prompt.md # Generated — Custom Fields API prompt -│ └── sales-tax-ready-prompt.md # Generated — Sales Tax API prompt +│ ├── sales-tax-ready-prompt.md # Generated — Sales Tax API prompt +│ ├── project-budgets-ready-prompt.md # Generated — Project Budgets API prompt +│ ├── project-change-orders-ready-prompt.md # Generated — Project Change Orders API prompt +│ └── oauth-setup-ready-prompt.md # Generated — OAuth 2.0 setup prompt └── README.md # This file ``` @@ -73,7 +109,7 @@ All settings live in `prompt-config.json`. The **first few keys** are the ones y | 3 | `type_of_transaction` | QuickBooks transaction type | `"Estimate"`, `"Invoice"`, `"Bill"` | | 4 | `typing_system` | Type system style for the chosen language | `"hints (dataclasses)"`, `"Pydantic models"` | | 5 | `transaction_creation_instructions` | Instructions for creating a Dimensions-tagged transaction | See [examples](#transaction-instructions) | -| 6 | `markup_percentages` | Markup % used for Project Estimate cost calculations (number; positive = profit, negative = loss) | `20` | +| 6 | `markup_percentages` | Markup % used for Project Estimate cost calculations | `"20"` | | 7 | `project_estimate_creation_instructions` | Instructions for creating a Project Estimate | See [examples](#transaction-instructions) | ### Static Fields (do not modify) @@ -97,7 +133,7 @@ These include GraphQL/REST endpoints, API documentation URLs, query templates, a | PHP | `PHP 8 typed properties` | Modern PHP with strict types | | Swift | `Swift structs/Codable` | iOS/macOS native | -## Supported Transaction Types (With Dimensions values) +## Supported Transaction Types (with Dimension Values) `Bill` · `CreditMemo` · `Deposit` · `Estimate` · `Expense/Purchase` · `Invoice` · `JournalEntry` · `PurchaseOrder` · `RefundReceipt` · `SalesReceipt` · `VendorCredit` @@ -112,7 +148,7 @@ The `transaction_creation_instructions` and `project_estimate_creation_instructi **Example — Bill with Java SDK:** ```json -"transaction_creation_instructions": "Create Bill with default item id: 2 and default vendor: 28 and Bill amount: 100 and APAccountRef=20. For REST API calls, use the Intuit official Java SDK at {{java-sdk-official}} with Gradle dependency. Use all the latest versions and best practices for SDK integration. Refer to the official documentation at: {{java-sdk-documentation}} and {{oauth2-documentation}}. Use appropriate methods from SDKs only, do not hallucinate or make up methods that don't exist in the SDK. " +"transaction_creation_instructions": "Create Bill with default item id: 2 and default vendor: 28 and Bill amount: 100 and APAccountRef=20. For REST API calls, use the Intuit official Java SDK at {{java-sdk-official}} with Gradle dependency. Use all the latest versions and best practices for SDK integration. Refer to the official documentation at: {{java-sdk-documentation}} and {{oauth2-documentation}}. Use appropriatemethods from SDKs only, do not hallucinate or make up methods that don't exist in the SDK. " ``` **Example — Project Estimate with markup:** @@ -127,23 +163,25 @@ Refer to `instructions.md` for more detailed examples, including Purchase Order ``` ┌─────────────────────┐ ┌──────────────────────┐ -│ prompt-config.json │────▶│ merge-prompt.js │ -│ (your settings) │ │ (asks: 1–4?) │ -└─────────────────────┘ └──────────┬───────────┘ - │ - ┌───────────────┬───────────────────┼───────────────────┬───────────────┐ - choice 1 choice 2 choice 3 choice 4 - ▼ ▼ ▼ ▼ - dimensions/ projects/ custom-fields/ sales-tax/ - prompt- prompt- prompt- prompt- - template- template- template- template- - dimensions.md projects.md custom-fields.md sales-tax.md - │ │ │ │ - ▼ ▼ ▼ ▼ - generated-prompts/…-ready-prompt.md (generated, AI-ready) +│ prompt-config.json │────▶│ merge-prompt.js │ +│ (your settings) │ │ (template engine) │ +└─────────────────────┘ └──────┬───────┬───────┘ + │ │ + ┌────────────────────┘ └────────────────────┐ + ▼ ▼ +┌─────────────────────────────────────┐ ┌─────────────────────────────────┐ +│ dimensions/prompt-template- │ │ projects/prompt-template- │ +│ dimensions.md (Dimensions template) │ │ projects.md (Projects template) │ +└────────────┬────────────────────────┘ └────────────┬────────────────────┘ + ▼ ▼ +┌──────────────────────────────────┐ ┌──────────────────────────────────┐ +│ generated-prompts/ │ │ generated-prompts/ │ +│ dimensions-ready-prompt.md │ │ projects-ready-prompt.md │ +│ (generated, AI-ready) │ │ (generated, AI-ready) │ +└──────────────────────────────────┘ └──────────────────────────────────┘ ``` -`merge-prompt.js` reads the selected template, replaces every `{{placeholder}}` with the corresponding value from `prompt-config.json`, and writes the result as a ready-to-use prompt file to `generated-prompts/`. Run the script once per template you want to generate. +`merge-prompt.js` reads each template, replaces every `{{placeholder}}` with the corresponding value from `prompt-config.json`, and writes the result as a ready-to-use prompt file. ### Safeguards @@ -176,6 +214,14 @@ Controls whether the AI output targets a **new** (greenfield) project or an **ex **Tip for existing projects:** After generating, review the integration notes in the output. The generated code uses the same API logic and pre-flight checks regardless of mode — the difference is packaging only. +**Shortcut for drop-in mode:** Use the bundled `prompt-config-existing.json` instead of editing the default config: + +```bash +node merge-prompt.js --config prompt-config-existing.json +``` + +This is identical to `prompt-config.json` but with `integration_mode` set to `"existing"`, so the generated prompt tells the AI to produce drop-in modules rather than scaffolding a sibling folder like `custom-fields-/`. Use this whenever you plan to paste the generated prompt into an existing codebase. + ## What the Generated Prompts Produce When you feed a generated prompt to an AI assistant, the output will include: @@ -198,27 +244,38 @@ Generates code to discover IES Dimensions (custom categorization) via GraphQL, t Generates code to verify project eligibility, discover or create projects via GraphQL, create Project Estimates via REST, and display the results with markup calculations. ### Custom Fields (`custom-fields/prompt-template-custom-fields.md`) -Generates code to discover active custom field definitions via GraphQL, attach custom metadata to QuickBooks transactions (and `Customer`/`Vendor` entities) via REST V3, and read them back with human-readable labels. - -- **Silver+ partner tier required** — the Custom Fields API is a premium API; Builder-tier apps receive `403 Forbidden`. -- **Production-only GraphQL** — the Custom Fields GraphQL API has no documented sandbox endpoint; test with free non-expiring partner test accounts. (The REST V3 read/write calls still support sandbox.) -- **`legacyIDV2` bridge** — the GraphQL definition returns both `id` (opaque) and `legacyIDV2` (numeric); REST V3 payloads must set `DefinitionId = legacyIDV2`, not `id`. +Generates code to discover active custom field definitions via GraphQL (using `legacyIDV2` as the REST bridge), create transactions with custom field values via REST V3, and hydrate the response with human-readable labels. SDK notes are injected per language via the `--language` flag. ### Sales Tax (`sales-tax/prompt-template-sales-tax.md`) -Generates code to calculate sale transaction tax via the GraphQL `indirectTaxCalculateSaleTransactionTax` mutation — computing per-line and aggregate tax from ship-from/ship-to addresses, line items, and shipping fees. +Generates code that calls the `indirectTaxCalculateSaleTransactionTax` GraphQL mutation to compute jurisdiction-aware sales tax for transactions built outside QuickBooks (custom invoicing UIs, checkout flows, third-party ERPs). Stateless — no transaction is persisted in QBO. SDK notes injected per language via `--language`. -- **Scope required** — `indirect-tax.tax-calculation.quickbooks`. -- **Calculation-only** — the API returns a tax calculation; it does not itself create or post a transaction. +### Project Budgets (`project-budgets/prompt-template-project-budgets.md`) +Generates code that verifies IES / QBO Advanced eligibility, discovers or creates a project via the Projects GraphQL API, then performs full CRUD on a `PROJECT`-type budget through the Business Planning GraphQL API. The hard `budgetType = "PROJECT"` rule is enforced in the prompt's anti-hallucination guardrails. + +### Project Change Orders (`project-change-orders/prompt-template-project-change-orders.md`) +Generates code that verifies IES / QBO Advanced + Construction Pack eligibility, discovers or creates a project + parent project estimate, then performs full CRUD on a change order via the REST V3 `/changeorder` endpoint. The hard "LinkedTxn on every line" rule is enforced in the prompt's anti-hallucination guardrails. ## Troubleshooting | Problem | Solution | |---------|----------| | Unresolved `{{placeholder}}` in output | The script warns automatically; ensure the key exists in your config file with exact spelling and casing | -| `node merge-prompt.js` fails | Verify Node.js is installed and you are running from the `discover/` directory (not the repo root) | +| `node merge-prompt.js` fails | Verify Node.js is installed and you are running from the project root | | Generated code gets 401 errors | Refresh your OAuth 2.0 access token | | Generated code gets 403 on GraphQL | Your QuickBooks company may not support the feature (IES/Advanced required) | +## Feedback + +Used this prompt? We'd love your input to shape the next iteration — what worked, what didn't, and what's missing. + +👉 **[Share feedback on the Intuit Developer Prompt Library](https://docs.google.com/forms/d/e/1FAIpQLScPhA0gk09aOmB-S1uBfZOEwshjGTwXoSRRCj2yD2bTseSQmg/viewform)** + +The survey takes ~1 minute and covers: which prompt you used, how useful it was (1–5), whether it simplified development, and what would make it better. + +## License + +Intuit Developer Relations. + ## License MIT — see [LICENSE](../LICENSE) at the repo root. diff --git a/discover/custom-fields/README.md b/discover/custom-fields/README.md new file mode 100644 index 0000000..44113a5 --- /dev/null +++ b/discover/custom-fields/README.md @@ -0,0 +1,295 @@ +# Custom Fields Prompt + +Generates AI-ready prompts for integrating with the **QuickBooks Custom Fields APIs** — discovers active custom field definitions via **GraphQL**, attaches custom metadata to QuickBooks transactions and to `Customer`/`Vendor` entities via **REST V3**, and reads them back with human-readable labels. + +> ℹ️ **Production only.** The Custom Fields GraphQL API does **not** have a documented sandbox endpoint — the official docs list only the production GraphQL host. Test using free non-expiring partner test accounts (request via the Intuit Partner Program). The REST V3 calls in Tasks 2 & 3 do still support sandbox for the entity payload, but the Task 1 / 4 / 5 GraphQL operations are production-only. +> +> ⚠️ **Silver+ partner tier required.** The Custom Fields API is a premium API restricted to Silver, Gold, and Platinum partners — Builder-tier apps will receive `403 Forbidden`. Upgrade on the [Intuit Developer Portal](https://developer.intuit.com); if access is missing on a paid tier, open a support ticket. + +## What This Prompt Produces + +When you paste the generated prompt into an AI coding assistant (Claude, Copilot, Cursor, ChatGPT, etc.), it generates a complete integration covering: + +1. **Task 1 — Discover Custom Field Definitions (GraphQL)** + - POSTs the `appFoundationsCustomFieldDefinitions` query to the GraphQL endpoint, with a `filters` input (plural — `AppFoundations_CustomExtensionsDefinitionFilterBy`) using primitive fields (the shipped query sends `active: true` only) — no `{ equals: ... }` wrappers. Narrowing to the target entity is done **client-side** (see the `subType` note below) + - Extracts `id` (lowercase — schema does NOT expose `Id`), **`legacyIDV2`**, `label`, `dataType`, `required`, `dropDownOptions[]`, and `associations[]` per definition + - Paginates via `pageInfo.endCursor` / `pageInfo.hasNextPage` until all definitions are collected + - Builds an in-memory map keyed by **`legacyIDV2`** (the value REST V3 will need) storing `label`, `dataType`, `dropDownOptions`, and `associations` +2. **Task 2 — Entity creation (REST V3)** — creates a transaction (e.g., `salesreceipt`, `invoice`, `bill`) or a `Customer` / `Vendor` / `Project` with a `CustomField` array attached. Sets `DefinitionId = legacyIDV2` from Task 1. Type-aware: chooses `StringValue` / `NumberValue` / `DateValue` based on each definition's `dataType`. **For `STRING_LIST` / `OBJECT_LIST` types, validates the written value against the definition's `dropDownOptions[]` before sending** — REST V3 will accept arbitrary strings, but they won't match QBO's UI dropdown and downstream features will break. **All requests must include `?include=enhancedAllCustomFields` in the query string** — without it, the response will not return the full custom-field metadata. +3. **Task 3 — Read & hydrate** — retrieves the created entity via REST V3 (with `?include=enhancedAllCustomFields`) and displays it with human-readable labels and type-correct value reads (using the cached definition map from Task 1). +4. **Task 4 (optional) — Create a new definition (GraphQL)** — calls `appFoundationsCreateCustomFieldDefinition` to programmatically provision a new custom field. Skip unless your app provisions fields as part of onboarding (e.g. an inventory app that requires a "SKU" field on every Invoice). Requires the write scope `app-foundations.custom-field-definitions` (no `.read` suffix). + +Plus: structured error handling (`401`, `400`, `403`, GraphQL errors on HTTP 200), `intuit_tid` observability logging, definition caching, and tier-awareness checks (see capability table below). + +## Capability by QBO Tier (Important) + +The Custom Fields API enforces hard limits per QBO product tier. Generated code that assumes "Vendor custom fields work everywhere" will break silently on Essentials. The prompt instructs the AI to surface tier-mismatch errors instead of letting empty GraphQL responses look like "no fields configured." + +| Tier | Max custom fields | Supported transactions | Supported entities | +|---|---|---|---| +| **Essentials** | 3 | SalesReceipt, Invoice, Estimate, CreditMemo, RefundReceipt | *(none — transactions only)* | +| **Plus** | 4 | SalesReceipt, Invoice, Estimate, CreditMemo, RefundReceipt, PurchaseOrder | *(none — transactions only)* | +| **Advanced / Intuit Enterprise Suite** | 12 | All of Plus + Purchase (Cash/Check/CC), Bill, Credit Card Payment, VendorCredit | Customer, Vendor, Projects | + +**Tier detection:** No GraphQL/REST field returns the QBO tier directly. Generated code should either (a) accept the tier as configuration, or (b) treat an empty Task 1 response on a known-configured field as a tier-mismatch hint and surface that to the developer. + +## The `legacyIDV2` Bridge (Non-Negotiable) + +The GraphQL response returns two ID-like fields per definition: + +| Field | Use | +|---|---| +| `id` | GraphQL internal ID (lowercase — the schema does NOT expose `Id`) — for GraphQL operations **only** | +| `legacyIDV2` | The value to pass as `DefinitionId` in **every** REST V3 call | + +Using the GraphQL `id` as `DefinitionId` in REST V3 will return **`400 Bad Request`**. The generated code enforces this bridge by keying the definition map on `legacyIDV2` everywhere. + +## GraphQL Filter Shape (Easy to Get Wrong) + +The `appFoundationsCustomFieldDefinitions` query takes an argument named **`filters`** (plural). The input type is `AppFoundations_CustomExtensionsDefinitionFilterBy` and its fields are **primitives** — there is no `{ equals: ... }` predicate wrapper: + +```graphql +appFoundationsCustomFieldDefinitions( + filters: { active: true } # ✅ what the shipped query uses — filter by target entity CLIENT-SIDE + first: 50 +) { edges { node { id legacyIDV2 label dataType active } } pageInfo { endCursor hasNextPage } } +``` + +> **On entity/`subType` filtering:** the generated code filters by `active` server-side and then narrows to the target entity **client-side** via `associations[].associatedEntity` / `subAssociations[]` (see the template Task 1). A server-side `subType` filter argument is **not used by the shipped query** and its behavior is unverified — do not rely on it; filter client-side as the template does. + +```graphql +# ❌ Wrong — `filter` (singular) does not exist +appFoundationsCustomFieldDefinitions(filter: { ... }) +# ❌ Wrong — there is no `{ equals: }` wrapper on the filter fields +filters: { active: { equals: true }, subType: { equals: "invoice" } } +# ❌ Wrong — `subType` is only an input filter, NOT a node field +node { Id subType label } +``` + +The full `filters` input fields: `active: Boolean`, `subType: String`, `entityType: String`, `dataType: AppFoundations_CustomExtensionDataType` (enum), `ids: [ID]`. + +## Type-Aware Value Mapping + +Custom Field definitions have a `dataType` (from GraphQL) that determines which value field to populate on the REST V3 entity. The official `AppFoundations_CustomExtensionDataType` enum has these values: + +| GraphQL `dataType` | REST V3 `Type` | Value field to set | Format | Validation | +|---|---|---|---|---| +| `STRING` | `StringType` | `StringValue` | string | none (free text) | +| `NUMBER` | `NumberType` | `NumberValue` | decimal | numeric only | +| `DATE` | `DateType` | `DateValue` | `YYYY-MM-DD` | RFC3339 date | +| `STRING_LIST` | `StringType` | `StringValue` | must match an active `Value` from `dropDownOptions[]` (case-sensitive) | **strict — reject pre-write** | +| `OBJECT_LIST` | `StringType` | `StringValue` | must match an active `Value` from `dropDownOptions[]` | **strict — reject pre-write** | +| `UNKNOWN` | *(skip)* | *(skip)* | Skip with logged warning | always skip | + +> The published `AppFoundations_CustomExtensionDataType` enum does not list `BOOLEAN`. Older legacy-REST docs reference a `BooleanType` / `BooleanValue` pairing — confirm against the live schema if you see a boolean type returned. The generated code should treat any `dataType` outside the documented set as `UNKNOWN` and surface a warning. + +Every `CustomField` entry must populate **exactly one** value field. Supplying zero or more than one is a schema violation. + +The prompt instructs the AI to **read `dataType` from each definition** and choose the correct REST V3 `Type` and value field at write time, and to read the correct value field at hydration time in Task 3. Generated code that hardcodes `StringValue` for every field will not pass the type-aware reading check in Task 3. + +## How To Generate The Prompt + +From the `discover/` directory: + +```bash +# Default (uses prompt-config.json — integration_mode "new" scaffolds a sibling folder) +node merge-prompt.js +# then select choice 3 at the menu + +# Language-specific (selects the right sdk-notes/.md) +node merge-prompt.js --language java +node merge-prompt.js --language python +node merge-prompt.js --language dotnet +``` + +Output: `discover/generated-prompts/custom-fields-ready-prompt.md` + +Copy that file's contents into your AI assistant. + +## Authentication & `.env` Setup + +The generated code assumes the caller already has a valid **OAuth 2.0 access token** and **realmId**: + +```bash +QBO_ACCESS_TOKEN= +QBO_REALM_ID= +QBO_MINOR_VERSION=75 +``` + +### Endpoints + +The Custom Fields GraphQL API is **production-only** — no sandbox is documented. + +| Surface | Host | +|---|---| +| GraphQL (Tasks 1, 4, 5) | `https://qb.api.intuit.com/graphql` | +| REST V3 (Tasks 2, 3) | `https://quickbooks.api.intuit.com` *(sandbox `https://sandbox-quickbooks.api.intuit.com` available for the entity payload itself, but the CF definitions still come from prod GraphQL)* | + +All REST V3 calls must include `?include=enhancedAllCustomFields` in the query string. + +Use the **[Intuit OAuth 2.0 Playground](https://developer.intuit.com/app/developer/playground)** to generate a production access token. For dev/test work, request a free non-expiring partner test account via the Intuit Partner Program rather than sandbox. + +## Required OAuth Scopes + +The generated code assumes the app is authorized with the scopes it needs. The `.read` and unsuffixed scopes are **different scope strings** — requesting `.read` does NOT grant write access. + +| Scope | Used For | Required for | +|---|---|---| +| `app-foundations.custom-field-definitions.read` | **Read** definitions via GraphQL | Task 1, Task 3 (hydration uses cache from Task 1) | +| `app-foundations.custom-field-definitions` | **Read + write** definitions via GraphQL (call `appFoundationsCreateCustomFieldDefinition`) | Task 4 only | +| `com.intuit.quickbooks.accounting` | Creating and reading transactions/entities via REST V3 | Task 2, Task 3 | + +A `403 Forbidden` on the GraphQL query usually means `app-foundations.custom-field-definitions.read` is missing, Custom Fields are not enabled at the company level, or the app is not on a Silver/Gold/Platinum partner tier. A `403` on the REST V3 calls usually means `com.intuit.quickbooks.accounting` is missing. A `403` on the create-definition mutation means the unsuffixed `app-foundations.custom-field-definitions` scope is missing (the `.read` scope alone won't work for writes). + +## Configuration + +All values are sourced from `discover/prompt-config.json` (or `prompt-config-existing.json` for drop-in mode). Custom-fields-specific keys: + +| Key | What it controls | Default | +|---|---|---| +| `type_of_transaction` | Transaction type the custom fields attach to (also passed as GraphQL `subType` filter) | `salesreceipt` | +| `custom_field_documentation` | Docs URL injected into the prompt | Custom Fields workflow page | +| `custom_field_scope_read` | OAuth scope for Task 1/3 (read) | `app-foundations.custom-field-definitions.read` | +| `custom_field_scope_readwrite` | OAuth scope for Task 4 (write) | `app-foundations.custom-field-definitions` | +| `custom_field_definitions_query` | GraphQL query for fetching active definitions (includes `dropDownOptions`, `associations`, cursor variable) | `query GetCustomFieldDefinitions($cursor: String) { ... }` | +| `custom_field_create_definition_mutation` | GraphQL mutation for Task 4 | `mutation CreateCustomFieldDefinition($input: ...) { ... }` | +| `custom_field_create_definition_variables_example` | Example variables JSON for Task 4 | A `STRING` definition associated with `"transaction"` | +| `custom_field_payload_structure` | JSON shape of the `CustomField` array in the REST payload (string-typed example) | `[ { "DefinitionId": ..., "Type": "StringType", "StringValue": ... } ]` | +| `custom_field_transaction_creation_instructions` | Free-form instructions for Task 2 | Defaults to creating with item id 1, customer 1, amount 111 | +| `minorversion` | REST V3 minor version | `75` | + +Shared keys (also used by Dimensions, Projects, and Sales Tax prompts): + +- `language_framework`, `typing_system`, `integration_mode` +- `graphql_endpoint_production`, `graphql_schema` (the Custom Fields GraphQL API has no sandbox; `graphql_endpoint_sandbox` is unused here) +- `rest_baseurl_production`, `rest_baseurl_sandbox` (REST V3 sandbox is fine for Tasks 2/3 entity payloads) +- `transaction_v3_api_endpoint`, `get_transaction_endpoint`, `rest_v3_api_documentation` +- `java_sdk_version`, `java-sdk-documentation`, `oauth2-documentation`, `php-sdk-documentation` +- `custom_field_sample_app_java`, `custom_field_sample_app_python` + +## Supported Entities + +Custom Fields support varies by entity. Confirmed entities for use with this prompt: + +**Transactions:** `Invoice` · `SalesReceipt` · `Estimate` · `CreditMemo` · `RefundReceipt` · `Bill` · `Purchase` · `VendorCredit` · `PurchaseOrder` · `JournalEntry` + +**Entities:** `Customer` · `Vendor` + +Always confirm support for your specific entity against the [Custom Fields API documentation](https://developer.intuit.com/app/developer/qbo/docs/workflows/create-custom-fields) before generating code. + +## Integration Mode (`new` vs. `existing`) + +Same as Dimensions, Projects, and Sales Tax — controlled by `integration_mode` in the config: + +| Mode | What the AI produces | +|---|---| +| `new` | Self-contained folder `custom-fields-/` with README, dependency manifest, and architecture diagram showing GraphQL discovery → legacyIDV2 bridge → REST creation → hydration | +| `existing` | Drop-in modules with integration notes — no new folder created | + +Use `prompt-config-existing.json` (or set `integration_mode: "existing"` in your config) when pasting the prompt into an existing codebase. + +## Anti-Hallucination Guardrails + +The prompt enforces these hard rules on the AI: + +1. **No invented endpoints, GraphQL field names, or `DefinitionId`s** — derive everything from provided docs and the live query +2. **`legacyIDV2` is the REST bridge** — never use the GraphQL `id` as `DefinitionId` +3. **Production-only GraphQL** — the Custom Fields GraphQL API has no sandbox; don't generate a `QBO_ENV=sandbox` branch for Tasks 1, 4, or 5. REST V3 in Tasks 2/3 still has a sandbox option, but the CF discovery hits production GraphQL either way. +4. **GraphQL filter shape** — argument is `filters` (plural) with primitive fields (no `{ equals: }` wrappers); node fields are lowercase `id` and don't include `subType` +5. **REST URL must include `&include=enhancedAllCustomFields`** on every create/read of an entity with custom fields +6. **Provided links only** — no external API references +7. **GraphQL errors on 200** — always inspect the `errors` array, not just the HTTP status +8. **Strict SDK usage** — SDKs handle REST (Tasks 2 & 3) only; Task 1 GraphQL discovery uses a plain HTTP client because no official SDK supports GraphQL today +9. **Type-aware value mapping** — read `dataType` from GraphQL, write the matching value field in REST V3; the published enum does not include `BOOLEAN`, but the AI should follow the live schema if it differs +10. **Stop if blocked** — state what's missing instead of guessing + +These are enforced by the "🛑 AI Guardrails" section at the bottom of the template. + +## Per-Language SDK Notes (`sdk-notes/`) + +Language-specific SDK guidance injected into the template via the `{{sdk_notes}}` placeholder. Tells the AI exactly which SDK classes/methods to use so it doesn't hallucinate fake method signatures. + +**No SDK supports GraphQL today** — every language note directs Task 1 (GraphQL discovery) to a plain HTTP client. SDK-backed languages use the SDK for Tasks 2 & 3 (REST V3 creation and read). + +| Language | File | Strategy | +|---|---|---| +| Java | `sdk-notes/java.md` | Task 1: Apache HttpClient. Tasks 2 & 3: `ipp-v3-java-devkit` (`DataService.add()` + `CustomField` model) | +| .NET | `sdk-notes/dotnet.md` | Task 1: `System.Net.Http.HttpClient`. Tasks 2 & 3: `IppDotNetSdkForQuickBooksApiV3` (`DataService.Add()` + `CustomField` model) | +| PHP | `sdk-notes/php.md` | Task 1: `GuzzleHttp\Client`. Tasks 2 & 3: `quickbooks/v3-php-sdk` (`DataService::Add()` + `IPPCustomField` model) | +| Node.js | `sdk-notes/nodejs.md` | Plain HTTP throughout — `axios` or native `fetch` (no official Node SDK) | +| Python | `sdk-notes/python.md` | Plain HTTP throughout — `requests` (no official Python SDK) | +| Ruby | `sdk-notes/ruby.md` | Plain HTTP throughout — `Net::HTTP` or `faraday` (community gem coverage uneven) | + +**Fallback:** Any language without a matching file falls back to a generic "use plain HTTP" message generated by `merge-prompt.js:loadSdkNotes()`. Unsupported languages (Rust, Go, Kotlin, etc.) still produce valid prompts. + +**To add a new language:** +1. Create `sdk-notes/.md` following the structure of the existing files (Task 1 GraphQL → Task 2 REST create → Task 3 REST read → install) +2. Run `node merge-prompt.js --language ` from `discover/` + +## File Structure + +``` +custom-fields/ +├── README.md # This file +├── prompt-template-custom-fields.md # The template — {{placeholder}} tokens get substituted at merge time +└── sdk-notes/ # Per-language SDK guidance injected via {{sdk_notes}} + ├── java.md + ├── dotnet.md + ├── php.md + ├── nodejs.md + ├── python.md + └── ruby.md +``` + +## Common Errors + +| Code | Most Likely Cause | +|---|---| +| `400` on GraphQL with `GRAPHQL_VALIDATION_FAILED` | Selecting fields that don't exist on the schema. Common mistakes: `Id` / `Value` (capital) on `dropDownOptions` — the schema uses lowercase `id` / `value`; or using `filter` (singular) instead of `filters` (plural); or wrapping primitive filter fields in `{ equals: ... }` predicates | +| `400` on REST V3 | Using the GraphQL `id` instead of `legacyIDV2` as `DefinitionId`, populating the wrong value field for the `dataType`, omitting `?include=enhancedAllCustomFields` from the URL, or exceeding the entity's custom-field cap (see tier table) | +| `401` (XML body) | Expired access token — refresh in the [OAuth Playground](https://developer.intuit.com/app/developer/playground). Note: gateway returns XML for auth failures, not JSON. | +| `403` on GraphQL | Missing `app-foundations.custom-field-definitions.read` scope, Custom Fields disabled on the company, or app is on Builder tier (premium API requires Silver+) | +| `403` on REST V3 | Missing `com.intuit.quickbooks.accounting` scope | +| **`200` with `data: null` and `errors[].extensions.errorCode.errorCode = "AUTHORIZATION_DENIED"`** | This is what a missing **write** scope (Task 4) looks like in practice — NOT a clean 403. The `errors[].message` is `"access denied"`, `extensions.service` is `"custom-extensions-service"`. Re-auth the app with the unsuffixed `app-foundations.custom-field-definitions` scope. | +| `200` with other `errors` | GraphQL partial failure — always inspect the response body, even on HTTP 200 | +| Empty definitions response when you expected results | Most likely a **tier mismatch** (your target entity isn't supported on this QBO tier — see capability table). Less common: actually no definitions configured. Empirically, `subType` and `entityType` filters are unreliable for narrowing — the prompt fetches all active definitions and filters client-side. | +| Custom field values come back wrong on read | Generated code is reading `StringValue` for every field. Verify Task 3's type-aware reading branch is actually consulting the cached `dataType` before choosing a value field | +| Custom field metadata missing on REST V3 read | The URL is missing `?include=enhancedAllCustomFields` — add it to every create and read. Without the flag, the API returns only a legacy `DefinitionId: "1"` placeholder (mapped to the legacy 3-string-fields slot) with no `StringValue`. Verified in production. | +| HTTP 200 on create but `CustomField` array empty or missing entries | The `DefinitionId` isn't associated with the target entity type (e.g., a definition created for `Invoice` was sent on a `SalesReceipt`). REST V3 silently drops mismatched entries with no error. Use a `DefinitionId` whose definition's `associations[].associatedEntity` matches the target entity (either the specific path like `/transactions/Invoice` or the generic `/transactions/Transaction`). | + +### Empirical quirks (verified against production, May 2026) + +| Behavior | Implication | +|---|---| +| `dropDownOptions { Id Value ... }` — wrong case | Schema rejects with `GRAPHQL_VALIDATION_FAILED`. Use lowercase `id` and `value`. The doc's section headers misled prompt authors. | +| `subType: "salesreceipt"` returns 0 results even when matching definitions exist | The server-side `subType` filter is too narrow. Fetch all active definitions (`filters: { active: true }`) and filter client-side on `associations[].associatedEntity`. | +| `entityType: "transaction"` returns 0 results | Schema expects path-style values like `"/transactions/Transaction"` — not the simple `"transaction"` shown in the doc. Same workaround: filter client-side. | +| `associations[].associatedEntity` returns path-style strings | **Verified live (prod):** the PARENT `associatedEntity` is one of `"/transactions/Transaction"`, `"/network/Contact"`, `"/work/Project"`. The specific type lives in `subAssociations[].associatedEntity` as an UPPER_SNAKE code — e.g. `SALE`/`SALE_INVOICE`/`SALE_ESTIMATE` under `/transactions/Transaction`, and `CUSTOMER`/`VENDOR` under `/network/Contact`. There is NO `/Customer`, `/Vendor`, `/transactions/Invoice`, or `/transactions/SalesReceipt` parent value — filter client-side on the parent path AND the sub-association code. | +| `dropDownOptions: []` (empty array) for non-list types | Don't treat as missing data. Only validate against `dropDownOptions` when `dataType` is `STRING_LIST` or `OBJECT_LIST`. | +| Task 4 (create) without write scope returns HTTP 200 with `errors[].extensions.errorCode.errorCode = "AUTHORIZATION_DENIED"` | Not a gateway 403. Match on the `errorCode` and `service: "custom-extensions-service"` to detect this case. | + +## Empty State + +If no active definitions exist for the configured `type_of_transaction`, the generated code surfaces: + +> *"No active Custom Field definitions found for ``. Please configure them in QuickBooks settings."* + +…and halts cleanly without attempting the REST create. + +## Reference Materials + +Official artifacts to crib from when building your integration: + +- **Java / Spring Boot sample app:** https://github.com/IntuitDeveloper/SampleApp-CustomFields-Java +- **Python sample app:** https://github.com/IntuitDeveloper/SampleApp-CustomFields-Python +- **Premium APIs Postman workspace:** https://www.postman.com/intuit-developer/intuit-developer-premium-apis/overview +- **GraphQL schema reference:** [appFoundationsCustomFieldDefinitions](https://developer.intuit.com/app/developer/gql/docs/api/qbexternal/queries/appFoundationsCustomFieldDefinitions) · [Create mutation](https://developer.intuit.com/app/developer/gql/docs/api/qbexternal/mutations/appFoundationsCreateCustomFieldDefinition) · [Update mutation](https://developer.intuit.com/app/developer/gql/docs/api/qbexternal/mutations/appFoundationsUpdateCustomFieldDefinition) + +The sample apps use `ipp-v3-java-devkit` (Java) and `requests` (Python) for REST V3, and plain HTTP for the GraphQL operations — same split documented in the per-language SDK notes. + +## Related + +- Parent docs: [`../README.md`](../README.md) +- Template source: [`prompt-template-custom-fields.md`](./prompt-template-custom-fields.md) +- Merge script: [`../merge-prompt.js`](../merge-prompt.js) +- Generated output: [`../generated-prompts/custom-fields-ready-prompt.md`](../generated-prompts/) diff --git a/discover/custom-fields/prompt-template-custom-fields.md b/discover/custom-fields/prompt-template-custom-fields.md index 973cbb8..b01b73a 100644 --- a/discover/custom-fields/prompt-template-custom-fields.md +++ b/discover/custom-fields/prompt-template-custom-fields.md @@ -8,6 +8,15 @@ - OAuth 2.0 documentation: `{{oauth2-documentation}}` - Official sample apps: `{{custom_field_sample_app_java}}` · `{{custom_field_sample_app_python}}` +**Hosts:** +- **GraphQL endpoint** (Tasks 1, 4, 5 — Custom Field *definitions*). This API is **production-only**, so always use the production host regardless of `QBO_ENV`: + - Production: `{{graphql_endpoint_production}}` +- **REST V3 base URL** (Tasks 2, 3 — attaching/reading custom field *values* on a transaction). These honor `QBO_ENV`: + - Production: `https://{{rest_baseurl_production}}` + - Sandbox: `https://{{rest_baseurl_sandbox}}` + +REST V3 paths below (e.g. `POST /v3/company/{{companyid}}/...`) are relative to the REST base URL. Do not modify either host. + --- ## Task 1: Discover Custom Field Definitions (GraphQL) @@ -140,7 +149,7 @@ Invalidate the Task 1 cache after a successful update. --- -## Technical Best Practices: +## Technical Best Practices - **Caching:** Cache the Task 1 definition map for 1 hour. Invalidate after Task 4 or Task 5. - **Error Handling:** Include blocks for: @@ -157,11 +166,17 @@ Invalidate the Task 1 cache after a successful update. --- +## Language-Specific SDK Notes + +{{sdk_notes}} + +--- + ## 🛑 AI Guardrails (Anti-Hallucination Constraints) **CRITICAL INSTRUCTIONS - YOU MUST ADHERE TO THE FOLLOWING:** 1. **No Hallucinations:** Do not invent, guess, or hallucinate API endpoints, GraphQL properties, or SDK methods that are not explicitly provided in the context or linked documentation. -2. **Strict SDK/Library Usage:** If an official SDK or library is specified (e.g., Intuit Java SDK), use ONLY the methods and classes that exist in its latest public release. Do not construct fake SDK models. For Custom Fields specifically: GraphQL (Tasks 1, 4, 5) has no SDK — use a plain HTTP client. For REST V3 (Tasks 2 & 3), the deciding constraint is the mandatory `include=enhancedAllCustomFields` URL parameter: use the SDK's create/read methods (`DataService.add()` / `findById()` in Java, `dataService.Add()` in .NET) **only when the SDK exposes a public hook to attach that parameter**. If it does not — as with the Python and Node.js SDKs — do not force it through the SDK: build the request body as a plain object with PascalCase keys and POST via plain HTTP with `&include=enhancedAllCustomFields` on the URL. +2. **Strict SDK/Library Usage:** If an official SDK or library is specified (e.g., Intuit Java SDK), use ONLY the methods and classes that exist in its latest public release. Do not construct fake SDK models. For Custom Fields specifically: GraphQL (Tasks 1, 4, 5) has no SDK — use a plain HTTP client. For REST V3 (Tasks 2 & 3), use the SDK's create/read methods (`DataService.add()` / `findById()` in Java, `dataService.Add()` in .NET, `entity.save(qb=client, params=...)` in Python). Each SDK has a language-specific way to attach the `enhancedAllCustomFields` include parameter; see the language-specific SDK notes for the exact mechanism. (Note: the Node.js SDK has no public hook for this parameter — for Node, build the request body as a plain object with PascalCase keys and POST via plain HTTP with `&include=enhancedAllCustomFields` on the URL.) 3. **Provided Links Only:** You must derive all API syntax, structure, and constraints strictly from the provided links. All HTTP responses (GraphQL and REST) must be parsed according to the provided documentation. 4. **Endpoint Strictness:** Use the exact endpoints and query structures provided. Do not attempt to modify the base URL, append unsupported parameters, or alter the `minorversion={{minorversion}}` requirement. 5. **If Blocked/Missing Info:** If the provided documentation or payload structures lack required fields to compile a functional request, STOP and clearly state what specific information is missing instead of making an educated guess. diff --git a/discover/custom-fields/sdk-notes/dotnet.md b/discover/custom-fields/sdk-notes/dotnet.md new file mode 100644 index 0000000..a3ff1e7 --- /dev/null +++ b/discover/custom-fields/sdk-notes/dotnet.md @@ -0,0 +1,90 @@ +**If generating .NET / C# code (language: `{{language_framework}}`):** + +Use **`System.Net.Http.HttpClient`** for Task 1 (GraphQL discovery — no SDK supports GraphQL) and the official **`IppDotNetSdkForQuickBooksApiV3`** for Tasks 2 & 3 (REST V3). + +- **Task 1 — GraphQL discovery (plain HTTP):** + - The Intuit .NET SDK does **not** support GraphQL. Use `System.Net.Http.HttpClient` (part of the .NET standard library — no extra package needed) to POST the `appFoundationsCustomFieldDefinitions` query to `{{graphql_endpoint_production}}` (or `{{graphql_endpoint_sandbox}}` when `QBO_ENV=sandbox`). + - Send the argument as **`filters`** (plural — `AppFoundations_CustomExtensionsDefinitionFilterBy` input type) with primitive fields: `{ active: true }`. Do not use `{ equals: ... }` predicate wrappers. Filter by target entity client-side after the response — see the main prompt's Task 1 for the `associations[].associatedEntity` + `subAssociations[].associatedEntity` filtering logic. + - Parse the JSON response and build a `Dictionary` keyed by `legacyIDV2` (NOT the GraphQL `id`). Store `label` and `dataType` per entry. The GraphQL node field is lowercase `id`. + - Handle pagination via `pageInfo.endCursor` / `pageInfo.hasNextPage`. + +- **Task 2 — REST V3 entity creation (SDK):** + + ### Setup — build ServiceContext, add include param, construct DataService + + ```csharp + using Intuit.Ipp.Core; + using Intuit.Ipp.Security; + using Intuit.Ipp.DataService; + using Intuit.Ipp.Data; + + string raw = token.StartsWith("Bearer ") ? token.Substring(7) : token; + var validator = new OAuth2RequestValidator(raw); + var serviceContext = new ServiceContext(realmId, IntuitServicesType.QBO, validator); + serviceContext.IppConfiguration.MinorVersion.Qbo = "{{minorversion}}"; + + // Add the include parameter BEFORE constructing DataService so every call uses it. + // Public, supported API on ServiceContext.Include — verified in the SDK's IncludeParamTest.cs. + serviceContext.Include.Add("enhancedAllCustomFields"); + + var dataService = new DataService(serviceContext); + ``` + + ### Build the entity with typed POJOs + + - The entity class matching `{{type_of_transaction}}` from the `Intuit.Ipp.Data` namespace (e.g., `Invoice`, `SalesReceipt`, `Estimate`, `Bill`, `Customer`, `Vendor`) — the entity object + - `Intuit.Ipp.Data.CustomField` — one instance per custom field + - `DefinitionId` — pass the **`legacyIDV2`** from Task 1, **not** the GraphQL `id` + - `Name` — the `label` from Task 1 (recommended for readability) + - `Type` — map Task 1's `dataType`: `STRING` / `STRING_LIST` / `OBJECT_LIST` → `CustomFieldTypeEnum.StringType`, `NUMBER` → `CustomFieldTypeEnum.NumberType`, `DATE` → `CustomFieldTypeEnum.DateType`. The published enum does not list `BOOLEAN`; if the live schema returns one, follow the schema. Skip `UNKNOWN` with a logged warning. + - `AnyIntuitObject` (typed as `object`) — the value to attach. Verified against the .NET SDK's compiled `Intuit.Ipp.Data.dll`: `CustomField` exposes exactly four properties (`DefinitionId`, `Name`, `Type`, `AnyIntuitObject`). There are **no** typed `StringValue` / `NumberValue` / `DateValue` properties — the XML-choice serialization picks the right element name based on the runtime type you assign: + - `string` → serializes as `` (for `STRING` / `STRING_LIST` / `OBJECT_LIST`) + - `decimal` → serializes as `` (for `NUMBER`) + - `DateTime` → serializes as `` (for `DATE`, value is `YYYY-MM-DD`) + - `bool` → serializes as `` (rarely surfaced) + + Set exactly one runtime type per `CustomField` (the XSD declares this as `xs:choice` with `minOccurs="1"`). Example: + ```csharp + var cf = new CustomField { + DefinitionId = definition.LegacyIDV2, // legacyIDV2, NOT GraphQL id + Name = definition.Label, + Type = CustomFieldTypeEnum.StringType, + AnyIntuitObject = "Demo value" // string → StringValue element + }; + ``` + - Attach the `CustomField[]` array to the entity's `CustomField` property, then call `dataService.Add(entity)`. Because `Include.Add("enhancedAllCustomFields")` was set on the ServiceContext at setup time, the response will carry the full CustomField metadata for round-trip verification. + + ### Silent-drop detection + + REST V3 silently drops `CustomField` entries whose `DefinitionId` doesn't match the target entity's sub-association exactly. After `dataService.Add(...)` returns, compare the response's `CustomField` array against your sent list and warn on any missing entries. Most common cause: a `SALE_INVOICE`-scoped definition will not attach to a SalesReceipt (`SALE`-scoped) — and vice versa. + + ### Error handling + + `DataService.Add()` throws on API failures (the SDK's exception hierarchy lives under `Intuit.Ipp.Exception`). Inspect `e.Message` and any inner exception. For `intuit_tid` correlation, enable the SDK's request/response logger on `serviceContext.IppConfiguration.Logger.RequestLog` (writes to disk or stream) — the SDK does not expose `intuit_tid` as a direct exception-getter property. + +- **Task 3 — Type-aware hydration (SDK):** + - Reuse the same `serviceContext` (with `Include.Add("enhancedAllCustomFields")` already applied) and `DataService` from Task 2. Build a probe with the Id set: + ```csharp + var probe = new SalesReceipt { Id = createdId }; + var fetched = dataService.FindById(probe); + ``` + - The REST response's `DefinitionId` equals the `legacyIDV2` you stored in Task 1 — use it to look up `label` and `dataType` from your cached map. + - Use the cached `dataType` to cast `AnyIntuitObject` to the right runtime type. **Reminder:** there are no typed getters like `cf.StringValue` on the SDK class — read `cf.AnyIntuitObject` and cast: + ```csharp + object value = rcf.AnyIntuitObject; + object typed = meta.DataType switch { + "STRING" or "STRING_LIST" or "OBJECT_LIST" => (string)value, + "NUMBER" => (decimal)value, + "DATE" => (DateTime)value, + _ => null + }; + ``` + - Filter the entity's `Line` array to entries where `DetailType == LineDetailTypeEnum.SalesItemLineDetail`. Exclude `SubTotalLineDetail`, `DiscountLineDetail`, `TaxLineDetail`. + +- **NuGet install:** + ``` + Install-Package IppDotNetSdkForQuickBooksApiV3 + ``` +- **SDK reference:** `{{dotnet-sdk-documentation}}` + +> Warning: Use **only** methods and classes that exist in the published SDK. Do not construct fake SDK models or invent method signatures. diff --git a/discover/custom-fields/sdk-notes/java.md b/discover/custom-fields/sdk-notes/java.md new file mode 100644 index 0000000..bc581aa --- /dev/null +++ b/discover/custom-fields/sdk-notes/java.md @@ -0,0 +1,234 @@ +**If generating Java code (language: `{{language_framework}}`):** + +Use the **Intuit Java SDK** (`ipp-v3-java-devkit`) **v{{java_sdk_version}}** for Tasks 2 and 3, and **Apache HttpClient + Jackson** for Task 1 (the SDK has no GraphQL support). The SDK supports `enhancedAllCustomFields` via a public method on `Context` — verified live against production with end-to-end round-trip of Invoice, SalesReceipt, and Customer custom-field writes. + +> **SDK version:** `6.7.0` is the latest. `6.5.2` / `6.5.3` work identically for custom fields (same `setIncludeParam` API, same typed CustomField setters). Prefer the latest unless your project pins an earlier version. + +--- + +## Task 1 — GraphQL discovery (plain HTTP + Jackson) + +The Intuit Java SDK does not support GraphQL. Use `CloseableHttpClient` from Apache HttpClient (a transitive dependency of `ipp-v3-java-devkit`) to POST the `appFoundationsCustomFieldDefinitions` query to `{{graphql_endpoint_production}}` (production only — GraphQL has no sandbox endpoint). + +- Build the request body as a `Map` with keys `query` and `variables`. Serialize with `objectMapper.writeValueAsString(body)`. +- Send the argument as **`filters`** (plural — `AppFoundations_CustomExtensionsDefinitionFilterBy` input type) with primitive fields: `{ active: true }`. Do not use `{ equals: ... }` predicate wrappers. Filter by target entity client-side after the response. +- Parse with Jackson (`objectMapper.readTree(body)` then walk via `JsonNode`). Build a `Map` keyed by `legacyIDV2` (NOT the GraphQL `id`). Store `label` and `dataType` per entry. The GraphQL node field is lowercase `id`. +- Handle pagination via `pageInfo.endCursor` / `pageInfo.hasNextPage`. + +--- + +## Task 2 — REST V3 entity creation (SDK) + +The SDK's `DataService.add()` handles serialization correctly when the Context has the include parameter set. Hold the `Context` reference yourself so you can call `setIncludeParam` on it directly — `DataService.getContext()` is private in the SDK, so reflection is the only alternative, and it isn't necessary if you keep the reference. + +### Setup + +```java +import com.intuit.ipp.core.Context; +import com.intuit.ipp.core.ServiceType; +import com.intuit.ipp.security.OAuth2Authorizer; +import com.intuit.ipp.services.DataService; + +String raw = token.startsWith("Bearer ") ? token.substring(7) : token; +Context context = new Context(new OAuth2Authorizer(raw), ServiceType.QBO, realmId); +context.setMinorVersion("{{minorversion}}"); + +// Set BEFORE constructing DataService so every call on this DataService uses it. +context.setIncludeParam(java.util.List.of("enhancedAllCustomFields")); + +DataService dataService = new DataService(context); +``` + +### Build the entity with typed POJOs + +```java +import com.intuit.ipp.data.SalesReceipt; // or Invoice / Estimate / Bill / Customer / Vendor — match {{type_of_transaction}} +import com.intuit.ipp.data.Line; +import com.intuit.ipp.data.LineDetailTypeEnum; +import com.intuit.ipp.data.SalesItemLineDetail; +import com.intuit.ipp.data.ReferenceType; +import com.intuit.ipp.data.CustomField; +import com.intuit.ipp.data.CustomFieldTypeEnum; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +SalesReceipt sr = new SalesReceipt(); + +ReferenceType custRef = new ReferenceType(); +custRef.setValue("1"); +sr.setCustomerRef(custRef); + +Line line = new Line(); +line.setAmount(new BigDecimal("111")); +line.setDetailType(LineDetailTypeEnum.SALES_ITEM_LINE_DETAIL); +SalesItemLineDetail sild = new SalesItemLineDetail(); +ReferenceType itemRef = new ReferenceType(); +itemRef.setValue("1"); +sild.setItemRef(itemRef); +line.setSalesItemLineDetail(sild); +sr.getLine().add(line); + +CustomField cf = new CustomField(); +cf.setDefinitionId(definition.legacyIDV2()); // legacyIDV2 from Task 1, NOT the GraphQL id +cf.setName(definition.label()); // optional but recommended +cf.setType(CustomFieldTypeEnum.STRING_TYPE); // STRING_TYPE / NUMBER_TYPE / DATE_TYPE based on Task 1's dataType +cf.setStringValue("Demo value"); // pick exactly ONE typed setter (see below) +// Use a mutable list if downstream code may add fields. +sr.setCustomField(new ArrayList<>(List.of(cf))); + +SalesReceipt created = dataService.add(sr); +String createdId = created.getId(); +``` + +### Typed value setters on `CustomField` + +Verified against `ipp-v3-java-data:{{java_sdk_version}}`: + +- `setStringValue(String)` — for `STRING_TYPE` (used when Task 1's `dataType` is `STRING`, `STRING_LIST`, or `OBJECT_LIST`) +- `setNumberValue(BigDecimal)` — for `NUMBER_TYPE` (used when `dataType` is `NUMBER`) +- `setDateValue(java.util.Date)` — for `DATE_TYPE` (used when `dataType` is `DATE`) +- `setBooleanValue(Boolean)` — for `BooleanType` (rarely surfaced by the GraphQL schema) + +Set **exactly one** typed value field per `CustomField`. There is no `setAnyIntuitObject(Object)` method on `CustomField` — do not call one. + +For `STRING_LIST` / `OBJECT_LIST`, validate `StringValue` against the definition's active `dropDownOptions[].value` set (from Task 1) before sending. Mismatched values are rejected by the API. + +### Silent-drop detection + +REST V3 silently drops `CustomField` entries whose `DefinitionId` doesn't match the target entity's sub-association exactly. After `dataService.add(...)` returns, compare `created.getCustomField()` against your sent list and warn on any missing entries. Most common cause: a `SALE_INVOICE`-scoped definition will not attach to a SalesReceipt (`SALE`-scoped) — and vice versa. + +### Error handling — `FMSException` + +`DataService.add()` throws `com.intuit.ipp.exception.FMSException` on API failures. Important behaviors: + +- `e.getErrorList()` — list of `com.intuit.ipp.data.Error` objects with code, message, detail. Code `6240` = duplicate name (most common on Customer creation). +- `e.getIntuit_tid()` — populated on failed calls. Log this for tracing alongside the `intuit_tid` you set on outbound requests. +- **Do NOT call `DataService.getLastRequestId()`** — that method does not exist in SDK 6.7.0. If you need the request id, use the `intuit_tid` you generated and set on the outbound request (the SDK forwards it). + +--- + +## Task 3 — Type-aware hydration (SDK) + +Reuse the same `Context` (with `setIncludeParam` already applied) and `DataService` from Task 2. The SDK's `findById` takes an entity "probe" with the Id set: + +```java +SalesReceipt probe = new SalesReceipt(); +probe.setId(createdId); +SalesReceipt fetched = dataService.findById(probe); + +for (CustomField rcf : fetched.getCustomField()) { + String defId = rcf.getDefinitionId(); // equals legacyIDV2 + DefinitionMeta meta = definitionMap.get(defId); + Object value = switch (meta.dataType()) { + case "STRING", "STRING_LIST", "OBJECT_LIST" -> rcf.getStringValue(); + case "NUMBER" -> rcf.getNumberValue(); + case "DATE" -> rcf.getDateValue(); + default -> null; + }; + // ...render label + value for the UI... +} +``` + +There is no `getAnyIntuitObject()` method on `CustomField` — use the four typed getters (`getStringValue`, `getNumberValue`, `getDateValue`, `isBooleanValue`). + +Filter the response's `Line` list to entries where `getDetailType() == LineDetailTypeEnum.SALES_ITEM_LINE_DETAIL`. Exclude system-generated lines (`SubTotalLineDetail`, `DiscountLineDetail`, `TaxLineDetail`). + +--- + +## Dependencies + +```xml + + com.intuit.quickbooks-online + ipp-v3-java-devkit + {{java_sdk_version}} + + + +``` + +Add `oauth2-platform-api:{{java_sdk_version}}` separately if you need to implement OAuth refresh (this prompt assumes the token in `.env` is already valid). + +--- + +## Required runtime essentials (don't forget these — they break silently) + +- **SLF4J + a binding.** `pom.xml` must declare both `org.slf4j:slf4j-api` AND a binding (`slf4j-simple` or `logback-classic`). Without a binding, `log.info` / `log.warn` calls silently no-op at runtime, which breaks the `intuit_tid` observability requirement. +- **`intuit_tid` per request.** Generate a new UUID and either pass it via `RequestElements` on the SDK call, or — when bypassing the SDK for Task 1 — set it as the `intuit_tid` request header. Log the response `intuit_tid` for log correlation with Intuit support. +- **`.env` loading robustness.** Use `io.github.cdimascio:dotenv-java`; configure `Dotenv.configure().directory().ignoreIfMissing().load()`. Don't rely on `Path.of(".env")` — that only resolves when cwd happens to be the project root. + +--- + +## Alternative: no-SDK approach (Map + plain HTTP) + +Use this only if you don't want the Intuit SDK dependency on your classpath (e.g. GraalVM native-image targets, projects already invested in `RestTemplate`-style HTTP). It's a valid working path that's been verified live against the same realm, but the SDK path above is simpler and gets you typed entities. + +Build the request body as a nested `Map` with PascalCase keys typed in directly, then POST via `CloseableHttpClient` or `java.net.http.HttpClient` with `&include=enhancedAllCustomFields` on the URL. **Do not** use the SDK's data classes plus Jackson with `JaxbAnnotationModule` — that produces camelCase JSON (`stringValue`, `customField`) which QBO REST V3 rejects. + +### Build the body + +```java +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.*; + +ObjectMapper mapper = new ObjectMapper(); +Map body = new HashMap<>(); + +List> lines = new ArrayList<>(); +Map line = new HashMap<>(); +line.put("Amount", 111.00); +line.put("DetailType", "SalesItemLineDetail"); +Map sild = new HashMap<>(); +Map itemRef = new HashMap<>(); +itemRef.put("value", "1"); +sild.put("ItemRef", itemRef); +line.put("SalesItemLineDetail", sild); +lines.add(line); +body.put("Line", lines); + +Map customerRef = new HashMap<>(); +customerRef.put("value", "1"); +body.put("CustomerRef", customerRef); + +List> customFields = new ArrayList<>(); +Map cf = new HashMap<>(); +cf.put("DefinitionId", definition.legacyIDV2()); +cf.put("Name", definition.label()); // optional but recommended +cf.put("Type", "StringType"); +cf.put("StringValue", "Demo value"); +customFields.add(cf); +body.put("CustomField", customFields); + +String requestJson = mapper.writeValueAsString(body); +``` + +POST to `https://quickbooks.api.intuit.com/v3/company//{{type_of_transaction}}?minorversion={{minorversion}}&include=enhancedAllCustomFields` (sandbox host when `QBO_ENV=sandbox`). + +Parse the response: `JsonNode entity = mapper.readTree(body).get("");` — e.g. `"SalesReceipt"` when `{{type_of_transaction}}` is `salesreceipt`. **QBO wraps the response in this top-level key; the request body must NOT have such a wrapper.** + +Hydrate by switching on `dataType` and reading `rcf.get("StringValue").asText()` / `.decimalValue()` / `.asText()` for `STRING` / `NUMBER` / `DATE`. + +### Alternative-path dependencies + +```xml + + com.fasterxml.jackson.core + jackson-databind + 2.17.1 + + + org.apache.httpcomponents + httpclient + 4.5.14 + +``` + +Or use `java.net.http.HttpClient` (built into JDK 11+) and skip the Apache dependency. diff --git a/discover/custom-fields/sdk-notes/nodejs.md b/discover/custom-fields/sdk-notes/nodejs.md new file mode 100644 index 0000000..28c339a --- /dev/null +++ b/discover/custom-fields/sdk-notes/nodejs.md @@ -0,0 +1,179 @@ +**If generating Node.js code (language: `{{language_framework}}`):** + +There is no Intuit-published entity SDK for Node.js. The official Intuit Custom Fields Node sample ([`IntuitDeveloper/Sampleapp-Customfields-Nodejs`](https://github.com/IntuitDeveloper/Sampleapp-Customfields-Nodejs)) uses **`graphql-request`** for GraphQL calls (Tasks 1, 4, 5) and **plain HTTP** (`axios` or native `fetch`) for everything else. There is also a community library `node-quickbooks` (by mcohen01) — **do not use it for this prompt** — its `module.request` honors only two hardcoded entity pseudo-fields (`allowDuplicateDocNum`, `requestId`) and exposes no public hook for adding query parameters like `include=enhancedAllCustomFields`. To send `include=enhancedAllCustomFields` via `node-quickbooks`, you'd need to fork or monkey-patch it. + +> **Note about the official Intuit Node sample:** that sample only implements Tasks 1, 4, and 5 (GraphQL definition CRUD). It does NOT implement Tasks 2 or 3 (creating an Invoice / SalesReceipt with attached custom-field values via REST V3). That's why no entity SDK appears in its `package.json` — those tasks need plain HTTP. + +## HTTP clients + +- **`graphql-request`** (recommended for Task 1 / 4 / 5) — `npm install graphql-request graphql`. Concise, handles the `query`/`variables` envelope automatically. +- **`axios`** (recommended for Tasks 2 / 3) — `npm install axios`. Used by the official Intuit sample. +- **native `fetch`** (Node 18+) — viable alternative to axios; no extra dep needed. +- **`intuit-oauth`** (optional) — `npm install intuit-oauth`. OAuth token flow only (login / exchange / refresh). NOT an entity SDK. Use only if you need to implement OAuth refresh; this prompt assumes the token in `.env` is already valid. + +--- + +## Task 1 — GraphQL discovery (`graphql-request`) + +```js +import { GraphQLClient, gql } from 'graphql-request'; + +const GRAPHQL_URL = 'https://qb.api.intuit.com/graphql'; // production-only + +const client = new GraphQLClient(GRAPHQL_URL, { + headers: { + Authorization: `Bearer ${process.env.QBO_ACCESS_TOKEN}`, + 'intuit_tid': crypto.randomUUID(), // unique per request + // QBO uses lowercase `realmId` for GraphQL; the official Intuit Node sample + // also tries `intuit-realm-id` — pass `realmId` to be safe. + realmId: process.env.QBO_REALM_ID, + }, +}); + +const QUERY = gql` + query ListDefinitions($filters: AppFoundations_CustomExtensionsDefinitionFilterBy!, $first: Int!, $after: String) { + appFoundationsCustomFieldDefinitions(filters: $filters, first: $first, after: $after) { + edges { node { id legacyIDV2 label dataType active dropDownOptions { id value active order } + associations { associatedEntity associationCondition allowedOperations + subAssociations { associatedEntity active allowedOperations } } } } + pageInfo { hasNextPage endCursor } + } + } +`; + +const data = await client.request(QUERY, { filters: { active: true }, first: 50, after: null }); +``` + +- Send the argument as **`filters`** (plural) with primitive fields: `{ active: true }`. Do not use `{ equals: ... }` predicate wrappers. Filter by target entity client-side after the response — see the main prompt's Task 1 for the `associations[].associatedEntity` + `subAssociations[].associatedEntity` filtering logic. +- Build a `Map` (or plain object) keyed by `legacyIDV2` (NOT the GraphQL `id`). Store `label`, `dataType`, and `dropDownOptions` per entry. The GraphQL node field is lowercase `id`. +- Handle pagination via `pageInfo.endCursor` / `pageInfo.hasNextPage` — loop until `hasNextPage` is `false`. + +--- + +## Task 2 — REST V3 entity creation (`axios` + plain JSON) + +QBO REST V3 expects PascalCase keys on the request body. Build a plain JS object with PascalCase keys and POST via axios. **No SDK is involved.** + +```js +import axios from 'axios'; + +const BASE_REST = process.env.QBO_ENV === 'sandbox' + ? 'https://sandbox-quickbooks.api.intuit.com' + : 'https://quickbooks.api.intuit.com'; + +const body = { + Line: [{ + Amount: 111, + DetailType: 'SalesItemLineDetail', + SalesItemLineDetail: { ItemRef: { value: '1' } }, + }], + CustomerRef: { value: '1' }, + CustomField: [{ + DefinitionId: definition.legacyIDV2, // NOT the GraphQL `id` + Name: definition.label, // optional but recommended + Type: 'StringType', // see "Typed value fields" below + StringValue: 'Demo value', + }], +}; + +const resp = await axios.post( + `${BASE_REST}/v3/company/${process.env.QBO_REALM_ID}/salesreceipt`, + body, + { + params: { + minorversion: '{{minorversion}}', + include: 'enhancedAllCustomFields', // critical — without this, the response strips CustomField metadata + }, + headers: { + Authorization: `Bearer ${process.env.QBO_ACCESS_TOKEN}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + intuit_tid: crypto.randomUUID(), + }, + timeout: 30_000, + } +); + +const created = resp.data.SalesReceipt; // QBO wraps the response in the entity name +console.log('Created SalesReceipt id', created.Id); +``` + +### Typed value fields + +Pick exactly **one** value field per `CustomField` based on Task 1's `dataType`: + +| dataType | `Type` | Value field | Format | +|---|---|---|---| +| `STRING` / `STRING_LIST` / `OBJECT_LIST` | `"StringType"` | `StringValue` | string | +| `NUMBER` | `"NumberType"` | `NumberValue` | number | +| `DATE` | `"DateType"` | `DateValue` | `"YYYY-MM-DD"` | + +The published enum does not list `BOOLEAN`; if the live schema returns one, follow the schema. Skip `UNKNOWN` with a logged warning. Do not hardcode `StringValue` for every entry. + +For `STRING_LIST` / `OBJECT_LIST`, validate `StringValue` against the definition's active `dropDownOptions[].value` set (from Task 1) before sending. + +### Silent-drop detection + +REST V3 silently drops `CustomField` entries whose `DefinitionId` doesn't match the target entity's sub-association. After the POST returns, compare `created.CustomField` against the array you sent and warn on any missing entries. Most common cause: a `SALE_INVOICE`-scoped definition will not attach to a SalesReceipt (`SALE`-scoped) — and vice versa. + +--- + +## Task 3 — Type-aware hydration (`axios` + JSON) + +```js +const resp = await axios.get( + `${BASE_REST}/v3/company/${process.env.QBO_REALM_ID}/salesreceipt/${createdId}`, + { + params: { minorversion: '{{minorversion}}', include: 'enhancedAllCustomFields' }, + headers: { + Authorization: `Bearer ${process.env.QBO_ACCESS_TOKEN}`, + Accept: 'application/json', + intuit_tid: crypto.randomUUID(), + }, + } +); +const fetched = resp.data.SalesReceipt; + +for (const rcf of fetched.CustomField ?? []) { + const meta = defMap.get(rcf.DefinitionId); // DefinitionId equals legacyIDV2 + const value = + meta.dataType === 'NUMBER' ? rcf.NumberValue : + meta.dataType === 'DATE' ? rcf.DateValue : + rcf.StringValue; + // ...render label + value for the UI... +} +``` + +Filter `fetched.Line` to entries where `line.DetailType === 'SalesItemLineDetail'`. Exclude `SubTotalLineDetail`, `DiscountLineDetail`, `TaxLineDetail`. + +--- + +## Required runtime essentials + +- **`dotenv`** — `npm install dotenv`. Call `dotenv.config()` once at startup to load `.env`. Works regardless of cwd as long as `.env` is at the project root. +- **`intuit_tid` per request.** Generate via `crypto.randomUUID()` (Node 18+) and set on every outbound request header. Capture the response `intuit_tid` (`resp.headers['intuit_tid']`) and log it for support correlation. +- **Logging.** Use Node's built-in `console` or a library like `pino` / `winston`. No binding-required setup needed (unlike Java's SLF4J trap). + +--- + +## TypeScript typing (optional) + +If using TypeScript, declare: + +```ts +interface DefinitionMeta { + legacyIDV2: string; + label: string; + dataType: 'STRING' | 'STRING_LIST' | 'OBJECT_LIST' | 'NUMBER' | 'DATE' | 'UNKNOWN'; + dropDownOptions?: { id: string; value: string; active: boolean; order: number }[]; +} + +interface CustomField { + DefinitionId: string; + Name?: string; + Type: 'StringType' | 'NumberType' | 'DateType'; + StringValue?: string; + NumberValue?: number; + DateValue?: string; // YYYY-MM-DD +} +``` diff --git a/discover/custom-fields/sdk-notes/php.md b/discover/custom-fields/sdk-notes/php.md new file mode 100644 index 0000000..bcf217f --- /dev/null +++ b/discover/custom-fields/sdk-notes/php.md @@ -0,0 +1,36 @@ +**If generating PHP code (language: `{{language_framework}}`):** + +Use **`GuzzleHttp\Client`** (or PHP's built-in `curl`) for Task 1 (GraphQL discovery — no SDK supports GraphQL) and the official **`quickbooks/v3-php-sdk`** for Tasks 2 & 3 (REST V3). + +- **Task 1 — GraphQL discovery (plain HTTP):** + - The PHP SDK does **not** support GraphQL. Use `GuzzleHttp\Client` (or `curl`) to POST the `appFoundationsCustomFieldDefinitions` query to `{{graphql_endpoint_production}}` (or `{{graphql_endpoint_sandbox}}` when `QBO_ENV=sandbox`). + - Send the argument as **`filters`** (plural — `AppFoundations_CustomExtensionsDefinitionFilterBy` input type) with primitive fields: `{ active: true }`. Do not use `{ equals: ... }` predicate wrappers. Filter by target entity client-side after the response. + - Parse the JSON response and build an associative array keyed by `legacyIDV2` (NOT the GraphQL `id`). Store `label` and `dataType` per entry. The GraphQL node field is lowercase `id`. + - Handle pagination via `pageInfo.endCursor` / `pageInfo.hasNextPage`. + +- **Task 2 — REST V3 entity creation (SDK):** + - `QuickBooksOnline\API\DataService\DataService::Add()` — create the transaction or `Customer`/`Vendor` + - Entity class matching `{{type_of_transaction}}` (e.g., `IPPSalesReceipt`, `IPPInvoice`, `IPPCustomer`, `IPPVendor`) — the entity object. All inherit `$CustomField` (typed `IPPCustomField[]`) via `IPPIntuitEntity`. + - `IPPCustomField` — one instance per custom field. Verified against `intuit/QuickBooks-V3-PHP-SDK` (commit 98223c9, PR #572): + - `$DefinitionId` — pass the **`legacyIDV2`** from Task 1, **not** the GraphQL `id` + - `$Name` — the `label` from Task 1 (recommended for readability) + - `$Type` — map Task 1's `dataType`: `STRING` / `STRING_LIST` / `OBJECT_LIST` → `"StringType"`, `NUMBER` → `"NumberType"`, `DATE` → `"DateType"`. Skip `UNKNOWN` with a logged warning. + - Set exactly ONE typed value property based on `$Type`: `$StringValue` (string), `$NumberValue` (numeric), `$DateValue` (`YYYY-MM-DD`), or `$BooleanValue` (rarely surfaced). **There is no `AnyIntuitObject` property on `IPPCustomField`** — do not assign one. + - Attach the `IPPCustomField` array to the entity's `$CustomField` property before calling `->Add()`. + - **Attach the include parameter via the SDK's public API** — two equivalent options: + - Service-wide: `$dataService->setIncludeParam([\QuickBooksOnline\API\Core\CoreConstants::INCLUDE_ENHANCED_ALL_CUSTOM_FIELDS]);` once on setup, applies to every subsequent call. + - Per-call: `$dataService->Add($entity, [\QuickBooksOnline\API\Core\CoreConstants::INCLUDE_ENHANCED_ALL_CUSTOM_FIELDS])`. `Update()`, `FindById($entity, $id, $includeParam)`, and `Retrieve($entity, $includeParam)` all accept the same trailing parameter. + - Either form appends `?include=enhancedAllCustomFields` (or `&include=...`) to the URL the SDK posts. No plain-Guzzle fallback is required. + +- **Task 3 — Type-aware hydration (SDK):** + - Reuse the same `$dataService` (with `setIncludeParam` already applied) or pass `$includeParam` per-call on `$dataService->FindById($entity, $id, [...])`. + - Iterate the returned entity's `$CustomField` array. The REST response's `$DefinitionId` equals the `legacyIDV2` you stored in Task 1 — use it to look up `label` and `dataType` from your cached map. + - Use the cached `dataType` to read the matching typed property: `$rcf->StringValue`, `$rcf->NumberValue`, `$rcf->DateValue`. (No `AnyIntuitObject` accessor.) + +- **Composer install:** + ```bash + composer require quickbooks/v3-php-sdk + ``` +- **SDK reference:** `{{php-sdk-documentation}}` + +> Warning: Use **only** methods and classes that exist in the published SDK. Do not construct fake SDK models or invent method signatures. diff --git a/discover/custom-fields/sdk-notes/python.md b/discover/custom-fields/sdk-notes/python.md new file mode 100644 index 0000000..1728580 --- /dev/null +++ b/discover/custom-fields/sdk-notes/python.md @@ -0,0 +1,177 @@ +**If generating Python code (language: `{{language_framework}}`):** + +There is no official Intuit-published Python SDK, but the community-maintained **`python-quickbooks`** library (by `ej2`, v0.9.12+) is the de-facto standard. It has typed entity models, custom-field wiring on Invoice/SalesReceipt, and a public `params={}` hook on `.save()` / `.get()` that propagates to the QBO REST URL — so `params={'include': 'enhancedAllCustomFields'}` does the right thing. + +For Task 1 (GraphQL), use `requests` directly — no library supports the Custom Fields GraphQL API in Python. + +--- + +## Task 1 — GraphQL discovery (`requests`) + +- POST the `appFoundationsCustomFieldDefinitions` query to `{{graphql_endpoint_production}}` (production only — GraphQL has no sandbox endpoint) with `Content-Type: application/json`, `Authorization: Bearer `, and a unique `intuit_tid` per request for log correlation. +- Send the argument as **`filters`** (plural — `AppFoundations_CustomExtensionsDefinitionFilterBy` input type) with primitive fields: `{ active: true }`. Do not use `{ equals: ... }` predicate wrappers. Filter by target entity client-side after the response. +- Parse the JSON response and build a `dict` keyed by `legacyIDV2` (NOT the GraphQL `id`). Store `label` and `dataType` per entry. The GraphQL node field is lowercase `id`. +- Handle pagination via `pageInfo.endCursor` / `pageInfo.hasNextPage`. + +--- + +## Task 2 — REST V3 entity creation (`python-quickbooks`) + +### Setup + +```python +from intuitlib.client import AuthClient +from quickbooks import QuickBooks +from quickbooks.objects.invoice import Invoice +from quickbooks.objects.salesreceipt import SalesReceipt +from quickbooks.objects.base import CustomField, Ref +from quickbooks.objects.detailline import SalesItemLineDetail, SalesItemLine + +auth_client = AuthClient( + client_id=..., + client_secret=..., + environment='production', # or 'sandbox' — honors {{type_of_transaction}}-relevant REST V3 envs only + redirect_uri=..., +) +auth_client.access_token = access_token # from .env + +client = QuickBooks( + auth_client=auth_client, + refresh_token=refresh_token, # optional, only if you'll refresh + company_id=realm_id, + minorversion={{minorversion}}, +) +``` + +### Build the entity + +```python +sr = SalesReceipt() +sr.CustomerRef = Ref() +sr.CustomerRef.value = "1" + +line = SalesItemLine() +line.Amount = 111 +line.SalesItemLineDetail = SalesItemLineDetail() +line.SalesItemLineDetail.ItemRef = Ref() +line.SalesItemLineDetail.ItemRef.value = "1" +sr.Line.append(line) + +cf = CustomField() +cf.DefinitionId = definition["legacyIDV2"] # NOT the GraphQL `id` +cf.Name = definition["label"] # optional but recommended +cf.Type = "StringType" # pick from StringType / NumberType / DateType +cf.StringValue = "Demo value" # see "Typed value fields" below +sr.CustomField.append(cf) + +# Attach the include parameter via `params=` on .save(). +# This is the key call — without it, the response strips CustomField metadata. +sr.save(qb=client, params={'include': 'enhancedAllCustomFields'}) + +print("Created SalesReceipt id =", sr.Id) +``` + +### Typed value fields — important Python-only gap + +The `quickbooks.objects.base.CustomField` class in `python-quickbooks` 0.9.12 only declares **`StringValue`** as a typed attribute. **`NumberValue` and `DateValue` are NOT typed on the class** — to set them, assign them as regular attributes and they'll serialize correctly via `to_json()`: + +```python +# STRING / STRING_LIST / OBJECT_LIST — uses the typed StringValue field +cf.Type = "StringType" +cf.StringValue = "Demo value" + +# NUMBER — set NumberValue as a raw attribute (not part of the typed POJO) +cf.Type = "NumberType" +cf.NumberValue = 42.0 + +# DATE — set DateValue as a raw attribute, format YYYY-MM-DD +cf.Type = "DateType" +cf.DateValue = "2026-05-23" +``` + +Set **exactly one** value field per `CustomField`. For `STRING_LIST` / `OBJECT_LIST`, validate `StringValue` against the definition's active `dropDownOptions[].value` set (from Task 1) before sending. + +### Customer entity — Python-only gap + +`python-quickbooks` 0.9.12's `Customer` class does **NOT** declare a `CustomField` attribute, even though the QBO REST API supports custom fields on Customer. If you need Customer custom fields, you have three options: +1. Monkey-patch: `Customer.CustomField = []` and add it to `Customer.list_dict` before serialization. +2. Use the SDK-free path below for Customer specifically. +3. Submit a PR upstream. + +### Silent-drop detection + +REST V3 silently drops `CustomField` entries whose `DefinitionId` doesn't match the target entity's sub-association. After `.save()` returns (the SDK populates `self.CustomField` from the response), compare the returned `CustomField` list against the list you sent. Warn on any missing entries — most common cause: a `SALE_INVOICE`-scoped definition will not attach to a SalesReceipt (`SALE`-scoped) — and vice versa. + +--- + +## Task 3 — Type-aware hydration (`python-quickbooks`) + +```python +fetched = SalesReceipt.get(sr.Id, qb=client, params={'include': 'enhancedAllCustomFields'}) + +for rcf in fetched.CustomField: + def_id = rcf.DefinitionId # equals legacyIDV2 + meta = definition_map[def_id] + data_type = meta['dataType'] + if data_type in ('STRING', 'STRING_LIST', 'OBJECT_LIST'): + value = rcf.StringValue + elif data_type == 'NUMBER': + value = getattr(rcf, 'NumberValue', None) + elif data_type == 'DATE': + value = getattr(rcf, 'DateValue', None) + # ...render label + value for the UI... +``` + +Filter `fetched.Line` to entries where `line.DetailType == "SalesItemLineDetail"`. Exclude system-generated lines (`SubTotalLineDetail`, `DiscountLineDetail`, `TaxLineDetail`). + +--- + +## Dependencies + +``` +pip install python-quickbooks intuit-oauth +``` + +`intuit-oauth` provides `AuthClient` (the OAuth flow). Use it only if you need refresh; this prompt assumes the token in `.env` is already valid. + +--- + +## Alternative: no-SDK approach (`requests` + dicts) + +Use this when you don't want to add `python-quickbooks` to the project, or when you need Customer custom fields (which the SDK doesn't wire — see above). The pattern matches what the Node.js notes do: build the request body as a `dict` with PascalCase keys, POST via `requests.post(...)` with `params={'include': 'enhancedAllCustomFields'}`. + +```python +import requests, uuid + +body = { + "Line": [{ + "Amount": 111, + "DetailType": "SalesItemLineDetail", + "SalesItemLineDetail": {"ItemRef": {"value": "1"}}, + }], + "CustomerRef": {"value": "1"}, + "CustomField": [{ + "DefinitionId": definition["legacyIDV2"], + "Name": definition["label"], + "Type": "StringType", + "StringValue": "Demo value", + }], +} + +resp = requests.post( + f"https://quickbooks.api.intuit.com/v3/company/{realm_id}/{{type_of_transaction}}", + params={"minorversion": "{{minorversion}}", "include": "enhancedAllCustomFields"}, + headers={ + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + "Accept": "application/json", + "intuit_tid": str(uuid.uuid4()), + }, + json=body, + timeout=30, +) +resp.raise_for_status() +created = resp.json()["SalesReceipt"] # QBO wraps the response in the entity name +``` + +PascalCase keys, top-level entity for request, response wrapped under PascalCase entity name. Same shape on the wire as the SDK path. diff --git a/discover/custom-fields/sdk-notes/ruby.md b/discover/custom-fields/sdk-notes/ruby.md new file mode 100644 index 0000000..b9c5d70 --- /dev/null +++ b/discover/custom-fields/sdk-notes/ruby.md @@ -0,0 +1,157 @@ +**If generating Ruby code (language: `{{language_framework}}`):** + +Use the community-maintained **`quickbooks-ruby`** gem for Tasks 2 and 3, and Ruby's built-in **`Net::HTTP`** (or the **`faraday`** gem) for Task 1 (no SDK supports GraphQL). `quickbooks-ruby` has typed `CustomField` with all four value types, full wiring on `Invoice` and `SalesReceipt`, and a public `params:` argument on `fetch_by_id` / `query:` on `create` that propagates to the REST URL — so `params: { include: 'enhancedAllCustomFields' }` does the right thing. + +--- + +## Task 1 — GraphQL discovery (`Net::HTTP` / `faraday`) + +- POST the `appFoundationsCustomFieldDefinitions` query to `{{graphql_endpoint_production}}` (production only — GraphQL has no sandbox endpoint) with `Content-Type: application/json`, `Authorization: Bearer `, and a unique `intuit_tid` per request. +- Send the argument as **`filters`** (plural — `AppFoundations_CustomExtensionsDefinitionFilterBy` input type) with primitive fields: `{ active: true }`. Do not use `{ equals: ... }` predicate wrappers. Filter by target entity client-side after the response. +- Parse the JSON response and build a `Hash` keyed by `legacyIDV2` (NOT the GraphQL `id`). Store `label` and `dataType` per entry. The GraphQL node field is lowercase `id`. +- Handle pagination via `pageInfo.endCursor` / `pageInfo.hasNextPage`. + +--- + +## Task 2 — REST V3 entity creation (`quickbooks-ruby`) + +### Setup + +```ruby +require 'quickbooks-ruby' + +Quickbooks.minorversion = {{minorversion}} + +oauth_client = OAuth2::Client.new(client_id, client_secret, site: 'https://oauth.platform.intuit.com') +access_token = OAuth2::AccessToken.new(oauth_client, env['QBO_ACCESS_TOKEN']) + +service = Quickbooks::Service::SalesReceipt.new +service.company_id = realm_id +service.access_token = access_token +``` + +### Build the entity + +```ruby +sr = Quickbooks::Model::SalesReceipt.new +sr.customer_id = "1" + +line = Quickbooks::Model::SalesReceiptLineItem.new +line.amount = 111 +line.sales_item! do |sales_item| + sales_item.item_id = "1" +end +sr.line_items << line + +cf = Quickbooks::Model::CustomField.new +cf.id = definition['legacyIDV2'].to_i # legacyIDV2 from Task 1 (NOT the GraphQL id). + # quickbooks-ruby maps `DefinitionId` → `id` on the model. +cf.name = definition['label'] # optional but recommended +cf.type = "StringType" # pick StringType / NumberType / DateType / BooleanType based on dataType +cf.string_value = "Demo value" # see "Typed value fields" below +sr.custom_fields << cf + +# Attach the include parameter via `query:` in the `create` options. +created = service.create(sr, query: { include: 'enhancedAllCustomFields' }) + +puts "Created SalesReceipt id = #{created.id}" +``` + +### Typed value fields + +`Quickbooks::Model::CustomField` (in `lib/quickbooks/model/custom_field.rb`) exposes all four typed accessors. Set **exactly one** based on `dataType`: + +- `STRING` / `STRING_LIST` / `OBJECT_LIST` → `cf.string_value = "..."` +- `NUMBER` → `cf.number_value = 42` +- `DATE` → `cf.date_value = Date.parse("2026-05-23")` (a Ruby `Date`) +- `BooleanType` → `cf.boolean_value = true` (rarely surfaced) + +There is also a polymorphic `cf.value = ...` setter that picks the right typed field based on `cf.type`, but for clarity prefer the explicit typed accessor. + +For `STRING_LIST` / `OBJECT_LIST`, validate `string_value` against the definition's active `dropDownOptions[].value` set (from Task 1) before sending. + +### Customer entity — Ruby-only gap + +`quickbooks-ruby`'s `Customer` model does **NOT** wire `CustomField`, even though the QBO REST API supports it on Customer. If you need Customer custom fields: +1. Monkey-patch: `Quickbooks::Model::Customer.xml_accessor :custom_fields, from: 'CustomField', as: [Quickbooks::Model::CustomField]` +2. Or use the SDK-free path below for Customer specifically. + +### Silent-drop detection + +REST V3 silently drops `CustomField` entries whose `DefinitionId` doesn't match the target entity's sub-association. After `service.create(...)` returns, compare `created.custom_fields` against the list you sent. Warn on any missing entries. + +--- + +## Task 3 — Type-aware hydration (`quickbooks-ruby`) + +```ruby +fetched = service.fetch_by_id(created.id, include: 'enhancedAllCustomFields') + +fetched.custom_fields.each do |rcf| + def_id = rcf.id.to_s # equals legacyIDV2 + meta = definition_map[def_id] + value = case meta[:dataType] + when 'STRING', 'STRING_LIST', 'OBJECT_LIST' then rcf.string_value + when 'NUMBER' then rcf.number_value + when 'DATE' then rcf.date_value + end + # ...render label + value for the UI... +end +``` + +Filter `fetched.line_items` to entries where the underlying detail type is `SalesItemLineDetail`. The gem exposes detail-type checks via `line.sales_item?`, `line.subtotal?`, etc. — use those. + +--- + +## Dependencies + +```ruby +# Gemfile +gem 'quickbooks-ruby' +``` + +```bash +bundle install +``` + +--- + +## Alternative: no-SDK approach (`Net::HTTP` / `faraday` + Hash) + +Use this when you don't want the `quickbooks-ruby` dependency, or when you need Customer custom fields (which the SDK doesn't wire). Build the request body as a `Hash` with PascalCase keys, POST via `Net::HTTP` or `Faraday` with `include=enhancedAllCustomFields` on the URL. + +```ruby +require 'net/http' +require 'json' +require 'securerandom' + +body = { + Line: [{ + Amount: 111, + DetailType: "SalesItemLineDetail", + SalesItemLineDetail: { ItemRef: { value: "1" } } + }], + CustomerRef: { value: "1" }, + CustomField: [{ + DefinitionId: definition[:legacyIDV2], + Name: definition[:label], + Type: "StringType", + StringValue: "Demo value" + }] +} + +uri = URI("https://quickbooks.api.intuit.com/v3/company/#{realm_id}/{{type_of_transaction}}?minorversion={{minorversion}}&include=enhancedAllCustomFields") +req = Net::HTTP::Post.new(uri) +req['Authorization'] = "Bearer #{access_token}" +req['Content-Type'] = 'application/json' +req['Accept'] = 'application/json' +req['intuit_tid'] = SecureRandom.uuid +req.body = body.to_json + +resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) } +raise "QBO #{resp.code}: #{resp.body}" unless resp.code.to_i.between?(200, 299) + +created = JSON.parse(resp.body)['SalesReceipt'] # QBO wraps the response in the entity name +``` + +PascalCase keys for the request, response wrapped under PascalCase entity name. Same shape on the wire as the SDK path. diff --git a/discover/dimensions/prompt-template-dimensions.md b/discover/dimensions/prompt-template-dimensions.md index e95f54b..dbe1368 100644 --- a/discover/dimensions/prompt-template-dimensions.md +++ b/discover/dimensions/prompt-template-dimensions.md @@ -38,7 +38,9 @@ For each `definitionId` from Step A, call `appFoundationsActiveCustomDimensionVa --- ## Task 2: Dynamic Transaction Creation (REST V3) - + +> **Host:** Task 1 uses the GraphQL endpoint above; Task 2 is **REST V3** — prepend the REST base URL (Production `https://{{rest_baseurl_production}}`, Sandbox `https://{{rest_baseurl_sandbox}}`, select by `QBO_ENV`) to the `/v3/company/...` path below. Do not send REST calls to the GraphQL host. + {{transaction_creation_instructions}} ### Data flow for CustomExtensions: diff --git a/discover/generated-prompts/custom-fields-ready-prompt.md b/discover/generated-prompts/custom-fields-ready-prompt.md index a90ab73..96c40fe 100644 --- a/discover/generated-prompts/custom-fields-ready-prompt.md +++ b/discover/generated-prompts/custom-fields-ready-prompt.md @@ -8,6 +8,15 @@ - OAuth 2.0 documentation: `https://developer.intuit.com/app/developer/qbo/docs/develop/authentication-and-authorization/oauth-2.0` - Official sample apps: `https://github.com/IntuitDeveloper/SampleApp-CustomFields-Java` · `https://github.com/IntuitDeveloper/SampleApp-CustomFields-Python` +**Hosts:** +- **GraphQL endpoint** (Tasks 1, 4, 5 — Custom Field *definitions*). This API is **production-only**, so always use the production host regardless of `QBO_ENV`: + - Production: `https://qb.api.intuit.com/graphql` +- **REST V3 base URL** (Tasks 2, 3 — attaching/reading custom field *values* on a transaction). These honor `QBO_ENV`: + - Production: `https://quickbooks.api.intuit.com` + - Sandbox: `https://sandbox-quickbooks.api.intuit.com` + +REST V3 paths below (e.g. `POST /v3/company/{{companyid}}/...`) are relative to the REST base URL. Do not modify either host. + --- ## Task 1: Discover Custom Field Definitions (GraphQL) @@ -120,7 +129,7 @@ Set exactly one value field per `CustomField` entry. Retrieve the entity and display custom field values with human-readable labels. -- **Fetch:** Use `GET /v3/company/{{companyid}}/salesreceipt/{{TransactionId}}` and append `include=enhancedAllCustomFields`. +- **Fetch:** Use `GET /v3/company/{{companyid}}/salesreceipt/{{TransactionId}}?minorversion=75` and append `include=enhancedAllCustomFields`. - **Hydration:** For each `CustomField` in the response, look up `DefinitionId` (= `legacyIDV2`) in the Task 1 map to get `label` and `dataType`. - **Type-aware reading:** Use the cached `dataType` to read the matching value field (`StringValue` / `NumberValue` / `DateValue`). Parse `DateValue` as `YYYY-MM-DD`. - **Display:** Show the entity ID, date, total amount, and a labelled list of custom fields and their values. @@ -164,7 +173,7 @@ Use only when your app provisions custom fields as part of onboarding. Requires "associationCondition": "INCLUDED", "allowedOperations": [], "subAssociations": [ - { "active": true, "associatedEntity": "SALE_INVOICE", "allowedOperations": [] } + { "active": true, "associatedEntity": "SALE", "allowedOperations": [] } ] } ] @@ -239,7 +248,7 @@ Invalidate the Task 1 cache after a successful update. --- -## Technical Best Practices: +## Technical Best Practices - **Caching:** Cache the Task 1 definition map for 1 hour. Invalidate after Task 4 or Task 5. - **Error Handling:** Include blocks for: @@ -256,11 +265,193 @@ Invalidate the Task 1 cache after a successful update. --- +## Language-Specific SDK Notes + +**If generating Python code (language: `python`):** + +There is no official Intuit-published Python SDK, but the community-maintained **`python-quickbooks`** library (by `ej2`, v0.9.12+) is the de-facto standard. It has typed entity models, custom-field wiring on Invoice/SalesReceipt, and a public `params={}` hook on `.save()` / `.get()` that propagates to the QBO REST URL — so `params={'include': 'enhancedAllCustomFields'}` does the right thing. + +For Task 1 (GraphQL), use `requests` directly — no library supports the Custom Fields GraphQL API in Python. + +--- + +## Task 1 — GraphQL discovery (`requests`) + +- POST the `appFoundationsCustomFieldDefinitions` query to `https://qb.api.intuit.com/graphql` (production only — GraphQL has no sandbox endpoint) with `Content-Type: application/json`, `Authorization: Bearer `, and a unique `intuit_tid` per request for log correlation. +- Send the argument as **`filters`** (plural — `AppFoundations_CustomExtensionsDefinitionFilterBy` input type) with primitive fields: `{ active: true }`. Do not use `{ equals: ... }` predicate wrappers. Filter by target entity client-side after the response. +- Parse the JSON response and build a `dict` keyed by `legacyIDV2` (NOT the GraphQL `id`). Store `label` and `dataType` per entry. The GraphQL node field is lowercase `id`. +- Handle pagination via `pageInfo.endCursor` / `pageInfo.hasNextPage`. + +--- + +## Task 2 — REST V3 entity creation (`python-quickbooks`) + +### Setup + +```python +from intuitlib.client import AuthClient +from quickbooks import QuickBooks +from quickbooks.objects.invoice import Invoice +from quickbooks.objects.salesreceipt import SalesReceipt +from quickbooks.objects.base import CustomField, Ref +from quickbooks.objects.detailline import SalesItemLineDetail, SalesItemLine + +auth_client = AuthClient( + client_id=..., + client_secret=..., + environment='production', # or 'sandbox' — honors salesreceipt-relevant REST V3 envs only + redirect_uri=..., +) +auth_client.access_token = access_token # from .env + +client = QuickBooks( + auth_client=auth_client, + refresh_token=refresh_token, # optional, only if you'll refresh + company_id=realm_id, + minorversion=75, +) +``` + +### Build the entity + +```python +sr = SalesReceipt() +sr.CustomerRef = Ref() +sr.CustomerRef.value = "1" + +line = SalesItemLine() +line.Amount = 111 +line.SalesItemLineDetail = SalesItemLineDetail() +line.SalesItemLineDetail.ItemRef = Ref() +line.SalesItemLineDetail.ItemRef.value = "1" +sr.Line.append(line) + +cf = CustomField() +cf.DefinitionId = definition["legacyIDV2"] # NOT the GraphQL `id` +cf.Name = definition["label"] # optional but recommended +cf.Type = "StringType" # pick from StringType / NumberType / DateType +cf.StringValue = "Demo value" # see "Typed value fields" below +sr.CustomField.append(cf) + +# Attach the include parameter via `params=` on .save(). +# This is the key call — without it, the response strips CustomField metadata. +sr.save(qb=client, params={'include': 'enhancedAllCustomFields'}) + +print("Created SalesReceipt id =", sr.Id) +``` + +### Typed value fields — important Python-only gap + +The `quickbooks.objects.base.CustomField` class in `python-quickbooks` 0.9.12 only declares **`StringValue`** as a typed attribute. **`NumberValue` and `DateValue` are NOT typed on the class** — to set them, assign them as regular attributes and they'll serialize correctly via `to_json()`: + +```python +# STRING / STRING_LIST / OBJECT_LIST — uses the typed StringValue field +cf.Type = "StringType" +cf.StringValue = "Demo value" + +# NUMBER — set NumberValue as a raw attribute (not part of the typed POJO) +cf.Type = "NumberType" +cf.NumberValue = 42.0 + +# DATE — set DateValue as a raw attribute, format YYYY-MM-DD +cf.Type = "DateType" +cf.DateValue = "2026-05-23" +``` + +Set **exactly one** value field per `CustomField`. For `STRING_LIST` / `OBJECT_LIST`, validate `StringValue` against the definition's active `dropDownOptions[].value` set (from Task 1) before sending. + +### Customer entity — Python-only gap + +`python-quickbooks` 0.9.12's `Customer` class does **NOT** declare a `CustomField` attribute, even though the QBO REST API supports custom fields on Customer. If you need Customer custom fields, you have three options: +1. Monkey-patch: `Customer.CustomField = []` and add it to `Customer.list_dict` before serialization. +2. Use the SDK-free path below for Customer specifically. +3. Submit a PR upstream. + +### Silent-drop detection + +REST V3 silently drops `CustomField` entries whose `DefinitionId` doesn't match the target entity's sub-association. After `.save()` returns (the SDK populates `self.CustomField` from the response), compare the returned `CustomField` list against the list you sent. Warn on any missing entries — most common cause: a `SALE_INVOICE`-scoped definition will not attach to a SalesReceipt (`SALE`-scoped) — and vice versa. + +--- + +## Task 3 — Type-aware hydration (`python-quickbooks`) + +```python +fetched = SalesReceipt.get(sr.Id, qb=client, params={'include': 'enhancedAllCustomFields'}) + +for rcf in fetched.CustomField: + def_id = rcf.DefinitionId # equals legacyIDV2 + meta = definition_map[def_id] + data_type = meta['dataType'] + if data_type in ('STRING', 'STRING_LIST', 'OBJECT_LIST'): + value = rcf.StringValue + elif data_type == 'NUMBER': + value = getattr(rcf, 'NumberValue', None) + elif data_type == 'DATE': + value = getattr(rcf, 'DateValue', None) + # ...render label + value for the UI... +``` + +Filter `fetched.Line` to entries where `line.DetailType == "SalesItemLineDetail"`. Exclude system-generated lines (`SubTotalLineDetail`, `DiscountLineDetail`, `TaxLineDetail`). + +--- + +## Dependencies + +``` +pip install python-quickbooks intuit-oauth +``` + +`intuit-oauth` provides `AuthClient` (the OAuth flow). Use it only if you need refresh; this prompt assumes the token in `.env` is already valid. + +--- + +## Alternative: no-SDK approach (`requests` + dicts) + +Use this when you don't want to add `python-quickbooks` to the project, or when you need Customer custom fields (which the SDK doesn't wire — see above). The pattern matches what the Node.js notes do: build the request body as a `dict` with PascalCase keys, POST via `requests.post(...)` with `params={'include': 'enhancedAllCustomFields'}`. + +```python +import requests, uuid + +body = { + "Line": [{ + "Amount": 111, + "DetailType": "SalesItemLineDetail", + "SalesItemLineDetail": {"ItemRef": {"value": "1"}}, + }], + "CustomerRef": {"value": "1"}, + "CustomField": [{ + "DefinitionId": definition["legacyIDV2"], + "Name": definition["label"], + "Type": "StringType", + "StringValue": "Demo value", + }], +} + +resp = requests.post( + f"https://quickbooks.api.intuit.com/v3/company/{realm_id}/salesreceipt", + params={"minorversion": "75", "include": "enhancedAllCustomFields"}, + headers={ + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + "Accept": "application/json", + "intuit_tid": str(uuid.uuid4()), + }, + json=body, + timeout=30, +) +resp.raise_for_status() +created = resp.json()["SalesReceipt"] # QBO wraps the response in the entity name +``` + +PascalCase keys, top-level entity for request, response wrapped under PascalCase entity name. Same shape on the wire as the SDK path. + +--- + ## 🛑 AI Guardrails (Anti-Hallucination Constraints) **CRITICAL INSTRUCTIONS - YOU MUST ADHERE TO THE FOLLOWING:** 1. **No Hallucinations:** Do not invent, guess, or hallucinate API endpoints, GraphQL properties, or SDK methods that are not explicitly provided in the context or linked documentation. -2. **Strict SDK/Library Usage:** If an official SDK or library is specified (e.g., Intuit Java SDK), use ONLY the methods and classes that exist in its latest public release. Do not construct fake SDK models. For Custom Fields specifically: GraphQL (Tasks 1, 4, 5) has no SDK — use a plain HTTP client. For REST V3 (Tasks 2 & 3), the deciding constraint is the mandatory `include=enhancedAllCustomFields` URL parameter: use the SDK's create/read methods (`DataService.add()` / `findById()` in Java, `dataService.Add()` in .NET) **only when the SDK exposes a public hook to attach that parameter**. If it does not — as with the Python and Node.js SDKs — do not force it through the SDK: build the request body as a plain object with PascalCase keys and POST via plain HTTP with `&include=enhancedAllCustomFields` on the URL. +2. **Strict SDK/Library Usage:** If an official SDK or library is specified (e.g., Intuit Java SDK), use ONLY the methods and classes that exist in its latest public release. Do not construct fake SDK models. For Custom Fields specifically: GraphQL (Tasks 1, 4, 5) has no SDK — use a plain HTTP client. For REST V3 (Tasks 2 & 3), use the SDK's create/read methods (`DataService.add()` / `findById()` in Java, `dataService.Add()` in .NET, `entity.save(qb=client, params=...)` in Python). Each SDK has a language-specific way to attach the `enhancedAllCustomFields` include parameter; see the language-specific SDK notes for the exact mechanism. (Note: the Node.js SDK has no public hook for this parameter — for Node, build the request body as a plain object with PascalCase keys and POST via plain HTTP with `&include=enhancedAllCustomFields` on the URL.) 3. **Provided Links Only:** You must derive all API syntax, structure, and constraints strictly from the provided links. All HTTP responses (GraphQL and REST) must be parsed according to the provided documentation. 4. **Endpoint Strictness:** Use the exact endpoints and query structures provided. Do not attempt to modify the base URL, append unsupported parameters, or alter the `minorversion=75` requirement. 5. **If Blocked/Missing Info:** If the provided documentation or payload structures lack required fields to compile a functional request, STOP and clearly state what specific information is missing instead of making an educated guess. diff --git a/discover/generated-prompts/dimensions-ready-prompt.md b/discover/generated-prompts/dimensions-ready-prompt.md index 7fd37ea..4b4dd21 100644 --- a/discover/generated-prompts/dimensions-ready-prompt.md +++ b/discover/generated-prompts/dimensions-ready-prompt.md @@ -69,7 +69,9 @@ For each `definitionId` from Step A, call `appFoundationsActiveCustomDimensionVa --- ## Task 2: Dynamic Transaction Creation (REST V3) - + +> **Host:** Task 1 uses the GraphQL endpoint above; Task 2 is **REST V3** — prepend the REST base URL (Production `https://quickbooks.api.intuit.com`, Sandbox `https://sandbox-quickbooks.api.intuit.com`, select by `QBO_ENV`) to the `/v3/company/...` path below. Do not send REST calls to the GraphQL host. + Create salesreceipt with default item id : 1 and default customer : 1 and salesreceipt amount: 111. ### Data flow for CustomExtensions: @@ -112,7 +114,7 @@ Ensure the Line items include the `CustomExtensions` array exactly as follows: Once the transaction is created, I need to display it to the user. -- **Fetch:** using `GET /v3/company/{{companyid}}/salesreceipt/{{TransactionId}}`. +- **Fetch:** using `GET /v3/company/{{companyid}}/salesreceipt/{{TransactionId}}?minorversion=75`. - **Data Hydration:** The API returns IDs (keys/values) in `CustomExtensions.AssociatedValues` for each `salesreceipt` line. Write a helper function that maps these IDs back to the human-readable names (e.g., "Region: North") using the cached data from the GraphQL discovery in Task 1. - **Display Logic:** Format the output to show each `salesreceipt` Line, the item name, the amount, and a comma-separated list of the associated Dimension Names and values. - **Line Filtering:** Only hydrate and display appropriate `salesreceipt` lines. Exclude system lines (`SubTotalLineDetail`, `GroupLineDetail`, `DiscountLineDetail`, `TaxLineDetail`) that QuickBooks adds automatically—these are not product lines and have no dimensions. diff --git a/discover/generated-prompts/oauth-setup-ready-prompt.md b/discover/generated-prompts/oauth-setup-ready-prompt.md new file mode 100644 index 0000000..eb70067 --- /dev/null +++ b/discover/generated-prompts/oauth-setup-ready-prompt.md @@ -0,0 +1,247 @@ +**Role:** You are a Principal Software Engineer specializing in OAuth 2.0 and OpenID Connect integrations with Intuit / QuickBooks Online. + +**Context:** I am developing a `python` application using `hints (dataclasses)` typing that needs to connect to QuickBooks Online via **OAuth 2.0 (authorization-code grant)** — sending a user to grant consent, exchanging the returned code for tokens, refreshing tokens, and storing them securely. This is the **prerequisite** every other QBO API integration assumes. Assume the app's `client_id`, `client_secret`, and a registered `redirect_uri` are available as environment variables (`QBO_CLIENT_ID`, `QBO_CLIENT_SECRET`, `QBO_REDIRECT_URI`), along with the environment (`QBO_ENV` = `production` or `sandbox`). Focus strictly on the auth flow. + +**References:** +- OAuth 2.0 documentation: `https://developer.intuit.com/app/developer/qbo/docs/develop/authentication-and-authorization/oauth-2.0` +- OpenID discovery document (production): `https://developer.api.intuit.com/.well-known/openid_configuration` +- OpenID discovery document (sandbox): `https://developer.api.intuit.com/.well-known/openid_sandbox_configuration` +- Developer portal (app keys & redirect URIs): `https://developer.intuit.com` + +--- + +## Use case: connect an app to QuickBooks Online + +The authorization-code flow: (1) send the user to Intuit's consent screen, (2) Intuit redirects back to your `redirect_uri` with a `code` + `realmId` + `state`, (3) exchange the `code` for an `access_token` + `refresh_token`, (4) call APIs with the access token, (5) refresh when it expires. The `realmId` you receive **is** the QBO Company ID you'll use on every subsequent API call. + +--- + +## The endpoints (verified — use exactly these) + +Prefer reading them from the discovery document (`https://developer.api.intuit.com/.well-known/openid_configuration` / `https://developer.api.intuit.com/.well-known/openid_sandbox_configuration`) at startup so you never hard-code a stale URL. They currently resolve to: + +- **Authorization endpoint:** `https://appcenter.intuit.com/connect/oauth2` +- **Token endpoint** (code exchange AND refresh): `https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer` +- **Revocation endpoint:** `https://developer.api.intuit.com/v2/oauth2/tokens/revoke` +- **UserInfo endpoint** (OpenID, optional): `https://accounts.platform.intuit.com/v1/openid_connect/userinfo (production) / https://sandbox-accounts.platform.intuit.com/v1/openid_connect/userinfo (sandbox)` + +> The authorization and token endpoints are the **same** for production and sandbox — the environment difference is in the *company/realm* you connect, not the auth hosts. Only the discovery doc and UserInfo host differ by environment. + +--- + +## Scopes + +Request only what you need (space-separated in the `scope` param): +- `com.intuit.quickbooks.accounting` — Accounting API (the common case) +- `com.intuit.quickbooks.payment` — Payments API +- `openid`, `profile`, `email`, `phone`, `address` — OpenID Connect identity claims + +> ⚠️ Don't request unrelated identity scopes you don't use — mixing them can yield an "Invalid permissions requested" error. Request the minimum set. + +--- + +## Task 1: Build the authorization URL and redirect the user + +Construct a URL to the authorization endpoint with these query params: +- `client_id` = `$QBO_CLIENT_ID` +- `response_type` = `code` +- `scope` = your space-separated scopes (e.g. `com.intuit.quickbooks.accounting`) +- `redirect_uri` = `$QBO_REDIRECT_URI` — **must match a redirect URI registered on the app in the developer portal EXACTLY** (scheme, host, path, trailing slash). A mismatch is the #1 cause of a failed connect. +- `state` = a cryptographically random, per-request value you store server-side — **required for CSRF protection.** Verify it matches on the callback. + +Redirect the user's browser to that URL. + +> 🛑 **Do not render the consent screen in an iframe.** Intuit's accounts host refuses to be framed (CSP). Use a full-page redirect or a popup window. + +--- + +## Task 2: Handle the callback and exchange the code + +Intuit redirects to your `redirect_uri` with query params: `code`, `realmId`, and `state`. + +1. **Verify `state`** matches the value you stored in Task 1. If not, reject the request (possible CSRF) — do not proceed. +2. **Capture `realmId`** — this is the QBO Company ID. Persist it alongside the tokens; you need it on every API call. +3. **Exchange the `code`** at the token endpoint: + +``` +POST https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer +Authorization: Basic base64("$QBO_CLIENT_ID:$QBO_CLIENT_SECRET") +Content-Type: application/x-www-form-urlencoded +Accept: application/json + +grant_type=authorization_code&code=&redirect_uri=$QBO_REDIRECT_URI +``` + +The response is JSON: +```json +{ + "access_token": "<~1 hour TTL>", + "refresh_token": "<~100 day max TTL>", + "expires_in": 3600, + "x_refresh_token_expires_in": 8726400, + "token_type": "bearer" +} +``` + +Persist `access_token`, `refresh_token`, `realmId`, and the computed expiry timestamps (see Task 4 for storage). + +--- + +## Task 3: Refresh the access token + +When the access token is expired (or about to be), get a new one at the **same token endpoint**: + +``` +POST https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer +Authorization: Basic base64("$QBO_CLIENT_ID:$QBO_CLIENT_SECRET") +Content-Type: application/x-www-form-urlencoded +Accept: application/json + +grant_type=refresh_token&refresh_token= +``` + +🛑 **The refresh token ROTATES.** The response may contain a **new** `refresh_token` — when it does, the old one is invalidated. **Always persist the newest `refresh_token` from every refresh response**, overwriting the stored one. Reusing a rotated/stale refresh token is the #1 cause of `invalid_grant` ("Incorrect or invalid refresh token"). + +- **Rotation cadence:** Intuit updates the `refresh_token` **value** roughly every **24 hours** (on the next refresh after 24h have elapsed), as a security measure — so the same value can come back on refreshes within a 24h window, then change. The rolling **100-day** expiry still advances on every refresh regardless. The practical rule is unconditional: read the `refresh_token` from *every* response and store it, so you never send a stale value. +- **Refresh one request at a time** for a given connection. Firing concurrent refreshes with the same `refresh_token` can cause Intuit to treat it as a possible compromise and **revoke** the token (you'll then get `invalid_grant` and must re-authorize). Serialize refreshes (see Task 4). +- Refresh proactively (e.g. when the access token has <5 minutes left) rather than waiting for a 401. +- If a refresh returns `invalid_grant`, the refresh token is dead — the user must re-authorize (start over at Task 1). Surface this clearly; do not retry in a loop. + +--- + +## Task 3b: Detect refresh-token HARD expiration (the 5-year ceiling) + +There are **two distinct refresh-token expiries**, and they are easy to confuse: +- **`x_refresh_token_expires_in`** — the **soft/rolling** window (~100 days). It resets every time you refresh. This is the one you already handle in Task 3. +- **Hard expiration** — a **maximum absolute lifetime of ~5 years** from when the connection was first authorized. **Refreshing does NOT extend it.** When it's reached, the refresh token dies permanently and the user MUST re-authorize — no amount of refreshing helps. (Per Intuit policy: tokens for `com.intuit.quickbooks.accounting`/`payment` issued from Oct 2023 carry a 5-year cap, first expiring ~Oct 2028.) + +To see how much hard lifetime remains, **opt in via a request header** on the token endpoint: + +``` +POST https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer +Authorization: Basic base64("$QBO_CLIENT_ID:$QBO_CLIENT_SECRET") +Content-Type: application/x-www-form-urlencoded +Accept: application/json +x-include-refresh-token-hard-expires-in: true + +grant_type=refresh_token&refresh_token= +``` + +When the header is sent, the response includes an extra field: + +```json +{ + "access_token": "...", + "refresh_token": "...", + "expires_in": 3600, + "x_refresh_token_expires_in": 8726400, + "x_refresh_token_hard_expires_in": 157494248 +} +``` + +- **`x_refresh_token_hard_expires_in`** = remaining **hard** lifetime in seconds (e.g. `157494248` ≈ 4.99 years). This is the authoritative "how long until forced re-auth" value. +- 🛑 **Field-name trap:** the correct field is **`x_refresh_token_hard_expires_in`**. Do **NOT** use `x_refresh_token_lifetime_expires_in` — that name appears in some sample code but is **wrong** and will never be present in the response. Match on `x_refresh_token_hard_expires_in` only. +- The request header is **opt-in** specifically so the new field doesn't break clients that reject unknown JSON properties. Only send it once your parser tolerates the extra field. + +**Legacy fallback (when the field is absent):** if your server tier doesn't yet return `x_refresh_token_hard_expires_in`, detect hard expiration by comparing the refresh-token expiry **before vs. after** a refresh, but only once you're within ~30 days of the soft expiry: if a refresh does **not** push the expiry date forward (old expiry == new expiry), the token has hit its hard ceiling and re-auth is required. + +**Act on it — notification timeline:** drive re-auth nudges off the remaining hard seconds: +- **> 30 days:** no action. +- **≤ 30 days:** in-product warning / surface a reconnect prompt. +- **≤ 7 days:** stronger alert (e.g. email the admin). +- **≤ 0 (expired):** stop data sync; the connection is dead until the user reconnects. + +**Reconnect (re-auth) flow:** when hard expiry is imminent or reached, send the user through Intuit's reconnect URL: + +``` +https://appcenter.intuit.com/app/connect/oauth2/request?appId={{appId}}&realmId={{realmId}}&mode=reconnect +``` + +Substitute your `appId` and the company `realmId`. Validate any reconnect URL goes through the `appcenter.intuit.com/app/connect/oauth2/request` host with `mode=reconnect` before redirecting (don't redirect to an arbitrary URL). After reconnect, you receive fresh tokens exactly as in Task 2. + +--- + +## Task 4: Store tokens securely + +- Persist per connection: `realmId`, `access_token`, `refresh_token`, access-token expiry, refresh-token expiry. +- Use the platform's secret management (python idioms — environment-injected secrets, a vault, an encrypted DB column). **Never** commit tokens or `client_secret` to source, and never put them in logs or URLs. +- Make refresh-token writes **atomic** — because the token rotates, a lost write means the next refresh fails. Serialize concurrent refreshes for the same connection (a lock/mutex) so two threads don't each refresh and clobber each other's rotated token. + +--- + +## Task 5 (optional): Revoke / disconnect + +To disconnect a company, revoke the token: + +``` +POST https://developer.api.intuit.com/v2/oauth2/tokens/revoke +Authorization: Basic base64("$QBO_CLIENT_ID:$QBO_CLIENT_SECRET") +Content-Type: application/json + +{ "token": "" } +``` + +Then delete the stored tokens for that `realmId`. + +--- + +## Task 6: Verify the connection + +After Task 2 (or a refresh), confirm the token works with a cheap authenticated call: + +``` +GET https://quickbooks.api.intuit.com/v3/company//companyinfo/?minorversion=75 +Authorization: Bearer +Accept: application/json +``` + +(Use `sandbox-quickbooks.api.intuit.com` when `QBO_ENV=sandbox`.) A `200` with the CompanyInfo confirms the full loop. A `401` means the token is invalid/expired — refresh and retry once. + +--- + +## Technical Best Practices + +- **Read endpoints from the discovery doc** at startup rather than hard-coding — it's the authoritative source and insulates you from URL changes. +- **The token endpoint is the same for prod and sandbox.** Don't build separate token URLs per environment; the realm determines which company you touch. +- **`redirect_uri` must match exactly** on both the authorization request AND the code exchange — and match what's registered in the portal. +- **Error Handling (verified):** + - **`invalid_grant`** — appears on both code exchange and refresh. Verified causes (per Intuit docs): + - *On code exchange:* the `redirect_uri` doesn't match the one used in the authorization request (and it must carry **no query parameters** — pass extra data via `state` instead); the authorization `code` was reused (a `code` is single-use); wrong key set (development vs. production `client_id`/`client_secret`); or the code was exchanged **more than once** (only exchange it one time — a second attempt returns `invalid_grant` and Intuit may revoke your refresh tokens as a security measure, forcing a full re-auth). + - *On refresh:* the refresh token is expired (100-day rolling or 5-year hard), was revoked, or a **stale/cached** value was sent instead of the latest; or concurrent refreshes raced. Refresh **one at a time** with the current `refresh_token`. When using an SDK, ensure the client object is updated with the latest token object. + - In all cases the connection is dead → re-authorize (Task 1). Do not retry in a loop. + - **`redirect_uri` mismatch** — the URI doesn't exactly match the registered value. Fix registration or the request. + - **"Invalid permissions requested"** — an unsupported or mismatched scope combination. Request only valid scopes. + - **HTTP `401`** on an API call — access token expired; refresh (Task 3) and retry once. + - The token endpoint returns JSON for errors here (`{"error":"invalid_grant",...}`) — but QBO *API* gateways return XML for `401` auth errors. Branch on status/content-type before parsing. +- **Observability:** Log the `intuit_tid` response header on API calls. **NEVER** log `client_secret`, `access_token`, `refresh_token`, the auth `code`, or PII. +- **Typing:** Provide `hints (dataclasses)` models for the token response (`access_token`, `refresh_token`, `expires_in`, `x_refresh_token_expires_in`, `token_type`) and a stored-connection record (`realmId` + tokens + expiries). +- **Output (integration mode: `new`):** Provide modular, clean code and a runnable example. + - **If mode is `new`:** Create a self-contained project in a folder named `qbo-oauth-python` (lowercase, no spaces). Include a `README.md` (how to register the redirect URI, set env vars, run), a dependency manifest, a tiny web server exposing `/connect` (Task 1 redirect) and `/callback` (Task 2 exchange), a refresh helper (Task 3), secure token storage (Task 4), and a CompanyInfo verification call (Task 6). + - **If mode is `existing`:** Produce modular, importable functions (build-auth-url, exchange-code, refresh, revoke, get-valid-token). Do **not** scaffold a new project. First scan the workspace for the build manifest, existing QBO/HTTP client code, and any existing auth/token storage; state your finding in one sentence and match the project's idioms (esp. its secret-storage and routing patterns). + +--- + +## Language-Specific SDK Notes + + + +> If no SDK notes appear above, no official Intuit OAuth client is wired in for your language. Intuit publishes OAuth client libraries for several languages (e.g. `intuit-oauth` for Node.js, `intuitlib`/`intuit-oauth` for Python, the Java/.NET/PHP SDKs include OAuth helpers) — prefer the official client where one exists; otherwise implement the flow with your HTTP client exactly as described above. + +--- + +## 🛑 AI Guardrails (Anti-Hallucination Constraints) + +**CRITICAL INSTRUCTIONS — YOU MUST ADHERE TO THE FOLLOWING:** +1. **No Hallucinations:** Use only the endpoints and params given here (or read live from the discovery doc). Do not invent endpoints, params, or response fields. +2. **Exact endpoints:** Authorization = `https://appcenter.intuit.com/connect/oauth2`; Token (exchange + refresh) = `https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer`; Revoke = `https://developer.api.intuit.com/v2/oauth2/tokens/revoke`. Token endpoint is the same for prod and sandbox. +3. **Refresh-token rotation:** Always persist the newest `refresh_token` from every token response. Never assume it stays constant. +3b. **Hard-expiry field name:** To read the 5-year ceiling, send the `x-include-refresh-token-hard-expires-in: true` request header and read **`x_refresh_token_hard_expires_in`** from the response. NEVER use `x_refresh_token_lifetime_expires_in` (a known-wrong name). The hard ceiling is NOT extended by refreshing — only re-auth resets it. +4. **`state` is required:** Generate a random `state`, store it, and verify it on callback (CSRF). Never skip it. +5. **`redirect_uri` must match exactly** on the auth request, the code exchange, and the portal registration. +6. **Basic auth on the token endpoint:** `Authorization: Basic base64(client_id:client_secret)` with `Content-Type: application/x-www-form-urlencoded`. +7. **Never log or commit secrets:** not `client_secret`, tokens, or the auth `code`. No tokens in URLs. +8. **No iframe for consent:** the consent screen cannot be framed — use a redirect or popup. +9. **`invalid_grant` = dead connection:** re-authorize; do not retry in a loop. +10. **Stop if Blocked:** if a needed value isn't covered here or in the discovery doc, STOP and state what's missing instead of guessing. + +I have provided you with all the necessary context and instructions. Please generate the code and documentation as per the instructions. diff --git a/discover/generated-prompts/project-budgets-ready-prompt.md b/discover/generated-prompts/project-budgets-ready-prompt.md new file mode 100644 index 0000000..888a03c --- /dev/null +++ b/discover/generated-prompts/project-budgets-ready-prompt.md @@ -0,0 +1,301 @@ +**Role:** You are a Principal Software Engineer specializing in Intuit Enterprise Suite (IES) integrations. + +**Context:** I am developing a `python` application. I need to implement a workflow that uses the Projects + Business Planning GraphQL APIs to create, read, update, and delete a **Project Budget** for IES or QuickBooks Advanced companies. Assume the application already has a valid OAuth 2.0 access token, realmId (Company ID), and environment (production or sandbox) available in the `.env` file. Focus strictly on the API integration logic. + +--- + +## Task 1: Pre-flight & Discovery + +**Capability Check:** Before making any Projects or Budget API calls, verify the connected QuickBooks company meets all three prerequisites below. Run these checks in order and stop at the first failure. + +#### Check 1 — Account Type (REST) +Query the CompanyInfo entity and iterate over the `NameValue` list to find the entry where `Name` equals `"OfferingSku"`. The company is eligible if the `Value` is `"QuickBooks Online Advanced"` **or** indicates Intuit Enterprise Suite. If the condition is not met, trigger a user-friendly error: +> "Project Budgets are only available for Intuit Enterprise Suite and QuickBooks Advanced accounts." + +#### Check 2 — Country (REST) +In the same CompanyInfo response, verify `"Country": "US"`. If the country is not `"US"`, trigger a user-friendly error: +> "Project Budgets are only available for US-based QuickBooks accounts." + +#### Check 3 — Projects Preference (REST) +Query Company Preferences and iterate over `Preferences.OtherPrefs.NameValue`. Find the entry where `Name` equals `"ProjectsEnabled"` and confirm its `Value` is `"true"`. If the entry is missing or its value is not `"true"`, trigger a user-friendly error: +> "Projects are not enabled for this QuickBooks account." + +Expected entry in the Preferences response: +```json +{ + "Name": "ProjectsEnabled", + "Value": "true" +} +``` + +- **Endpoints:** `GET /v3/company/{{companyid}}/query?minorversion=75&query=select * from CompanyInfo` and `GET /v3/company/{{companyid}}/query?minorversion=75&query=select * from preferences` + +> **Two hosts:** the Task 1 pre-flight endpoints above are **REST v3** — prepend the REST host (Production `https://quickbooks.api.intuit.com`, Sandbox `https://sandbox-quickbooks.api.intuit.com`, select by `QBO_ENV`). Every budget operation in Tasks 2–5 is **GraphQL** on `https://qb.api.intuit.com/graphql` / `https://qb-sandbox.api.intuit.com/graphql`. Do not send the pre-flight REST calls to the GraphQL host or vice-versa. + +#### Check 4 — Project cost method (per-project prerequisite) +A budget can only be created for a project whose **cost method is NOT `Basic`** (i.e. the project uses the Advanced cost method). Creating a budget for a Basic-cost-method project fails (HTTP 200) with `errorCode: "PNB-INPUT-87"` / `PROJECT_COST_METHOD_IS_BASIC` ("Project budgets are not supported for projects with Basic cost method"). This is a **per-project** check — verify it for the specific project you intend to budget before Task 2. + +### Discovery Flow (two-step, implement in this order): + +#### Step A — Fetch projects for the company +Use `projectManagementProjects` to get projects for the company using the GraphQL endpoint. +- **Extract:** `id`, `name`, `status`, and `customer{id}` from each node. +- **Query:** `{"query":"query projectManagementProjects($first: PositiveInt!,$after: String,$filter: ProjectManagement_ProjectFilter!,$orderBy: [ProjectManagement_OrderBy!]){projectManagementProjects(first: $first,after: $after,filter: $filter,orderBy: $orderBy){edges{node{id,name,status,dueDate,customer{id},account{id}}}pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}}}","variables":{"first":4,"filter":{"status":{"in":["OPEN","IN_PROGRESS"]}},"orderBy":["DUE_DATE_ASC"]}}` +- Display the list of projects to the user. Count the number of projects received. Store the `id` of any single project — this value is required for creating a Project Budget in Task 2 (used as `linkedEntityId`). +- **Empty State Handling:** If zero projects are returned, log a warning: *"No projects found — proceeding to create a new project via GraphQL (Step B)."* Then continue to Step B. +- **GraphQL Endpoint:** `https://qb.api.intuit.com/graphql` or `https://qb-sandbox.api.intuit.com/graphql`. Refer to `https://developer.intuit.com/app/developer/gql/docs/api/qbexternal/queries/`. + +#### Step B — Create project (conditional) +> **Execute this step only if Step A returned zero projects.** Otherwise, skip directly to Task 2. + +Use `projectManagementCreateProject` to create a project for the company using the GraphQL endpoint. For the mandatory parameters (`name`, `customer`, `status`), prompt the user to provide the values. Create the project using the values provided by the user. +Store the `id` of the created project — this value is required for creating a Project Budget in Task 2 (used as `linkedEntityId`). +- **Mutation:** `{"query":"mutation ProjectManagementCreateProject($name: String!,$customer: ProjectManagement_CustomerInput,$status: ProjectManagement_Status){projectManagementCreateProject(input:{name: $name,customer: $customer,status: $status}){... on ProjectManagement_Project {id,name,customer{id},account{id},status}}}","variables":{"name":"Red testing via API","customer":{"id":"32"},"status":"OTHER"}}` +- **GraphQL Endpoint:** `https://qb.api.intuit.com/graphql` or `https://qb-sandbox.api.intuit.com/graphql`. Refer to `https://developer.intuit.com/app/developer/gql/docs/api/qbexternal/queries/`. + +--- + +## Task 2: Create the Project Budget (GraphQL) + +Use the `projectId` obtained from Task 1 (Step A or Step B) as `linkedEntityId` to create the budget. + +Before creating the budget, dynamically discover Items via REST V3 so the generated code runs against ANY connected company (do NOT hard-code item or account IDs). Step (a): GET /v3/company/{{companyid}}/query?query=select * from Item maxresults 3&minorversion=75 to fetch the first 3 active Items. Step (b): for each Item, capture its Id and its IncomeAccountRef.value (use that as accountId; if missing on a non-Service item, fall back to ExpenseAccountRef.value, then to AssetAccountRef.value). Step (c): create a budget named 'Test Project Budget' with budgetType='PROJECT', linkedEntityId=, startDate='2026-01-01', endDate='2026-12-31'. Build 3 budgetDetails line items (one per discovered Item) with sequenceId (1,2,3), order (1,2,3), type='ITEM', date='2026-01-01', quantity 1, unitCost 1000, amount = unitCost * quantity, itemId = discovered Item Id, accountId = discovered account ref value, description = discovered Item Name. If fewer than 3 Items exist, fail with a clear error message instructing the developer to add Items in QuickBooks Settings first. + +### Data Flow for the Budget Mutation: +- **`linkedEntityId`**: `id` from `projectManagementProjects` (`node.id`) **OR** `id` from the created project in Step B. +- **`budgetType`**: MUST be `"PROJECT"`. Any other value will be rejected by the validator and will not link to a project. +- **`startDate` / `endDate`**: ISO-8601 date strings (`YYYY-MM-DD`). +- **`total`**: **Server-computed** as the sum of `budgetDetails[*].amount`. Any `total` you send in the input is **ignored** — read the computed value back from the response; never treat a caller-supplied `total` as authoritative. +- **`budgetDetails[*]`**: Each line carries `sequenceId`, `order`, `type` (`ITEM`), `itemId`, `unitCost`, `quantity`, `amount`, `description`, `date`, `accountId`, and optionally `klassId` / `locationId`. **The server ASSIGNS each line's `sequenceId` on create and ignores whatever you send** — to update or delete a specific line later you must reuse the *server-assigned* `sequenceId` from a Task 3 read (or the create response), not your input value. +- **`state`** (optional): `"DRAFT"` or `"LOCKED"`. If omitted, the server defaults to **`LOCKED`** (not `DRAFT`), and `LOCKED → DRAFT` is a forbidden one-way transition (see Task 4). Send `state: "DRAFT"` explicitly at create time if you need a draft. + +### API Details: +- **Mutation:** `businessPlanningCreateBudget` +- **GraphQL Endpoint:** `https://qb.api.intuit.com/graphql` or `https://qb-sandbox.api.intuit.com/graphql`. Refer to `https://developer.intuit.com/app/developer/gql/docs/api/qbexternal/queries/`. +- **Documentation:** Refer to `https://developer.intuit.com/app/developer/qbo/docs/workflows/manage-projects/use-cases#use-case-6`. + +### Mutation Skeleton: +```graphql +mutation businessPlanningCreateBudget($budgetInput: BusinessPlanning_BudgetInput!) { + businessPlanningCreateBudget(budgetInput: $budgetInput) { + budget { + budgetId + budgetName + budgetType + startDate + endDate + linkedEntityId + total + state + budgetMetaData { createdBy createdAt } + budgetDetails { + sequenceId order type itemId unitCost quantity amount + description date accountId klassId locationId + } + } + } +} +``` + +### Variables Payload (best-effort sample — verify against the live schema): + +> ⚠️ **Schema verification note:** The field names below match the documented response fields, but the `BusinessPlanning_BudgetInput` SDL is not publicly published. If the server returns an `INPUT_VALIDATION` or `GRAPHQL_VALIDATION_FAILED` error, inspect `errors[0].message` for the canonical field name and adjust. Do **NOT** silently swap field names without surfacing the underlying error. + +```json +{ + "budgetInput": { + "budgetName": "Test Project Budget", + "budgetType": "PROJECT", + "linkedEntityId": "", + "startDate": "2026-01-01", + "endDate": "2026-12-31", + "total": 3000.00, + "budgetDetails": [ + { "sequenceId": 1, "order": 1, "type": "ITEM", "itemId": "", "unitCost": 1000.00, "quantity": 1, "amount": 1000.00, "description": "", "date": "2026-01-01", "accountId": "" }, + { "sequenceId": 2, "order": 2, "type": "ITEM", "itemId": "", "unitCost": 1000.00, "quantity": 1, "amount": 1000.00, "description": "", "date": "2026-01-01", "accountId": "" }, + { "sequenceId": 3, "order": 3, "type": "ITEM", "itemId": "", "unitCost": 1000.00, "quantity": 1, "amount": 1000.00, "description": "", "date": "2026-01-01", "accountId": "" } + ] + } +} +``` + +**Constraints:** +- `budgetType` MUST be `"PROJECT"` — any other value silently creates a non-project budget that won't roll up into the Projects dashboard. +- `linkedEntityId` MUST reference the `projectManagementProject.id` returned by the Project API. Never pass a customer ID or the project's underlying REST sub-customer/Job id here. +- `budgetType` should be a private constant inside the budget-service module, NOT a public method parameter — callers must not be able to override it. +- **One budget per project (1:1).** Creating a second budget for a project that already has one fails with `errorCode: "PNB-INPUT-72"` / `BUDGET_EXIST_WITH_SAME_LINKED_ENTITY_ID`. +- Store the returned `budgetId` **and the server-assigned line `sequenceId`s** — you need them for Tasks 4 and 5. There is **no list query** for budgets, so `budgetId` cannot be rediscovered later; persist it now. The `BusinessPlanning_Budget` type has **no `syncToken`** — do not expect, store, or send one. + +--- + +## Task 3: Read & Hydrate the Budget for UI + +Once the budget is created, read it back and display it to the user in a readable format. + +### Step A — Read the budget summary +- **Query:** `businessPlanningBudget(budgetId: $budgetId)` +- **Fetch:** `budgetId`, `budgetName`, `budgetType`, `startDate`, `endDate`, `linkedEntityId`, `total`, `state`, `budgetMetaData`, and a first page of `budgetDetails` (including each line's server-assigned `sequenceId`). **Do NOT select `syncToken`** — it does not exist on `BusinessPlanning_Budget` and selecting it fails with `GRAPHQL_VALIDATION_FAILED`. `businessPlanningBudget` requires `budgetId` (`ID!`). + +### Step B — Paginated read for long line-item lists +If `budgetDetails` is large, switch to the paginated field `budgetDetailsPaginated` using cursor-based traversal. Use the templated query below verbatim: + +```graphql +query BusinessPlanningBudgetDetailsPaginated($budgetId: ID!, $budgetDetailsInput: BusinessPlanning_FetchBudgetDetailsInput) { + businessPlanningBudget(budgetId: $budgetId) { + budgetId + budgetName + budgetType + linkedEntityId + total + state + budgetDetailsPaginated(budgetDetailsInput: $budgetDetailsInput) { + edges { + node { sequenceId order type itemId unitCost quantity amount description date accountId klassId locationId } + cursor + } + pageInfo { hasNextPage hasPreviousPage startCursor endCursor } + totalCount + } + } +} + +# Variables: { "budgetId": "", "budgetDetailsInput": { "first": 50, "after": null, "sortBy": [{ "field": "SEQUENCE_ID", "direction": "ASC" }] } } +``` + +Traversal rules: +- Start with `after: null` and a reasonable page size (e.g. `first: 50`). +- After each page, set `after = pageInfo.endCursor` and re-issue. +- Stop when `pageInfo.hasNextPage` is `false`. +- Concatenate all `edges[*].node` into the consolidated line list shown to the user. + +### Display Logic: +- Map `linkedEntityId` back to the project's human-readable `name` using the data cached from the GraphQL discovery in Task 1. +- Format the output to show: project name, budget name, start/end date, state, total, and line items with description, unit cost, quantity, amount, and account. +- Show pagination cursors only in verbose/debug mode — end-users should see a flat consolidated list. + +--- + +## Task 4: Update the Project Budget + +Use `businessPlanningUpdateBudget` to modify an existing budget — edit a line, add a line, or delete a line. + +**Required fields on every update:** +- `budgetId` — the budget's ID returned by Task 2. +- `budgetName` — required (`String!`); the update fails validation if it is omitted. +- There is **no `syncToken`** on this type — do NOT send one (it fails `GRAPHQL_VALIDATION_FAILED`). Do not port SyncToken/optimistic-concurrency patterns from the REST V3 entities to this GraphQL API. + +**Line-item semantics (verified live — this is NOT REST V3 full-replace):** +- `budgetDetails` updates **MERGE by `sequenceId`**; they do **not** replace the whole array. A line you leave out is left **unchanged**, NOT deleted. You only need to send the line(s) you are changing/deleting. +- **Edit a line:** re-send it with its **server-assigned** `sequenceId` (from a Task 3 read) and the new values. A `sequenceId` that doesn't match a stored line silently creates a NEW line (duplicate). +- **Add a line:** send a new line with a new, unused `sequenceId`; the server assigns the authoritative id — read it back afterward. +- **Delete a line:** re-send that line with its server-assigned `sequenceId` and **`deleted: true`**. This is the verified single-line-delete mechanism; the server removes the line and recomputes `total`. +- **`total`** is server-computed from the remaining line amounts — do not send it to change the amount; change the line amounts instead. +- **`state`:** `LOCKED → DRAFT` is forbidden — fails with `errorCode: "PNB-INPUT-85"` / `INVALID_LOCKED_STATE`. Because create defaults to `LOCKED`, a budget not explicitly created as `DRAFT` cannot be moved to `DRAFT`. + +### Mutation Skeleton: +```graphql +mutation businessPlanningUpdateBudget($budgetInput: BusinessPlanning_BudgetInput!) { + businessPlanningUpdateBudget(budgetInput: $budgetInput) { + budget { + budgetId budgetName total state + budgetMetaData { lastUpdatedBy updatedAt } + budgetDetails { + sequenceId order type itemId unitCost quantity amount + description date accountId klassId locationId + } + } + } +} +``` +> Do NOT select `syncToken` on the budget or `clientMutationId` on the payload — `BusinessPlanning_BudgetPayload` exposes only `budget`, and neither field exists (both fail `GRAPHQL_VALIDATION_FAILED`). + +### Variables Payload (best-effort sample — verify against the live schema): + +```json +{ + "_comment": "No syncToken (does not exist on BusinessPlanning_Budget). No total (server-computed). Updates MERGE by sequenceId, so send ONLY the lines you are changing: here we EDIT the server-assigned line and DELETE another via deleted:true. Use the SERVER-ASSIGNED sequenceId values read back in Task 3 — not 1/2/3.", + "budgetInput": { + "budgetId": "", + "budgetName": "Test Project Budget (revised)", + "budgetType": "PROJECT", + "linkedEntityId": "", + "startDate": "2026-01-01", + "endDate": "2026-12-31", + "budgetDetails": [ + { "sequenceId": "", "order": 1, "type": "ITEM", "itemId": "", "unitCost": 1500.00, "quantity": 1, "amount": 1500.00, "description": "", "date": "2026-01-01", "accountId": "" }, + { "sequenceId": "", "deleted": true } + ] + } +} +``` + +--- + +## Task 5: Delete the Project Budget + +Use `businessPlanningDeleteBudgets` to remove one or more budgets. The mutation accepts a list, so a single deletion still passes an array. + +### Mutation Skeleton: +```graphql +mutation businessPlanningDeleteBudgets($budgetIdsInput: BusinessPlanning_BudgetIdsInput!) { + businessPlanningDeleteBudgets(budgetIdsInput: $budgetIdsInput) { + budgetResponseList { budgetId status description } + } +} +``` +> Do NOT select `clientMutationId` — `BusinessPlanning_BudgetResponsePayload` exposes only `budgetResponseList`. + +### Variables Payload (best-effort sample — verify against the live schema): + +```json +{ + "budgetIdsInput": { + "budgetIds": [""] + } +} +``` + +Inspect each `budgetResponseList[*].status` (boolean, `true` on success); `description` is the verified live text `"Delete budget successful."`. Delete is a **hard delete** — any subsequent operation on that `budgetId` (including a read) fails with `errorCode: "PNB-INPUT-024"` / `BUDGET_DELETED`. + +--- + +## Non-Goals (do NOT include in the generated code) + +To keep the generated integration focused and avoid scope creep, the following are **out of scope** for this prompt: + +- **No OAuth client / token-refresh flow** — the integration assumes a valid access token is already present in `.env`. +- **No UI / web framework** — do not scaffold Spring Boot, Express, Flask, or any HTTP server. The deliverable is a CLI/library, not a web app. +- **No persistent database** — do not introduce JPA, Hibernate, SQLAlchemy, Mongoose, etc. Hold state in memory for the duration of the run. +- **No Intuit official SDK for the GraphQL `businessPlanning*` operations** — those SDKs do not expose typed bindings for these mutations. Use raw HTTP (e.g. Java `HttpClient`, Python `httpx`, Node `fetch`) for all GraphQL calls in this prompt. +- **Required artifacts only**: source files, a `README.md`, a dependency manifest (`build.gradle` / `requirements.txt` / `package.json` / etc.), a runnable entry point that exercises Tasks 1–5 end-to-end, and a `.env.example`. + +## Technical Best Practices: +- **Error Handling:** Include specific error-handling blocks for: + - `401 Unauthorized` — prompt token refresh. + - `GRAPHQL_VALIDATION_FAILED` (HTTP 400) — a selected field doesn't exist (e.g. `syncToken`, `clientMutationId`) or a required arg is missing (e.g. `businessPlanningBudget` without `budgetId`). Select only fields that exist on `BusinessPlanning_Budget`. + - **GraphQL errors on HTTP 200** — business/validation errors return 200 with `data: null` and an `errors[]` array carrying `extensions.errorCode`. Common `budgeting-service` codes: `PNB-INPUT-72` (`BUDGET_EXIST_WITH_SAME_LINKED_ENTITY_ID` — project already has a budget), `PNB-INPUT-75` (`INVALID_BUDGET_PROJECT_ID` — bad/unknown `linkedEntityId`), `PNB-INPUT-85` (`INVALID_LOCKED_STATE` — `LOCKED → DRAFT`), `PNB-INPUT-87` (`PROJECT_COST_METHOD_IS_BASIC` — project uses Basic cost method), `PNB-INPUT-024` (`BUDGET_DELETED` — operating on a deleted budget). Also `PNB-PCBM-008` / `REALM_SKU_DOES_NOT_MATCH` — company not Advanced+Construction Pack / IES (Task 1 pre-flight should catch this first). +- **Observability:** Include structured logging. You **MUST** capture and log the `intuit_tid` header from every Intuit API response for traceability. **NEVER** log access tokens, OAuth secrets, or PII. +- **Output (integration mode: `new`):** Provide modular, clean code and a runnable verification example. + - **If mode is `new`:** Create a self-contained project in a dedicated folder named `project-budgets-python` (no spaces). Include a `README.md` explaining how to run the code, a dependency manifest, and a brief architectural diagram showing the data flow from Projects GraphQL → Business Planning GraphQL. + - **If mode is `existing`:** Produce modular, well-documented functions/classes/files designed to be imported into an existing codebase. Do **not** scaffold a new project structure. Provide clear integration notes describing which files to add, what imports are needed, and how to wire the functions into an existing app. + +--- + +## 🛑 AI Guardrails (Anti-Hallucination Constraints) + +**CRITICAL INSTRUCTIONS - YOU MUST ADHERE TO THE FOLLOWING:** +1. **No Hallucinations:** Do not invent, guess, or hallucinate API endpoints, GraphQL properties, or SDK methods not explicitly provided here or in the linked documentation. The operations are exactly `businessPlanningCreateBudget`, `businessPlanningBudget`, `businessPlanningUpdateBudget`, and `businessPlanningDeleteBudgets`. +2. **Strict SDK/Library Usage:** If an official SDK or library is specified, use ONLY methods/classes that exist in its latest public release. There is no typed SDK binding for these `businessPlanning*` GraphQL mutations — build the request as a plain object and POST via a plain HTTP client. +3. **Provided Links Only:** Derive all API syntax, structure, and constraints strictly from the provided links and this template. +4. **Endpoint Strictness:** Budget CRUD is GraphQL (`https://qb.api.intuit.com/graphql` / `https://qb-sandbox.api.intuit.com/graphql`); the Task 1 pre-flight is REST v3 (`https://quickbooks.api.intuit.com` / `https://sandbox-quickbooks.api.intuit.com`). Do not send one to the other's host, and do not generate a REST budget endpoint — there is no REST budget entity. +5. **No `syncToken`:** `BusinessPlanning_Budget` has no `syncToken`. Do not select it in a query or send it in an update — it fails `GRAPHQL_VALIDATION_FAILED`. Do NOT port optimistic-concurrency patterns from REST V3. +6. **No `clientMutationId`:** create/update payloads expose only `budget`; delete exposes only `budgetResponseList`. Do not select `clientMutationId`. +7. **`sequenceId` is server-assigned:** the server assigns line `sequenceId`s and ignores yours on create. On update/delete, always send the exact server-assigned `sequenceId` from a read — a mismatch silently creates a new line. +8. **Line updates MERGE, they do not full-replace:** omitting a line leaves it unchanged (NOT deleted). To delete a line, send it with its `sequenceId` and `deleted: true`. Never assume omission deletes. +9. **`total` is server-computed:** it is the sum of `budgetDetails[*].amount`; the input `total` is ignored. To change the total, change the line amounts. +10. **`state` rules:** create defaults to `LOCKED` (not `DRAFT`); `LOCKED → DRAFT` is forbidden (`PNB-INPUT-85`). Only emit `DRAFT` or `LOCKED`. +11. **`budgetType` MUST be `"PROJECT"`** and `linkedEntityId` MUST be the `projectManagementProject.id` (not a customer/Job id). One budget per project (1:1). +12. **GraphQL errors on 200:** never treat HTTP 200 as unconditional success — inspect `errors[]` (business/validation errors return 200 with `data: null`) and surface `extensions.errorCode`. +13. **If Blocked/Missing Info:** if required fields to compile a functional request are missing, STOP and state what's missing instead of guessing. + +I have provided you with all the necessary context and instructions. Please generate the code and documentation as per the instructions. diff --git a/discover/generated-prompts/project-change-orders-ready-prompt.md b/discover/generated-prompts/project-change-orders-ready-prompt.md new file mode 100644 index 0000000..123f68b --- /dev/null +++ b/discover/generated-prompts/project-change-orders-ready-prompt.md @@ -0,0 +1,200 @@ +**Role:** You are a Principal Software Engineer specializing in Intuit Enterprise Suite (IES) integrations. + +**Context:** I am developing a `python` application. I need to implement a workflow that uses the Projects GraphQL API and the Accounting REST V3 ChangeOrder endpoint to create a **Project Change Order** linked to an existing project estimate. This works for IES or QuickBooks Online Advanced (with the Construction Pack add-on) companies. Assume the application already has a valid OAuth 2.0 access token, realmId (Company ID), and environment (production or sandbox) available in the `.env` file. Focus strictly on the API integration logic. + +**Hosts (select by `QBO_ENV`) — this workflow uses TWO hosts:** +- **REST V3 base URL** (CompanyInfo + Preferences pre-flight in Task 1, and the ChangeOrder entity in Tasks 2–5): + - Production: `https://quickbooks.api.intuit.com` + - Sandbox: `https://sandbox-quickbooks.api.intuit.com` +- **GraphQL endpoint** (`projectManagement*` discovery in Task 1): + - Production: `https://qb.api.intuit.com/graphql` + - Sandbox: `https://qb-sandbox.api.intuit.com/graphql` + +REST v3 paths below (e.g. the `POST /v3/company/.../changeorder` endpoint) are relative to the REST base URL; `projectManagement*` operations POST to the GraphQL endpoint. Do not modify either host. + +--- + +## Task 1: Pre-flight & Discovery + +**Capability Check:** Before making any Projects or ChangeOrder API calls, verify the connected QuickBooks company meets all three prerequisites below. Run these checks in order and stop at the first failure. + +#### Check 1 — Account Type (REST) +Query the CompanyInfo entity and iterate over the `NameValue` list to find the entry where `Name` equals `"OfferingSku"`. The company is eligible if the `Value` is `"QuickBooks Online Advanced"` (with Construction Pack add-on) **or** indicates Intuit Enterprise Suite. If the condition is not met, trigger a user-friendly error: +> "Project Change Orders are only available for Intuit Enterprise Suite or QuickBooks Advanced (Construction Pack) accounts." + +#### Check 2 — Country (REST) +In the same CompanyInfo response, verify `"Country": "US"`. If the country is not `"US"`, trigger a user-friendly error: +> "Project Change Orders are only available for US-based QuickBooks accounts." + +#### Check 3 — Projects Preference (REST) +Query Company Preferences and iterate over `Preferences.OtherPrefs.NameValue`. Find the entry where `Name` equals `"ProjectsEnabled"` and confirm its `Value` is `"true"`. If the entry is missing or its value is not `"true"`, trigger a user-friendly error: +> "Projects are not enabled for this QuickBooks account." + +Expected entry in the Preferences response: +```json +{ + "Name": "ProjectsEnabled", + "Value": "true" +} +``` + +- **Endpoints:** `GET /v3/company/{{companyid}}/query?minorversion=75&query=select * from CompanyInfo` and `GET /v3/company/{{companyid}}/query?minorversion=75&query=select * from preferences` + +### Discovery Flow (three-step, implement in this order): + +#### Step A — Fetch projects for the company +Use `projectManagementProjects` to get projects for the company using the GraphQL endpoint. +- **Extract:** `id`, `name`, `status`, and `customer{id}` from each node. +- **Query:** `{"query":"query projectManagementProjects($first: PositiveInt!,$after: String,$filter: ProjectManagement_ProjectFilter!,$orderBy: [ProjectManagement_OrderBy!]){projectManagementProjects(first: $first,after: $after,filter: $filter,orderBy: $orderBy){edges{node{id,name,status,dueDate,customer{id},account{id}}}pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}}}","variables":{"first":4,"filter":{"status":{"in":["OPEN","IN_PROGRESS"]}},"orderBy":["DUE_DATE_ASC"]}}` +- Display the list of projects to the user. Store the `id` of any single project and its associated `customer{id}` — these values are required for Step B and Task 2. +- **Empty State Handling:** If zero projects are returned, log a warning: *"No projects found — proceeding to create a new project via GraphQL (Step B-1)."* Then run a project creation step before continuing to Step B. +- **GraphQL Endpoint:** `https://qb.api.intuit.com/graphql` or `https://qb-sandbox.api.intuit.com/graphql`. Refer to `https://developer.intuit.com/app/developer/gql/docs/api/qbexternal/queries/`. + +##### Step A-1 — Create project (conditional) +> **Execute this step only if Step A returned zero projects.** Otherwise, skip to Step B. + +Use `projectManagementCreateProject`. For the mandatory parameters (`name`, `customer`, `status`), prompt the user. Store the returned `id` and `customer{id}`. +- **Mutation:** `{"query":"mutation ProjectManagementCreateProject($name: String!,$customer: ProjectManagement_CustomerInput,$status: ProjectManagement_Status){projectManagementCreateProject(input:{name: $name,customer: $customer,status: $status}){... on ProjectManagement_Project {id,name,customer{id},account{id},status}}}","variables":{"name":"Red testing via API","customer":{"id":"32"},"status":"OTHER"}}` + +#### Step B — Find or create the parent project estimate +A change order MUST be linked to an existing project estimate (an Estimate entity that already carries a `ProjectRef` value pointing to the selected project). Run this step in order: + +1. **Query existing estimates for the project (REST V3):** + `GET /v3/company/{{companyid}}/query?query=select * from Estimate where ProjectRef = ''&minorversion=75` + - If at least one Estimate is returned, store its `Id` as `parent_estimate_id` and continue to Task 2. +2. **If no project estimate exists, create one (REST V3):** + Create an estimate with default item id : 1 ,UnitPrice: 1, Qty: 100, and ItemAccountRef=5. Amount=UnitPrice*Qty. CostAmount in the line should be 30 percent more or less than the Amount. + - **Endpoint:** `POST /v3/company/{{companyid}}/estimate?minorversion=75` + - The created Estimate request body MUST include both `ProjectRef.value = ` and `CustomerRef.value = ` at the top level so it is recognized as a project estimate. + - Store the returned `Id` as `parent_estimate_id`. + +**Constraint:** The parent estimate MUST itself carry a `ProjectRef`. Referencing a standalone (non-project) estimate from a change order will fail validation. + +--- + +## Task 2: Create the Project Change Order (REST V3) + +Use the `projectId`, `customerId`, and `parent_estimate_id` obtained from Task 1 to create the change order. + +Create a Change Order linked to the discovered project and parent estimate. Use TxnStatus='Pending', DocNumber='CO-001', and 2 line items: 'Additional electrical work' $500 (item id 1, qty 1, UnitPrice 500) and 'Permit fees' $750 (item id 1, qty 1, UnitPrice 750). Top-level ProjectRef and LinkedTxn are mandatory, AND every line MUST re-include LinkedTxn referencing the parent estimate. Omit TotalAmt (read-only). + +### Data Flow for ProjectRef and LinkedTxn: +- **`projectId`**: from Task 1 Step A (or A-1). +- **`customerId`**: `customer{id}` associated with the project. +- **`parent_estimate_id`**: from Task 1 Step B. +- `ProjectRef.value` = `projectId` (top-level — mandatory). +- `CustomerRef.value` = `customerId` (top-level). +- `LinkedTxn`: array containing `{ "TxnId": "", "TxnType": "Estimate" }` at the top level (**required**), and **recommended** on each line item for clarity. If a line omits it, the server backfills it from the top-level value — see Constraints. + +### API Details: +- **Endpoint:** `POST /v3/company/{{companyid}}/changeorder?minorversion=75` +- **Documentation:** Refer to `https://developer.intuit.com/app/developer/qbo/docs/api/accounting/all-entities/changeorder` and `https://developer.intuit.com/app/developer/qbo/docs/workflows/manage-projects/use-cases#use-case-7`. + +### Payload Structure: +Ensure the ChangeOrder request body includes `ProjectRef`, `CustomerRef`, and `LinkedTxn` at the top level (the top-level `LinkedTxn` is required and authoritative). Including `LinkedTxn` on each line is recommended but not required — omitted lines inherit the top-level value (see Constraints). Skeleton: + +```json +{ + "TxnDate": "<>", + "TxnStatus": "Pending", + "DocNumber": "CO-001", + "ProjectRef": { "value": "<>" }, + "CustomerRef": { "value": "<>" }, + "LinkedTxn": [{ "TxnId": "<>", "TxnType": "Estimate" }], + "Line": [ + { + "LineNum": 1, + "Description": "", + "Amount": 0.00, + "DetailType": "SalesItemLineDetail", + "SalesItemLineDetail": { + "ItemRef": { "value": "" }, + "UnitPrice": 0.00, + "Qty": 0, + "TaxCodeRef": { "value": "NON" } + }, + "LinkedTxn": [{ "TxnId": "<>", "TxnType": "Estimate" }] + } + ] +} +``` + +**Constraints (verified live against production):** +- `LinkedTxn` (an Estimate reference) is **required — at minimum at the top level.** If a line omits its own `LinkedTxn`, the server **auto-propagates** the top-level `LinkedTxn` onto that line (confirmed: the stored line comes back *with* it). Including `LinkedTxn` on every line is still recommended for clarity, but a line missing it is **not** rejected while a top-level `LinkedTxn` is present. Only **total absence** (no top-level AND no line `LinkedTxn`) is rejected — with error code **`2020`** ("Required parameter LinkedTxn with an Estimate reference is required for ChangeOrder is missing"). +- **A change order references exactly one parent estimate, and the top-level `LinkedTxn` is authoritative:** the server applies its `TxnId` to every line, **silently overriding** any different per-line `TxnId` (e.g. a line sent with `TxnId:"7"` is stored as the top-level `TxnId:"8"`). This is silent normalization, NOT a validation error — so always intend a single parent and set it at the top level. +- `ProjectRef` at the top level is **mandatory**. Without it the change order will not appear in the Projects dashboard. +- `TotalAmt` is **read-only**. Do not include it in the request body — it is computed from the sum of line `Amount` values. The server also auto-appends a `SubTotalLineDetail` line in the response (exclude it from UI display). +- Use `minorversion=75` on create, update, and delete (POST) calls. + +--- + +## Task 3: Read & Hydrate the Change Order for UI + +Once the change order is created, display it to the user in a readable format. + +- **Fetch:** Retrieve the created ChangeOrder using `GET /v3/company/{{companyid}}/changeorder/{{ChangeOrderId}}?minorversion=75`. +- **Data Hydration:** The API response contains `ProjectRef.value` (project ID) and `LinkedTxn[*].TxnId` (parent estimate ID). Write a helper function that maps these IDs back to human-readable names using the data cached from the GraphQL discovery in Task 1. +- **Display Logic:** Format the output to show: change order DocNumber, status, project name, customer name, parent estimate ID, line items with description / quantity / unit price / Amount, and totals. +- **Line Filtering:** Only display product/service lines. Exclude system-generated lines (`SubTotalLineDetail`, `DiscountLineDetail`, `TaxLineDetail`) that QuickBooks adds automatically. + +--- + +## Task 4: Update the Change Order (sparse update + line full-replace) + +QuickBooks Online supports sparse updates at the top level (only changed fields modified) but the `Line` array is **always full-replace**. + +**Required fields on every update:** +- `Id` — the change order's ID returned by Task 2. +- `SyncToken` — concurrency token from the most recent read. +- `sparse: true` — enables top-level sparse mode. +- `LinkedTxn` at the top level — always re-include. +- The COMPLETE desired `Line` array, with `LinkedTxn` re-included on every line. Any line omitted will be deleted. + +**Recommended pattern:** +1. Re-read the change order (Task 3) to get the latest `SyncToken` and existing lines. +2. Apply changes locally (add/edit/remove lines, update status fields). +3. POST the full revised `Line` array along with the latest `SyncToken`. + +### Endpoint: +- `POST /v3/company/{{companyid}}/changeorder?minorversion=75` (same endpoint as create — body shape signals update via `Id` + `SyncToken`). + +--- + +## Task 5: Delete the Change Order + +Use `POST /v3/company/{{companyid}}/changeorder?minorversion=75&operation=delete` with `Id` + current `SyncToken` in the body. (The endpoint already carries `?minorversion=…`, so the delete flag is appended with `&`, not a second `?`.) + +**Behavior:** +- Deletion is **permanent** and irreversible. +- The parent estimate is **not** modified by the deletion. +- The project's contracted total is recalculated to remove the change order's contribution. +- A successful delete returns a **sparse** `ChangeOrder` object — `{"ChangeOrder": {"domain": "QBO", "status": "Deleted", "Id": ""}}`. It does **not** echo a `SyncToken` in the delete response. + +--- + +## Technical Best Practices: +- **Error Handling:** Include specific error-handling blocks for: + - `401 Unauthorized` — prompt token refresh. + - `400 Bad Request` (`Fault.Error[].code`) — verified codes: **`2020`** ("Required param missing") when there is **no `LinkedTxn`/Estimate reference anywhere** on the request (neither top-level nor any line); **`5010`** ("Stale Object Error") when the `SyncToken` sent on update/delete is not current — re-read (Task 3) and retry. Other causes: referencing a non-project parent estimate, missing `ProjectRef`, or sending read-only `TotalAmt`. Note: differing per-line `TxnId`s do **not** error — the server silently normalizes them to the top-level `LinkedTxn.TxnId`. + - Other project change order error codes — surface code and message per `https://developer.intuit.com/app/developer/qbo/docs/develop/troubleshooting/error-codes#project-change-order-error-codes`. +- **Observability:** Include structured logging. You **MUST** capture and log the `intuit_tid` header from every Intuit API response for traceability. **NEVER** log access tokens, OAuth secrets, or PII. +- **Output (integration mode: `new`):** Provide modular, clean code and a runnable verification example. + - **If mode is `new`:** Create a self-contained project in a dedicated folder named `project-change-orders-python` (no spaces). Include a `README.md` explaining how to run the code, a dependency manifest, and a brief architectural diagram showing the data flow from Projects GraphQL → Estimate REST → ChangeOrder REST. + - **If mode is `existing`:** Produce modular, well-documented functions/classes/files designed to be imported into an existing codebase. Do **not** scaffold a new project structure. Provide clear integration notes describing which files to add, what imports are needed, and how to wire the functions into an existing app. + +--- + +## 🛑 AI Guardrails (Anti-Hallucination Constraints) + +**CRITICAL INSTRUCTIONS - YOU MUST ADHERE TO THE FOLLOWING:** +1. **No Hallucinations:** Do not invent, guess, or hallucinate API endpoints, GraphQL properties, REST fields, or SDK methods that are not explicitly provided in the context or linked documentation. +2. **Strict SDK/Library Usage:** If an official SDK or library is specified, use ONLY the methods and classes that exist in its latest public release. Do not construct fake SDK models. +3. **Provided Links Only:** You must derive all API syntax, structure, and constraints strictly from the provided links. +4. **Endpoint Strictness:** Use the exact endpoints and query structures provided. Do not modify the base URL or alter the `minorversion=75` requirement. +5. **LinkedTxn (Estimate ref) is required — at minimum at the top level.** Recommended: include it on every line for clarity. The server auto-propagates a top-level `LinkedTxn` onto lines that omit it, and it is authoritative (it overrides differing per-line `TxnId`s by silent normalization). Only *total* absence (no top-level and no line) fails, with error `2020`. Do not claim omitting it on a single line "degrades to a standalone estimate" — that is not the observed behavior. +6. **TotalAmt is Read-Only:** Never include `TotalAmt` in a create or update request body. The server computes it. +7. **No Sparse Line Updates:** The `Line` array does not support true sparse updates. Always read-modify-write the complete line array on update. +8. **One Parent Estimate per Change Order:** All lines must reference the same parent estimate `TxnId`. +9. **If Blocked/Missing Info:** If the provided documentation or payload structures lack required fields to compile a functional request, STOP and clearly state what specific information is missing instead of making an educated guess. + +I have provided you with all the necessary context and instructions. Please generate the code and documentation as per the instructions. diff --git a/discover/generated-prompts/projects-ready-prompt.md b/discover/generated-prompts/projects-ready-prompt.md index 84cf3da..6414e1e 100644 --- a/discover/generated-prompts/projects-ready-prompt.md +++ b/discover/generated-prompts/projects-ready-prompt.md @@ -4,10 +4,20 @@ **References:** - Project Estimate documentation: `https://developer.intuit.com/app/developer/qbo/docs/workflows/manage-projects/use-cases#use-case-5` +- Estimate (REST V3) entity documentation: `https://developer.intuit.com/app/developer/qbo/docs/api/accounting/all-entities/estimate` - GraphQL schema reference: `https://developer.intuit.com/app/developer/gql/docs/api/qbexternal/queries/` -- REST V3 API documentation: `https://developer.intuit.com/app/developer/qbo/docs/api/accounting/all-entities/salesreceipt` - OAuth 2.0 documentation: `https://developer.intuit.com/app/developer/qbo/docs/develop/authentication-and-authorization/oauth-2.0` +**Hosts (select by `QBO_ENV`) — this workflow uses TWO hosts:** +- **REST V3 base URL** (CompanyInfo + Preferences pre-flight, and the Estimate entity in Task 2): + - Production: `https://quickbooks.api.intuit.com` + - Sandbox: `https://sandbox-quickbooks.api.intuit.com` +- **GraphQL endpoint** (`projectManagement*` operations in Task 1): + - Production: `https://qb.api.intuit.com/graphql` + - Sandbox: `https://qb-sandbox.api.intuit.com/graphql` + +REST v3 paths below are relative to the REST base URL; `projectManagement*` operations POST to the GraphQL endpoint. Do not modify either host. + --- ## Task 1: Pre-flight & Discovery @@ -15,7 +25,7 @@ **Capability Check:** Before making any Projects or Estimate API calls, verify the connected QuickBooks company meets all three prerequisites below. Run these checks in order and stop at the first failure. #### Check 1 — Account Type (REST) -Query the CompanyInfo entity and iterate over the `NameValue` list to find the entry where `Name` equals `"OfferingSku"`. The company is eligible if the `Value` is `"QuickBooks Online Advanced"`. If the condition is not met, trigger a user-friendly error: +Query the CompanyInfo entity and iterate over the `NameValue` list to find the entry where `Name` equals `"OfferingSku"`. The company is eligible if the `Value` is `"QuickBooks Online Advanced"` **or** indicates Intuit Enterprise Suite. If the condition is not met, trigger a user-friendly error: > "Project Estimates are only available for Intuit Enterprise Suite and QuickBooks Advanced accounts." #### Check 2 — Country (REST) @@ -71,7 +81,7 @@ Create an estimate with default item id : 1 ,UnitPrice: 1, Qty: 100, and ItemAcc ### API Details: - **Endpoint:** `POST /v3/company/{{companyid}}/estimate?minorversion=75` -- **Documentation:** Refer to `https://developer.intuit.com/app/developer/qbo/docs/api/accounting/all-entities/salesreceipt` and `https://developer.intuit.com/app/developer/qbo/docs/workflows/manage-projects/use-cases#use-case-5` +- **Documentation:** Refer to `https://developer.intuit.com/app/developer/qbo/docs/api/accounting/all-entities/estimate` and `https://developer.intuit.com/app/developer/qbo/docs/workflows/manage-projects/use-cases#use-case-5` ### Payload Structure: Ensure the Estimate request body includes **both** `ProjectRef` and `CustomerRef` at the top level, exactly as follows: @@ -97,7 +107,7 @@ At line level, ensure each line item includes both `Amount` and `CostAmount`, wh Once the Estimate is created, display it to the user in a readable format. -- **Fetch:** Retrieve the created Estimate using `GET /v3/company/{{companyid}}/estimate/{{TransactionId}}`. +- **Fetch:** Retrieve the created Estimate using `GET /v3/company/{{companyid}}/estimate/{{TransactionId}}?minorversion=75`. - **Data Hydration:** The API response contains `ProjectRef.value` (the project ID). Write a helper function that maps this ID back to the human-readable project name using the data cached from the GraphQL discovery in Task 1. - **Display Logic:** Format the output to show the Estimate details including: project name, customer name, line items with description, quantity, unit price, `Amount`, `CostAmount`, and totals. - **Line Filtering:** Only display product/service lines. Exclude system-generated lines (`SubTotalLineDetail`, `DiscountLineDetail`, `TaxLineDetail`) that QuickBooks adds automatically. diff --git a/discover/generated-prompts/sales-tax-ready-prompt.md b/discover/generated-prompts/sales-tax-ready-prompt.md index fc21b6c..25f87ae 100644 --- a/discover/generated-prompts/sales-tax-ready-prompt.md +++ b/discover/generated-prompts/sales-tax-ready-prompt.md @@ -167,7 +167,7 @@ Shipping tax: $ --- -## Technical Best Practices: +## Technical Best Practices - **No discovery caching needed.** Unlike most QBO entity flows, there's nothing to cache up front — the mutation computes everything per-call from the addresses you pass. - **`realmId` is NOT validated against the access token.** Empirically (verified May 2026): passing a bogus `realmId` header with a valid token returns a successful tax calculation against the token's bound company. Do **not** rely on the API to reject mismatched realm IDs. Validate the `QBO_REALM_ID` env var matches the token holder's company in your own app code if this matters. @@ -198,7 +198,7 @@ Shipping tax: $ - **Observability:** Capture and log the `intuit_tid` response header on every call. **NEVER** log access tokens, OAuth secrets, or PII (addresses, customer names). - **Typing:** Provide `hints (dataclasses)` models for `IndirectTax_TaxCalculationInput`, `IndirectTax_TaxCalculationPayload`, `IndirectTax_TaxCalculationLineInput`, and `IndirectTax_ShipmentInput`. - **Output (integration mode: `new`):** Provide modular, clean code and a runnable verification example. - - **If mode is `new`:** Create a self-contained project in a folder named `qbo-sales-tax-python` (no spaces). Include a `README.md` with setup and environment variable instructions, a dependency manifest, and a runnable main entry point that executes Task 1 against a hardcoded example transaction and prints the breakdown. + - **If mode is `new`:** Create a self-contained project in a folder named `qbo-sales-tax-python` (no spaces, lowercase). Include a `README.md` with setup and environment variable instructions, a dependency manifest, and a runnable main entry point that executes Task 1 against a hardcoded example transaction and prints the breakdown. - **If mode is `existing`:** Produce modular, well-documented functions/classes/files designed to be imported into an existing codebase. Do **not** scaffold a new project structure. Before writing code, scan the workspace: 1. Look for a dependency manifest (`pom.xml`, `build.gradle`, `package.json`, `requirements.txt`, `go.mod`, etc.) to confirm the build system. 2. Look for existing service classes that make QBO API calls (e.g., files containing `QBO_REALM_ID`, `QBO_ACCESS_TOKEN`, `DataService`, or `OAuth2Authorizer`). @@ -206,13 +206,29 @@ Shipping tax: $ State your finding in one sentence before writing code (e.g., "Found existing Express app with `qboClient.js` — adding `salesTaxCalc.js` as a new module.") and match the project's package names, logging style, and error-handling patterns. -> **SDK note:** No official QBO SDK includes typed bindings for the Indirect Tax GraphQL mutation. Regardless of language, use your preferred HTTP client to POST GraphQL requests to `https://qb.api.intuit.com/graphql` (or `https://qb-sandbox.api.intuit.com/graphql`) — even when an SDK is present, you call this mutation via raw HTTP. +--- + +## Language-Specific SDK Notes + +**If generating Python code (`python` = python):** + +There is no official Intuit entity SDK for Python, and no Python SDK bindings for the Indirect Tax GraphQL mutation regardless. Use plain HTTP. + +- Use `requests` to POST to `https://qb.api.intuit.com/graphql` (or `https://qb-sandbox.api.intuit.com/graphql` when `QBO_ENV=sandbox`). +- Required headers: `Authorization: Bearer `, `Content-Type: application/json`, `realmId: `. +- Recommended: set a unique `intuit_tid` header per request for log correlation. +- **Install:** + ```bash + pip install requests + ``` + +> If no SDK notes appear above, no official entity SDK exists for your language. Use your preferred HTTP client to POST GraphQL requests to `https://qb.api.intuit.com/graphql` (or `https://qb-sandbox.api.intuit.com/graphql`). The official QBO SDKs do **not** include typed bindings for the Indirect Tax GraphQL mutation — even when an SDK is present, you call this mutation via raw HTTP. --- ## 🛑 AI Guardrails (Anti-Hallucination Constraints) -**CRITICAL INSTRUCTIONS - YOU MUST ADHERE TO THE FOLLOWING:** +**CRITICAL INSTRUCTIONS — YOU MUST ADHERE TO THE FOLLOWING:** 1. **No Hallucinations:** Do not invent or guess GraphQL fields, types, or arguments not present in the mutation/variables provided above. The schema does **not** expose `taxCodes`, `salesTaxCodes`, `taxRates`, or any other root query field for sales tax discovery — only the `indirectTaxCalculateSaleTransactionTax` mutation. 2. **Exact Mutation:** Use the mutation provided in `mutation CalculateTax($input: IndirectTax_TaxCalculationInput!) { indirectTaxCalculateSaleTransactionTax(input: $input) { diff --git a/discover/instructions.md b/discover/instructions.md index c7d9fba..228aa9f 100644 --- a/discover/instructions.md +++ b/discover/instructions.md @@ -1,73 +1,69 @@ -Examples of type_of_transaction values which can be used to configure in prompt-config.json: -Bill -CreditMemo -Deposit -Estimate -Expense/Purchase -Invoice -JournalEntry -PurchaseOrder -RefundReceipt -SalesReceipt -VendorCredit +## Supported `type_of_transaction` Values -Examples of {{language_framework}} and {{typing_system}} combinations that can be used to configure in prompt-config.json: ----------------------------- -Python hints (dataclasses) (Native Python 3.9+ typing with dataclasses) -Python Pydantic models (Runtime validation + serialization) -TypeScript TypeScript interfaces (Static typing, good for Node.js backends) -TypeScript Zod schemas (Runtime validation + type inference) -Java Java classes/records (Strong typing, enterprise standard) -Kotlin Kotlin data classes (Concise, null-safe, JVM compatible) -C#/.NET C# classes/records (Strong typing, good for enterprise) -Go Go structs with tags (Lightweight, good for microservices) -Rust Rust structs with serde (Memory-safe, high performance) -Ruby Sorbet type annotations (Optional typing for Ruby) -PHP PHP 8 typed properties (Modern PHP with strict types) -Swift Swift structs/Codable (iOS/macOS native development) +| Value | +|-------| +| `Bill` | +| `CreditMemo` | +| `Deposit` | +| `Estimate` | +| `Expense/Purchase` | +| `Invoice` | +| `JournalEntry` | +| `PurchaseOrder` | +| `RefundReceipt` | +| `SalesReceipt` | +| `VendorCredit` | -Examples of {{transaction_creation_instructions}} that can be used to configure in prompt-config.json: ----------------------------- -"transaction_creation_instructions": { - "invoice": "Create {{type_of_transaction}} with default item id : 1 and default customer : 1 and {{type_of_transaction}} amount: 100", - "salesreceipt": "Create {{type_of_transaction}} with default item id : 1 and default customer : 1 and {{type_of_transaction}} amount: 100", - "purchaseorder": "Create {{type_of_transaction}} with default item id : 2 and default vendor : 28 and {{type_of_transaction}} amount: 100 and APAccountRef=20. PreCheck whether {{type_of_transaction}} is enabled for the company by checking the company preferences API, OtherPrefs.NameValue array for { - "Name": "VendorAndPurchasesPrefs.PurchaseOrderEnabled", - "Value": "true" - }. - If not enabled, throw an error - 'Purchase Order is not enabled for this company.'.", - "bill": "Create {{type_of_transaction}} with default item id : 2 and default vendor : 28 and {{type_of_transaction}} amount: 100 and APAccountRef=20. - For any REST API calls and OAuth 2.0 authentication, I want to use Intuit official JAVA SDK at: {{java-sdk-official}} with {{java-sdk-dependency}} dependency. Use all the latest versions and best practices for SDK integration. Refer to the official documentation at: {{java-sdk-documentation}} and {{oauth2-documentation}}. Use appropriate methods from SDKs only, do not hallucinate or make up methods that don't exist in the SDK. ", - "estimate": { - "project_estimate_creation_instructions": "Create a {{type_of_transaction}} with default item id : 1 and default Customer : 32 and UnitPrice: 1, UnitCostPrice: 10,Qty: 100, and ItemAccountRef=5. ", - "transaction_creation_instructions": "Create {{type_of_transaction}} with default item id : 1 and default customer : 1 and {{type_of_transaction}} amount: 111. For any REST API calls and OAuth 2.0 authentication, I want to use Intuit official PHP SDK documented at: {{php-sdk-documentation}}. Use all the latest versions and best practices for SDK integration. Refer to the official documentation mentioned in the link and {{oauth2-documentation}}. Use appropriate methods from SDKs only, do not hallucinate or make up methods that don't exist in the SDK. " - } -} +--- +## Supported `language_framework` and `typing_system` Combinations -Configuring the Custom Fields prompt (choice 3 in merge-prompt.js): ----------------------------- -The Custom Fields prompt has ONE free-form field you configure: - custom_field_transaction_creation_instructions — how to create the transaction that carries the custom field values (default item/customer/amount, which DefinitionId to attach). Reuses {{type_of_transaction}}. -Everything else (GraphQL queries, mutations, payload structure, scope) is a static field maintained to match the API surface — do not change it. -Notes: - - Silver+ partner tier required — Builder-tier apps receive 403 Forbidden. - - The Custom Fields GraphQL API is production-only (no documented sandbox endpoint); test with free non-expiring partner test accounts. REST V3 read/write calls still support sandbox. - - REST V3 payloads must set DefinitionId = legacyIDV2 (the numeric id from the GraphQL definition), not the opaque id. +| Language / Framework | Typing System | Notes | +|----------------------|---------------|-------| +| `Python` | `hints (dataclasses)` | Native Python 3.9+ typing with dataclasses | +| `Python` | `Pydantic models` | Runtime validation + serialization | +| `TypeScript` | `TypeScript interfaces` | Static typing, good for Node.js backends | +| `TypeScript` | `Zod schemas` | Runtime validation + type inference | +| `Java` | `Java classes/records` | Strong typing, enterprise standard | +| `Kotlin` | `Kotlin data classes` | Concise, null-safe, JVM compatible | +| `C#/.NET` | `C# classes/records` | Strong typing, good for enterprise | +| `Go` | `Go structs with tags` | Lightweight, good for microservices | +| `Rust` | `Rust structs with serde` | Memory-safe, high performance | +| `Ruby` | `Sorbet type annotations` | Optional typing for Ruby | +| `PHP` | `PHP 8 typed properties` | Modern PHP with strict types | +| `Swift` | `Swift structs/Codable` | iOS/macOS native development | -Configuring the Sales Tax prompt (choice 4 in merge-prompt.js): ----------------------------- -The Sales Tax prompt has NO free-form configurable fields — it is driven entirely by static fields (the calculate mutation, its variables example, scope, and documentation links). -Notes: - - Requires the OAuth scope: indirect-tax.tax-calculation.quickbooks - - Calculation-only — the API returns a tax calculation; it does not create or post a transaction. +--- +## Example `transaction_creation_instructions` Values - ### Author, use-case, API version, last-tested date (Template registries) - Intuit Developer, Dimensions API, v1, 2026-03-30 - Intuit Developer, Projects API, v1, 2026-03-30 - Intuit Developer, Custom Fields API, v1, 2026-07-16 - Intuit Developer, Sales Tax API, v1, 2026-07-16 +Copy one of the examples below into the `transaction_creation_instructions` field in `prompt-config.json`. +**Invoice / SalesReceipt:** +```json +"transaction_creation_instructions": "Create {{type_of_transaction}} with default item id: 1 and default customer: 1 and {{type_of_transaction}} amount: 100." +``` +**PurchaseOrder:** +```json +"transaction_creation_instructions": "Create {{type_of_transaction}} with default item id: 2 and default vendor: 28 and {{type_of_transaction}} amount: 100 and APAccountRef=20. Pre-check whether {{type_of_transaction}} is enabled for the company by checking the company preferences API, OtherPrefs.NameValue array for {\"Name\": \"VendorAndPurchasesPrefs.PurchaseOrderEnabled\", \"Value\": \"true\"}. If not enabled, throw an error — 'Purchase Order is not enabled for this company.'." +``` +**Bill (with Java SDK):** +```json +"transaction_creation_instructions": "Create {{type_of_transaction}} with default item id: 2 and default vendor: 28 and {{type_of_transaction}} amount: 100 and APAccountRef=20. For any REST API calls and OAuth 2.0 authentication, use the Intuit official Java SDK at: https://central.sonatype.com/search?q=g:com.intuit.quickbooks-online&smo=true with Gradle dependency. Use all the latest versions and best practices for SDK integration. Refer to the official documentation at: https://developer.intuit.com/app/developer/qbo/docs/develop/sdks-and-samples-collections/java/install-the-java-sdk#jar-files and https://developer.intuit.com/app/developer/qbo/docs/develop/authentication-and-authorization/oauth-2.0. Use appropriate methods from SDKs only; do not hallucinate or make up methods that don't exist in the SDK." +``` + +**Estimate (with PHP SDK — set both fields):** +```json +"transaction_creation_instructions": "Create {{type_of_transaction}} with default item id: 1 and default customer: 1 and {{type_of_transaction}} amount: 111. For any REST API calls and OAuth 2.0 authentication, use the Intuit official PHP SDK documented at: {{php-sdk-documentation}}. Use all the latest versions and best practices for SDK integration. Refer to the official documentation mentioned in the link and {{oauth2-documentation}}. Use appropriate methods from SDKs only; do not hallucinate or make up methods that don't exist in the SDK.", +"project_estimate_creation_instructions": "Create a {{type_of_transaction}} with default item id: 1 and default customer: 32, UnitPrice: 1, UnitCostPrice: 10, Qty: 100, and ItemAccountRef=5." +``` + +--- + +## Metadata + +| Author | API | Version | Last tested | +|--------|-----|---------|-------------| +| Intuit Developer | Dimensions API | v1 | 2026-03-30 | diff --git a/discover/merge-prompt.js b/discover/merge-prompt.js index 3d7cd67..f472012 100644 --- a/discover/merge-prompt.js +++ b/discover/merge-prompt.js @@ -9,6 +9,43 @@ if (configFlagIndex !== -1 && args[configFlagIndex + 1]) { configFile = args[configFlagIndex + 1]; } +// --language selects which SDK notes file (if any) to inject into the +// {{sdk_notes}} placeholder. Falls back to language_framework from the config. +let language = null; +const languageFlagIndex = args.indexOf('--language'); +if (languageFlagIndex !== -1 && args[languageFlagIndex + 1]) { + language = args[languageFlagIndex + 1]; +} + +// Normalize a language name to its sdk-notes filename stem (e.g. "C#" -> "dotnet"). +function normalizeLanguage(lang) { + if (!lang) return null; + // Map "c#" -> "csharp" before stripping symbols so it doesn't collapse to "c". + const key = String(lang).toLowerCase().replace(/#/g, 'sharp').replace(/[^a-z0-9]/g, ''); + const aliases = { + py: 'python', python: 'python', python3: 'python', + ts: 'nodejs', typescript: 'nodejs', js: 'nodejs', javascript: 'nodejs', node: 'nodejs', nodejs: 'nodejs', + dotnet: 'dotnet', net: 'dotnet', csharp: 'dotnet', cs: 'dotnet', + java: 'java', + php: 'php', + ruby: 'ruby', rb: 'ruby', + }; + return aliases[key] || key; +} + +// Display name + default typing system for a normalized language. Used to keep +// the whole prompt consistent when --language overrides the config default. +function languageProfile(normalized) { + const profiles = { + python: { framework: 'Python3', typing: 'hints (dataclasses)' }, + java: { framework: 'Java', typing: 'Java classes/records' }, + dotnet: { framework: '.NET (C#)', typing: 'C# classes/records' }, + nodejs: { framework: 'TypeScript', typing: 'TypeScript interfaces' }, + php: { framework: 'PHP', typing: 'PHP 8 typed properties' }, + ruby: { framework: 'Ruby', typing: 'Sorbet type annotations' }, + }; + return profiles[normalized] || null; +} if (!fs.existsSync(configFile)) { console.error(`Error: Config file "${configFile}" not found.`); process.exit(1); @@ -31,11 +68,54 @@ if (fs.existsSync(schemaPath)) { console.warn('Warning: prompt-config.schema.json not found, skipping validation.'); } +// Resolve the SDK notes for a template. Notes live in a `sdk-notes/` folder next +// to the template (e.g. custom-fields/sdk-notes/python.md). Returns the notes +// text, or an empty string when no notes apply (so {{sdk_notes}} never leaks). +function resolveSdkNotes(templateFile) { + // Language precedence: --language flag, then language_framework from config. + const lang = normalizeLanguage(language || config.language_framework); + if (!lang) return ''; + + const notesDir = path.join(__dirname, path.dirname(templateFile), 'sdk-notes'); + if (!fs.existsSync(notesDir)) { + return ''; // This prompt has no SDK notes at all — templates handle this. + } + + const notesFile = path.join(notesDir, `${lang}.md`); + if (!fs.existsSync(notesFile)) { + const available = fs.readdirSync(notesDir) + .filter(f => f.endsWith('.md')) + .map(f => f.replace(/\.md$/, '')); + console.warn( + `\nWarning: no SDK notes for language "${lang}" in ${notesDir}.` + + `\n Available: ${available.join(', ') || '(none)'}. Leaving SDK notes section empty.` + ); + return ''; + } + + console.log(`Injecting SDK notes: ${path.relative(__dirname, notesFile)}`); + return fs.readFileSync(notesFile, 'utf8').trim(); +} + // --- Merge helper (multi-pass to resolve nested placeholders) --- function mergeTemplate(templateFile, outputFile) { let prompt = fs.readFileSync(templateFile, 'utf8'); - const mergeValues = { ...config }; + // sdk_notes is not a config key — it's loaded from a per-prompt notes file. + // Merge it alongside the config values so {{sdk_notes}} always resolves. + const mergeValues = { ...config, sdk_notes: resolveSdkNotes(templateFile) }; + + // When --language is explicitly passed, override the language fields too, so the + // prompt's Context line and typing match the injected SDK notes (otherwise the + // prompt contradicts itself). With no flag, config values are used unchanged. + if (language) { + const profile = languageProfile(normalizeLanguage(language)); + if (profile) { + mergeValues.language_framework = profile.framework; + mergeValues.typing_system = profile.typing; + console.log(`Language override: language_framework="${profile.framework}", typing_system="${profile.typing}"`); + } + } const MAX_PASSES = 5; for (let pass = 0; pass < MAX_PASSES; pass++) { @@ -81,8 +161,11 @@ console.log(' 1 - Build prompt for dimensions API'); console.log(' 2 - Build prompt for project estimates'); console.log(' 3 - Build prompt for custom fields'); console.log(' 4 - Build prompt for sales tax'); +console.log(' 5 - Build prompt for project budgets'); +console.log(' 6 - Build prompt for project change orders'); +console.log(' 7 - Build prompt for OAuth 2.0 setup'); -rl.question('\nEnter your choice (1-4): ', (answer) => { +rl.question('\nEnter your choice (1-7): ', (answer) => { const choice = answer.trim(); if (choice === '1') { @@ -97,8 +180,17 @@ rl.question('\nEnter your choice (1-4): ', (answer) => { } else if (choice === '4') { //generate prompt for sales tax use cases code generation mergeTemplate('sales-tax/prompt-template-sales-tax.md', 'generated-prompts/sales-tax-ready-prompt.md'); + } else if (choice === '5') { + //generate prompt for project budgets use cases code generation + mergeTemplate('project-budgets/prompt-template-project-budgets.md', 'generated-prompts/project-budgets-ready-prompt.md'); + } else if (choice === '6') { + //generate prompt for project change orders use cases code generation + mergeTemplate('project-change-orders/prompt-template-project-change-orders.md', 'generated-prompts/project-change-orders-ready-prompt.md'); + } else if (choice === '7') { + //generate prompt for OAuth 2.0 setup use cases code generation + mergeTemplate('oauth-setup/prompt-template-oauth-setup.md', 'generated-prompts/oauth-setup-ready-prompt.md'); } else { - console.error('Invalid choice. Please enter 1-4.'); + console.error('Invalid choice. Please enter 1-7.'); rl.close(); process.exit(1); } diff --git a/discover/oauth-setup/prompt-template-oauth-setup.md b/discover/oauth-setup/prompt-template-oauth-setup.md new file mode 100644 index 0000000..dbb806c --- /dev/null +++ b/discover/oauth-setup/prompt-template-oauth-setup.md @@ -0,0 +1,247 @@ +**Role:** You are a Principal Software Engineer specializing in OAuth 2.0 and OpenID Connect integrations with Intuit / QuickBooks Online. + +**Context:** I am developing a `{{language_framework}}` application using `{{typing_system}}` typing that needs to connect to QuickBooks Online via **OAuth 2.0 (authorization-code grant)** — sending a user to grant consent, exchanging the returned code for tokens, refreshing tokens, and storing them securely. This is the **prerequisite** every other QBO API integration assumes. Assume the app's `client_id`, `client_secret`, and a registered `redirect_uri` are available as environment variables (`QBO_CLIENT_ID`, `QBO_CLIENT_SECRET`, `QBO_REDIRECT_URI`), along with the environment (`QBO_ENV` = `production` or `sandbox`). Focus strictly on the auth flow. + +**References:** +- OAuth 2.0 documentation: `{{oauth2-documentation}}` +- OpenID discovery document (production): `{{oauth_discovery_production}}` +- OpenID discovery document (sandbox): `{{oauth_discovery_sandbox}}` +- Developer portal (app keys & redirect URIs): `{{developer_portal_url}}` + +--- + +## Use case: connect an app to QuickBooks Online + +The authorization-code flow: (1) send the user to Intuit's consent screen, (2) Intuit redirects back to your `redirect_uri` with a `code` + `realmId` + `state`, (3) exchange the `code` for an `access_token` + `refresh_token`, (4) call APIs with the access token, (5) refresh when it expires. The `realmId` you receive **is** the QBO Company ID you'll use on every subsequent API call. + +--- + +## The endpoints (verified — use exactly these) + +Prefer reading them from the discovery document (`{{oauth_discovery_production}}` / `{{oauth_discovery_sandbox}}`) at startup so you never hard-code a stale URL. They currently resolve to: + +- **Authorization endpoint:** `{{oauth_authorization_endpoint}}` +- **Token endpoint** (code exchange AND refresh): `{{oauth_token_endpoint}}` +- **Revocation endpoint:** `{{oauth_revocation_endpoint}}` +- **UserInfo endpoint** (OpenID, optional): `{{oauth_userinfo_endpoint}}` + +> The authorization and token endpoints are the **same** for production and sandbox — the environment difference is in the *company/realm* you connect, not the auth hosts. Only the discovery doc and UserInfo host differ by environment. + +--- + +## Scopes + +Request only what you need (space-separated in the `scope` param): +- `com.intuit.quickbooks.accounting` — Accounting API (the common case) +- `com.intuit.quickbooks.payment` — Payments API +- `openid`, `profile`, `email`, `phone`, `address` — OpenID Connect identity claims + +> ⚠️ Don't request unrelated identity scopes you don't use — mixing them can yield an "Invalid permissions requested" error. Request the minimum set. + +--- + +## Task 1: Build the authorization URL and redirect the user + +Construct a URL to the authorization endpoint with these query params: +- `client_id` = `$QBO_CLIENT_ID` +- `response_type` = `code` +- `scope` = your space-separated scopes (e.g. `com.intuit.quickbooks.accounting`) +- `redirect_uri` = `$QBO_REDIRECT_URI` — **must match a redirect URI registered on the app in the developer portal EXACTLY** (scheme, host, path, trailing slash). A mismatch is the #1 cause of a failed connect. +- `state` = a cryptographically random, per-request value you store server-side — **required for CSRF protection.** Verify it matches on the callback. + +Redirect the user's browser to that URL. + +> 🛑 **Do not render the consent screen in an iframe.** Intuit's accounts host refuses to be framed (CSP). Use a full-page redirect or a popup window. + +--- + +## Task 2: Handle the callback and exchange the code + +Intuit redirects to your `redirect_uri` with query params: `code`, `realmId`, and `state`. + +1. **Verify `state`** matches the value you stored in Task 1. If not, reject the request (possible CSRF) — do not proceed. +2. **Capture `realmId`** — this is the QBO Company ID. Persist it alongside the tokens; you need it on every API call. +3. **Exchange the `code`** at the token endpoint: + +``` +POST {{oauth_token_endpoint}} +Authorization: Basic base64("$QBO_CLIENT_ID:$QBO_CLIENT_SECRET") +Content-Type: application/x-www-form-urlencoded +Accept: application/json + +grant_type=authorization_code&code=&redirect_uri=$QBO_REDIRECT_URI +``` + +The response is JSON: +```json +{ + "access_token": "<~1 hour TTL>", + "refresh_token": "<~100 day max TTL>", + "expires_in": 3600, + "x_refresh_token_expires_in": 8726400, + "token_type": "bearer" +} +``` + +Persist `access_token`, `refresh_token`, `realmId`, and the computed expiry timestamps (see Task 4 for storage). + +--- + +## Task 3: Refresh the access token + +When the access token is expired (or about to be), get a new one at the **same token endpoint**: + +``` +POST {{oauth_token_endpoint}} +Authorization: Basic base64("$QBO_CLIENT_ID:$QBO_CLIENT_SECRET") +Content-Type: application/x-www-form-urlencoded +Accept: application/json + +grant_type=refresh_token&refresh_token= +``` + +🛑 **The refresh token ROTATES.** The response may contain a **new** `refresh_token` — when it does, the old one is invalidated. **Always persist the newest `refresh_token` from every refresh response**, overwriting the stored one. Reusing a rotated/stale refresh token is the #1 cause of `invalid_grant` ("Incorrect or invalid refresh token"). + +- **Rotation cadence:** Intuit updates the `refresh_token` **value** roughly every **24 hours** (on the next refresh after 24h have elapsed), as a security measure — so the same value can come back on refreshes within a 24h window, then change. The rolling **100-day** expiry still advances on every refresh regardless. The practical rule is unconditional: read the `refresh_token` from *every* response and store it, so you never send a stale value. +- **Refresh one request at a time** for a given connection. Firing concurrent refreshes with the same `refresh_token` can cause Intuit to treat it as a possible compromise and **revoke** the token (you'll then get `invalid_grant` and must re-authorize). Serialize refreshes (see Task 4). +- Refresh proactively (e.g. when the access token has <5 minutes left) rather than waiting for a 401. +- If a refresh returns `invalid_grant`, the refresh token is dead — the user must re-authorize (start over at Task 1). Surface this clearly; do not retry in a loop. + +--- + +## Task 3b: Detect refresh-token HARD expiration (the 5-year ceiling) + +There are **two distinct refresh-token expiries**, and they are easy to confuse: +- **`x_refresh_token_expires_in`** — the **soft/rolling** window (~100 days). It resets every time you refresh. This is the one you already handle in Task 3. +- **Hard expiration** — a **maximum absolute lifetime of ~5 years** from when the connection was first authorized. **Refreshing does NOT extend it.** When it's reached, the refresh token dies permanently and the user MUST re-authorize — no amount of refreshing helps. (Per Intuit policy: tokens for `com.intuit.quickbooks.accounting`/`payment` issued from Oct 2023 carry a 5-year cap, first expiring ~Oct 2028.) + +To see how much hard lifetime remains, **opt in via a request header** on the token endpoint: + +``` +POST {{oauth_token_endpoint}} +Authorization: Basic base64("$QBO_CLIENT_ID:$QBO_CLIENT_SECRET") +Content-Type: application/x-www-form-urlencoded +Accept: application/json +{{oauth_hard_expiry_request_header}}: true + +grant_type=refresh_token&refresh_token= +``` + +When the header is sent, the response includes an extra field: + +```json +{ + "access_token": "...", + "refresh_token": "...", + "expires_in": 3600, + "x_refresh_token_expires_in": 8726400, + "{{oauth_hard_expiry_response_field}}": 157494248 +} +``` + +- **`{{oauth_hard_expiry_response_field}}`** = remaining **hard** lifetime in seconds (e.g. `157494248` ≈ 4.99 years). This is the authoritative "how long until forced re-auth" value. +- 🛑 **Field-name trap:** the correct field is **`{{oauth_hard_expiry_response_field}}`**. Do **NOT** use `x_refresh_token_lifetime_expires_in` — that name appears in some sample code but is **wrong** and will never be present in the response. Match on `{{oauth_hard_expiry_response_field}}` only. +- The request header is **opt-in** specifically so the new field doesn't break clients that reject unknown JSON properties. Only send it once your parser tolerates the extra field. + +**Legacy fallback (when the field is absent):** if your server tier doesn't yet return `{{oauth_hard_expiry_response_field}}`, detect hard expiration by comparing the refresh-token expiry **before vs. after** a refresh, but only once you're within ~30 days of the soft expiry: if a refresh does **not** push the expiry date forward (old expiry == new expiry), the token has hit its hard ceiling and re-auth is required. + +**Act on it — notification timeline:** drive re-auth nudges off the remaining hard seconds: +- **> 30 days:** no action. +- **≤ 30 days:** in-product warning / surface a reconnect prompt. +- **≤ 7 days:** stronger alert (e.g. email the admin). +- **≤ 0 (expired):** stop data sync; the connection is dead until the user reconnects. + +**Reconnect (re-auth) flow:** when hard expiry is imminent or reached, send the user through Intuit's reconnect URL: + +``` +{{oauth_reconnect_url}} +``` + +Substitute your `appId` and the company `realmId`. Validate any reconnect URL goes through the `appcenter.intuit.com/app/connect/oauth2/request` host with `mode=reconnect` before redirecting (don't redirect to an arbitrary URL). After reconnect, you receive fresh tokens exactly as in Task 2. + +--- + +## Task 4: Store tokens securely + +- Persist per connection: `realmId`, `access_token`, `refresh_token`, access-token expiry, refresh-token expiry. +- Use the platform's secret management ({{language_framework}} idioms — environment-injected secrets, a vault, an encrypted DB column). **Never** commit tokens or `client_secret` to source, and never put them in logs or URLs. +- Make refresh-token writes **atomic** — because the token rotates, a lost write means the next refresh fails. Serialize concurrent refreshes for the same connection (a lock/mutex) so two threads don't each refresh and clobber each other's rotated token. + +--- + +## Task 5 (optional): Revoke / disconnect + +To disconnect a company, revoke the token: + +``` +POST {{oauth_revocation_endpoint}} +Authorization: Basic base64("$QBO_CLIENT_ID:$QBO_CLIENT_SECRET") +Content-Type: application/json + +{ "token": "" } +``` + +Then delete the stored tokens for that `realmId`. + +--- + +## Task 6: Verify the connection + +After Task 2 (or a refresh), confirm the token works with a cheap authenticated call: + +``` +GET https://{{rest_baseurl_production}}/v3/company//companyinfo/?minorversion={{minorversion}} +Authorization: Bearer +Accept: application/json +``` + +(Use `{{rest_baseurl_sandbox}}` when `QBO_ENV=sandbox`.) A `200` with the CompanyInfo confirms the full loop. A `401` means the token is invalid/expired — refresh and retry once. + +--- + +## Technical Best Practices + +- **Read endpoints from the discovery doc** at startup rather than hard-coding — it's the authoritative source and insulates you from URL changes. +- **The token endpoint is the same for prod and sandbox.** Don't build separate token URLs per environment; the realm determines which company you touch. +- **`redirect_uri` must match exactly** on both the authorization request AND the code exchange — and match what's registered in the portal. +- **Error Handling (verified):** + - **`invalid_grant`** — appears on both code exchange and refresh. Verified causes (per Intuit docs): + - *On code exchange:* the `redirect_uri` doesn't match the one used in the authorization request (and it must carry **no query parameters** — pass extra data via `state` instead); the authorization `code` was reused (a `code` is single-use); wrong key set (development vs. production `client_id`/`client_secret`); or the code was exchanged **more than once** (only exchange it one time — a second attempt returns `invalid_grant` and Intuit may revoke your refresh tokens as a security measure, forcing a full re-auth). + - *On refresh:* the refresh token is expired (100-day rolling or 5-year hard), was revoked, or a **stale/cached** value was sent instead of the latest; or concurrent refreshes raced. Refresh **one at a time** with the current `refresh_token`. When using an SDK, ensure the client object is updated with the latest token object. + - In all cases the connection is dead → re-authorize (Task 1). Do not retry in a loop. + - **`redirect_uri` mismatch** — the URI doesn't exactly match the registered value. Fix registration or the request. + - **"Invalid permissions requested"** — an unsupported or mismatched scope combination. Request only valid scopes. + - **HTTP `401`** on an API call — access token expired; refresh (Task 3) and retry once. + - The token endpoint returns JSON for errors here (`{"error":"invalid_grant",...}`) — but QBO *API* gateways return XML for `401` auth errors. Branch on status/content-type before parsing. +- **Observability:** Log the `intuit_tid` response header on API calls. **NEVER** log `client_secret`, `access_token`, `refresh_token`, the auth `code`, or PII. +- **Typing:** Provide `{{typing_system}}` models for the token response (`access_token`, `refresh_token`, `expires_in`, `x_refresh_token_expires_in`, `token_type`) and a stored-connection record (`realmId` + tokens + expiries). +- **Output (integration mode: `{{integration_mode}}`):** Provide modular, clean code and a runnable example. + - **If mode is `new`:** Create a self-contained project in a folder named `qbo-oauth-{{language_framework}}` (lowercase, no spaces). Include a `README.md` (how to register the redirect URI, set env vars, run), a dependency manifest, a tiny web server exposing `/connect` (Task 1 redirect) and `/callback` (Task 2 exchange), a refresh helper (Task 3), secure token storage (Task 4), and a CompanyInfo verification call (Task 6). + - **If mode is `existing`:** Produce modular, importable functions (build-auth-url, exchange-code, refresh, revoke, get-valid-token). Do **not** scaffold a new project. First scan the workspace for the build manifest, existing QBO/HTTP client code, and any existing auth/token storage; state your finding in one sentence and match the project's idioms (esp. its secret-storage and routing patterns). + +--- + +## Language-Specific SDK Notes + +{{sdk_notes}} + +> If no SDK notes appear above, no official Intuit OAuth client is wired in for your language. Intuit publishes OAuth client libraries for several languages (e.g. `intuit-oauth` for Node.js, `intuitlib`/`intuit-oauth` for Python, the Java/.NET/PHP SDKs include OAuth helpers) — prefer the official client where one exists; otherwise implement the flow with your HTTP client exactly as described above. + +--- + +## 🛑 AI Guardrails (Anti-Hallucination Constraints) + +**CRITICAL INSTRUCTIONS — YOU MUST ADHERE TO THE FOLLOWING:** +1. **No Hallucinations:** Use only the endpoints and params given here (or read live from the discovery doc). Do not invent endpoints, params, or response fields. +2. **Exact endpoints:** Authorization = `{{oauth_authorization_endpoint}}`; Token (exchange + refresh) = `{{oauth_token_endpoint}}`; Revoke = `{{oauth_revocation_endpoint}}`. Token endpoint is the same for prod and sandbox. +3. **Refresh-token rotation:** Always persist the newest `refresh_token` from every token response. Never assume it stays constant. +3b. **Hard-expiry field name:** To read the 5-year ceiling, send the `{{oauth_hard_expiry_request_header}}: true` request header and read **`{{oauth_hard_expiry_response_field}}`** from the response. NEVER use `x_refresh_token_lifetime_expires_in` (a known-wrong name). The hard ceiling is NOT extended by refreshing — only re-auth resets it. +4. **`state` is required:** Generate a random `state`, store it, and verify it on callback (CSRF). Never skip it. +5. **`redirect_uri` must match exactly** on the auth request, the code exchange, and the portal registration. +6. **Basic auth on the token endpoint:** `Authorization: Basic base64(client_id:client_secret)` with `Content-Type: application/x-www-form-urlencoded`. +7. **Never log or commit secrets:** not `client_secret`, tokens, or the auth `code`. No tokens in URLs. +8. **No iframe for consent:** the consent screen cannot be framed — use a redirect or popup. +9. **`invalid_grant` = dead connection:** re-authorize; do not retry in a loop. +10. **Stop if Blocked:** if a needed value isn't covered here or in the discovery doc, STOP and state what's missing instead of guessing. + +I have provided you with all the necessary context and instructions. Please generate the code and documentation as per the instructions. diff --git a/discover/project-budgets/README.md b/discover/project-budgets/README.md new file mode 100644 index 0000000..077c2f6 --- /dev/null +++ b/discover/project-budgets/README.md @@ -0,0 +1,104 @@ +# Project Budgets Prompt + +Generates AI-ready prompts that produce a runnable integration for the **QuickBooks Project Budget API** (Use Case 6) — verifying a company is eligible for projects, discovering or creating a project, then creating, reading (with pagination), updating, and deleting a project-linked budget via the Business Planning GraphQL API. + +> ℹ️ **Available only for Intuit Enterprise Suite (IES) and QuickBooks Online Advanced.** Project Budgets are not supported on QBO Plus or non-US regions. The generated capability check halts early with a user-friendly error if the company isn't eligible. + +## Folder Contents + +``` +project-budgets/ +├── README.md # This file +└── prompt-template-project-budgets.md # The prompt template (do not edit unless updating the workflow) +``` + +The template lives here; the merge script and config live one level up in `discover/`. + +## Authentication & `.env` Setup + +The generated code assumes the caller already has a valid **OAuth 2.0 access token** and **realmId** available in a `.env` file. The prompt is intentionally scoped to API integration logic — it does **not** generate an OAuth client. + +### Required `.env` variables + +```bash +QBO_ACCESS_TOKEN= +QBO_REALM_ID= +QBO_MINOR_VERSION=75 +QBO_ENV=production # or "sandbox" +``` + +### Environment endpoints + +| Env | GraphQL host | REST host | +|---|---|---| +| Production | `https://qb.api.intuit.com/graphql` | `quickbooks.api.intuit.com` | +| Sandbox | `https://qb-sandbox.api.intuit.com/graphql` | `sandbox-quickbooks.api.intuit.com` | + +### How to get an access token + +Use the **[Intuit OAuth 2.0 Playground](https://developer.intuit.com/app/developer/playground)** to generate an access token (sandbox or production). Paste the token into `.env` and run the generated app. + +### Required OAuth Scopes + +| Scope | Used For | +|---|---| +| `project-management.project` | Reading/creating projects via the Projects GraphQL API | +| `com.intuit.quickbooks.accounting` | CompanyInfo + Preferences pre-flight checks, and Business Planning GraphQL access | + +A missing scope or disabled Projects feature surfaces as `403 Forbidden` — the generated capability check halts early with a user-friendly message pointing the developer at QuickBooks Settings > Account and Settings > Projects. + +## What the Generated Code Does + +| Task | Layer | Purpose | +|---|---|---| +| 1. Pre-flight + Discover | REST + GraphQL | Verify the company is QBO Advanced/IES + US + ProjectsEnabled. Then list existing projects via `projectManagementProjects`, or create one via `projectManagementCreateProject` if none exist. | +| 2. Create Budget | GraphQL | Call `businessPlanningCreateBudget` with `budgetType="PROJECT"` and `linkedEntityId=` plus the configured line items. Capture `budgetId` and the **server-assigned** line `sequenceId`s. (There is **no `syncToken`** on `BusinessPlanning_Budget`.) | +| 3. Read & Hydrate | GraphQL | Read the budget back via `businessPlanningBudget` (with `budgetDetailsPaginated` for long line lists), then map `linkedEntityId` to the human-readable project name for display. | +| 4. Update | GraphQL | `businessPlanningUpdateBudget` — updates **MERGE by `sequenceId`** (send only the lines you change; omitting a line leaves it unchanged, it is NOT deleted). Delete a single line by sending it with its server-assigned `sequenceId` and `deleted: true`. `total` is server-computed. No `syncToken`. | +| 5. Delete | GraphQL | `businessPlanningDeleteBudgets` accepts a list of `budgetIds` and returns a status per ID (hard delete). | + +## The "PROJECT-Type Only" Rule (Non-Negotiable) + +Only budgets with `budgetType="PROJECT"` can carry `linkedEntityId` pointing to a project. Sending any other type silently creates a non-project budget that **will not** roll up in the QuickBooks Projects dashboard. + +| Field | Required Value | +|---|---| +| `budgetType` | `"PROJECT"` | +| `linkedEntityId` | A project ID returned by `projectManagementProjects` or `projectManagementCreateProject` | + +## Generating a Prompt + +From the `discover/` directory: + +```bash +# Default (uses prompt-config.json) +node merge-prompt.js + +# Language-specific +node merge-prompt.js --language java +node merge-prompt.js --language python +node merge-prompt.js --language nodejs +``` + +Choose option **5** at the menu prompt. Output is written to `generated-prompts/project-budgets-ready-prompt.md`. Paste that file into your AI coding assistant (Copilot, Cursor, ChatGPT, Windsurf) to scaffold the integration project. + +## Common Errors + +| Code | Most Likely Cause | +|---|---| +| `400` w/ "Project not found" | Passed a non-project ID (e.g. a customer ID) as `linkedEntityId` | +| `200` w/ `PNB-INPUT-87` | Project uses the **Basic** cost method — budgets require a non-Basic (Advanced) cost method | +| `200` w/ `PNB-INPUT-72` | Project already has a budget (1:1) — only one budget per project | +| `200` w/ `PNB-INPUT-85` | Attempted `LOCKED → DRAFT` — forbidden; create as `DRAFT` explicitly if needed | +| `400` `GRAPHQL_VALIDATION_FAILED` | Selected a field that doesn't exist (e.g. `syncToken`, `clientMutationId`) — select only fields on `BusinessPlanning_Budget` | +| `401` | Expired access token — refresh in the [OAuth Playground](https://developer.intuit.com/app/developer/playground) | +| `403` | Missing scope, or company isn't IES/Advanced, or Projects feature disabled | +| `200` with `errors` array | GraphQL partial failure — always inspect the response body, even on HTTP 200 | + +## Reference + +- Project Budget use case (UC6): https://developer.intuit.com/app/developer/qbo/docs/workflows/manage-projects/use-cases#use-case-6 +- `businessPlanningCreateBudget`: https://developer.intuit.com/app/developer/gql/docs/api/qbexternal/mutations/businessPlanningCreateBudget +- `businessPlanningUpdateBudget`: https://developer.intuit.com/app/developer/gql/docs/api/qbexternal/mutations/businessPlanningUpdateBudget +- `businessPlanningDeleteBudgets`: https://developer.intuit.com/app/developer/gql/docs/api/qbexternal/mutations/businessPlanningDeleteBudgets +- `businessPlanningBudget` (query): https://developer.intuit.com/app/developer/gql/docs/api/qbexternal/queries/businessPlanningBudget diff --git a/discover/project-budgets/prompt-template-project-budgets.md b/discover/project-budgets/prompt-template-project-budgets.md new file mode 100644 index 0000000..b9efa7f --- /dev/null +++ b/discover/project-budgets/prompt-template-project-budgets.md @@ -0,0 +1,250 @@ +**Role:** You are a Principal Software Engineer specializing in Intuit Enterprise Suite (IES) integrations. + +**Context:** I am developing a `{{language_framework}}` application. I need to implement a workflow that uses the Projects + Business Planning GraphQL APIs to create, read, update, and delete a **Project Budget** for IES or QuickBooks Advanced companies. Assume the application already has a valid OAuth 2.0 access token, realmId (Company ID), and environment (production or sandbox) available in the `.env` file. Focus strictly on the API integration logic. + +--- + +## Task 1: Pre-flight & Discovery + +**Capability Check:** Before making any Projects or Budget API calls, verify the connected QuickBooks company meets all three prerequisites below. Run these checks in order and stop at the first failure. + +#### Check 1 — Account Type (REST) +Query the CompanyInfo entity and iterate over the `NameValue` list to find the entry where `Name` equals `"OfferingSku"`. The company is eligible if the `Value` is `"QuickBooks Online Advanced"` **or** indicates Intuit Enterprise Suite. If the condition is not met, trigger a user-friendly error: +> "Project Budgets are only available for Intuit Enterprise Suite and QuickBooks Advanced accounts." + +#### Check 2 — Country (REST) +In the same CompanyInfo response, verify `"Country": "US"`. If the country is not `"US"`, trigger a user-friendly error: +> "Project Budgets are only available for US-based QuickBooks accounts." + +#### Check 3 — Projects Preference (REST) +Query Company Preferences and iterate over `Preferences.OtherPrefs.NameValue`. Find the entry where `Name` equals `"ProjectsEnabled"` and confirm its `Value` is `"true"`. If the entry is missing or its value is not `"true"`, trigger a user-friendly error: +> "Projects are not enabled for this QuickBooks account." + +Expected entry in the Preferences response: +```json +{ + "Name": "ProjectsEnabled", + "Value": "true" +} +``` + +- **Endpoints:** `{{company_info_rest_v3_api_endpoint}}` and `{{company_preferences_rest_v3_api_endpoint}}` + +> **Two hosts:** the Task 1 pre-flight endpoints above are **REST v3** — prepend the REST host (Production `https://quickbooks.api.intuit.com`, Sandbox `https://sandbox-quickbooks.api.intuit.com`, select by `QBO_ENV`). Every budget operation in Tasks 2–5 is **GraphQL** on `{{graphql_endpoint_production}}` / `{{graphql_endpoint_sandbox}}`. Do not send the pre-flight REST calls to the GraphQL host or vice-versa. + +#### Check 4 — Project cost method (per-project prerequisite) +A budget can only be created for a project whose **cost method is NOT `Basic`** (i.e. the project uses the Advanced cost method). Creating a budget for a Basic-cost-method project fails (HTTP 200) with `errorCode: "PNB-INPUT-87"` / `PROJECT_COST_METHOD_IS_BASIC` ("Project budgets are not supported for projects with Basic cost method"). This is a **per-project** check — verify it for the specific project you intend to budget before Task 2. + +### Discovery Flow (two-step, implement in this order): + +#### Step A — Fetch projects for the company +Use `projectManagementProjects` to get projects for the company using the GraphQL endpoint. +- **Extract:** `id`, `name`, `status`, and `customer{id}` from each node. +- **Query:** `{{projects_discovery_query}}` +- Display the list of projects to the user. Count the number of projects received. Store the `id` of any single project — this value is required for creating a Project Budget in Task 2 (used as `linkedEntityId`). +- **Empty State Handling:** If zero projects are returned, log a warning: *"No projects found — proceeding to create a new project via GraphQL (Step B)."* Then continue to Step B. +- **GraphQL Endpoint:** `{{graphql_endpoint_production}}` or `{{graphql_endpoint_sandbox}}`. Refer to `{{graphql_schema}}`. + +#### Step B — Create project (conditional) +> **Execute this step only if Step A returned zero projects.** Otherwise, skip directly to Task 2. + +Use `projectManagementCreateProject` to create a project for the company using the GraphQL endpoint. For the mandatory parameters (`name`, `customer`, `status`), prompt the user to provide the values. Create the project using the values provided by the user. +Store the `id` of the created project — this value is required for creating a Project Budget in Task 2 (used as `linkedEntityId`). +- **Mutation:** `{{project_creation_query}}` +- **GraphQL Endpoint:** `{{graphql_endpoint_production}}` or `{{graphql_endpoint_sandbox}}`. Refer to `{{graphql_schema}}`. + +--- + +## Task 2: Create the Project Budget (GraphQL) + +Use the `projectId` obtained from Task 1 (Step A or Step B) as `linkedEntityId` to create the budget. + +{{project_budget_creation_instructions}} + +### Data Flow for the Budget Mutation: +- **`linkedEntityId`**: `id` from `projectManagementProjects` (`node.id`) **OR** `id` from the created project in Step B. +- **`budgetType`**: MUST be `"PROJECT"`. Any other value will be rejected by the validator and will not link to a project. +- **`startDate` / `endDate`**: ISO-8601 date strings (`YYYY-MM-DD`). +- **`total`**: **Server-computed** as the sum of `budgetDetails[*].amount`. Any `total` you send in the input is **ignored** — read the computed value back from the response; never treat a caller-supplied `total` as authoritative. +- **`budgetDetails[*]`**: Each line carries `sequenceId`, `order`, `type` (`ITEM`), `itemId`, `unitCost`, `quantity`, `amount`, `description`, `date`, `accountId`, and optionally `klassId` / `locationId`. **The server ASSIGNS each line's `sequenceId` on create and ignores whatever you send** — to update or delete a specific line later you must reuse the *server-assigned* `sequenceId` from a Task 3 read (or the create response), not your input value. +- **`state`** (optional): `"DRAFT"` or `"LOCKED"`. If omitted, the server defaults to **`LOCKED`** (not `DRAFT`), and `LOCKED → DRAFT` is a forbidden one-way transition (see Task 4). Send `state: "DRAFT"` explicitly at create time if you need a draft. + +### API Details: +- **Mutation:** `businessPlanningCreateBudget` +- **GraphQL Endpoint:** `{{graphql_endpoint_production}}` or `{{graphql_endpoint_sandbox}}`. Refer to `{{graphql_schema}}`. +- **Documentation:** Refer to `{{project_budget_documentation}}`. + +### Mutation Skeleton: +```graphql +mutation businessPlanningCreateBudget($budgetInput: BusinessPlanning_BudgetInput!) { + businessPlanningCreateBudget(budgetInput: $budgetInput) { + budget { + budgetId + budgetName + budgetType + startDate + endDate + linkedEntityId + total + state + budgetMetaData { createdBy createdAt } + budgetDetails { + sequenceId order type itemId unitCost quantity amount + description date accountId klassId locationId + } + } + } +} +``` + +### Variables Payload (best-effort sample — verify against the live schema): + +> ⚠️ **Schema verification note:** The field names below match the documented response fields, but the `BusinessPlanning_BudgetInput` SDL is not publicly published. If the server returns an `INPUT_VALIDATION` or `GRAPHQL_VALIDATION_FAILED` error, inspect `errors[0].message` for the canonical field name and adjust. Do **NOT** silently swap field names without surfacing the underlying error. + +```json +{{project_budget_create_variables_example}} +``` + +**Constraints:** +- `budgetType` MUST be `"PROJECT"` — any other value silently creates a non-project budget that won't roll up into the Projects dashboard. +- `linkedEntityId` MUST reference the `projectManagementProject.id` returned by the Project API. Never pass a customer ID or the project's underlying REST sub-customer/Job id here. +- `budgetType` should be a private constant inside the budget-service module, NOT a public method parameter — callers must not be able to override it. +- **One budget per project (1:1).** Creating a second budget for a project that already has one fails with `errorCode: "PNB-INPUT-72"` / `BUDGET_EXIST_WITH_SAME_LINKED_ENTITY_ID`. +- Store the returned `budgetId` **and the server-assigned line `sequenceId`s** — you need them for Tasks 4 and 5. There is **no list query** for budgets, so `budgetId` cannot be rediscovered later; persist it now. The `BusinessPlanning_Budget` type has **no `syncToken`** — do not expect, store, or send one. + +--- + +## Task 3: Read & Hydrate the Budget for UI + +Once the budget is created, read it back and display it to the user in a readable format. + +### Step A — Read the budget summary +- **Query:** `businessPlanningBudget(budgetId: $budgetId)` +- **Fetch:** `budgetId`, `budgetName`, `budgetType`, `startDate`, `endDate`, `linkedEntityId`, `total`, `state`, `budgetMetaData`, and a first page of `budgetDetails` (including each line's server-assigned `sequenceId`). **Do NOT select `syncToken`** — it does not exist on `BusinessPlanning_Budget` and selecting it fails with `GRAPHQL_VALIDATION_FAILED`. `businessPlanningBudget` requires `budgetId` (`ID!`). + +### Step B — Paginated read for long line-item lists +If `budgetDetails` is large, switch to the paginated field `budgetDetailsPaginated` using cursor-based traversal. Use the templated query below verbatim: + +```graphql +{{project_budget_details_paginated_query}} +``` + +Traversal rules: +- Start with `after: null` and a reasonable page size (e.g. `first: 50`). +- After each page, set `after = pageInfo.endCursor` and re-issue. +- Stop when `pageInfo.hasNextPage` is `false`. +- Concatenate all `edges[*].node` into the consolidated line list shown to the user. + +### Display Logic: +- Map `linkedEntityId` back to the project's human-readable `name` using the data cached from the GraphQL discovery in Task 1. +- Format the output to show: project name, budget name, start/end date, state, total, and line items with description, unit cost, quantity, amount, and account. +- Show pagination cursors only in verbose/debug mode — end-users should see a flat consolidated list. + +--- + +## Task 4: Update the Project Budget + +Use `businessPlanningUpdateBudget` to modify an existing budget — edit a line, add a line, or delete a line. + +**Required fields on every update:** +- `budgetId` — the budget's ID returned by Task 2. +- `budgetName` — required (`String!`); the update fails validation if it is omitted. +- There is **no `syncToken`** on this type — do NOT send one (it fails `GRAPHQL_VALIDATION_FAILED`). Do not port SyncToken/optimistic-concurrency patterns from the REST V3 entities to this GraphQL API. + +**Line-item semantics (verified live — this is NOT REST V3 full-replace):** +- `budgetDetails` updates **MERGE by `sequenceId`**; they do **not** replace the whole array. A line you leave out is left **unchanged**, NOT deleted. You only need to send the line(s) you are changing/deleting. +- **Edit a line:** re-send it with its **server-assigned** `sequenceId` (from a Task 3 read) and the new values. A `sequenceId` that doesn't match a stored line silently creates a NEW line (duplicate). +- **Add a line:** send a new line with a new, unused `sequenceId`; the server assigns the authoritative id — read it back afterward. +- **Delete a line:** re-send that line with its server-assigned `sequenceId` and **`deleted: true`**. This is the verified single-line-delete mechanism; the server removes the line and recomputes `total`. +- **`total`** is server-computed from the remaining line amounts — do not send it to change the amount; change the line amounts instead. +- **`state`:** `LOCKED → DRAFT` is forbidden — fails with `errorCode: "PNB-INPUT-85"` / `INVALID_LOCKED_STATE`. Because create defaults to `LOCKED`, a budget not explicitly created as `DRAFT` cannot be moved to `DRAFT`. + +### Mutation Skeleton: +```graphql +mutation businessPlanningUpdateBudget($budgetInput: BusinessPlanning_BudgetInput!) { + businessPlanningUpdateBudget(budgetInput: $budgetInput) { + budget { + budgetId budgetName total state + budgetMetaData { lastUpdatedBy updatedAt } + budgetDetails { + sequenceId order type itemId unitCost quantity amount + description date accountId klassId locationId + } + } + } +} +``` +> Do NOT select `syncToken` on the budget or `clientMutationId` on the payload — `BusinessPlanning_BudgetPayload` exposes only `budget`, and neither field exists (both fail `GRAPHQL_VALIDATION_FAILED`). + +### Variables Payload (best-effort sample — verify against the live schema): + +```json +{{project_budget_update_variables_example}} +``` + +--- + +## Task 5: Delete the Project Budget + +Use `businessPlanningDeleteBudgets` to remove one or more budgets. The mutation accepts a list, so a single deletion still passes an array. + +### Mutation Skeleton: +```graphql +mutation businessPlanningDeleteBudgets($budgetIdsInput: BusinessPlanning_BudgetIdsInput!) { + businessPlanningDeleteBudgets(budgetIdsInput: $budgetIdsInput) { + budgetResponseList { budgetId status description } + } +} +``` +> Do NOT select `clientMutationId` — `BusinessPlanning_BudgetResponsePayload` exposes only `budgetResponseList`. + +### Variables Payload (best-effort sample — verify against the live schema): + +```json +{{project_budget_delete_variables_example}} +``` + +Inspect each `budgetResponseList[*].status` (boolean, `true` on success); `description` is the verified live text `"Delete budget successful."`. Delete is a **hard delete** — any subsequent operation on that `budgetId` (including a read) fails with `errorCode: "PNB-INPUT-024"` / `BUDGET_DELETED`. + +--- + +## Non-Goals (do NOT include in the generated code) + +To keep the generated integration focused and avoid scope creep, the following are **out of scope** for this prompt: + +- **No OAuth client / token-refresh flow** — the integration assumes a valid access token is already present in `.env`. +- **No UI / web framework** — do not scaffold Spring Boot, Express, Flask, or any HTTP server. The deliverable is a CLI/library, not a web app. +- **No persistent database** — do not introduce JPA, Hibernate, SQLAlchemy, Mongoose, etc. Hold state in memory for the duration of the run. +- **No Intuit official SDK for the GraphQL `businessPlanning*` operations** — those SDKs do not expose typed bindings for these mutations. Use raw HTTP (e.g. Java `HttpClient`, Python `httpx`, Node `fetch`) for all GraphQL calls in this prompt. +- **Required artifacts only**: source files, a `README.md`, a dependency manifest (`build.gradle` / `requirements.txt` / `package.json` / etc.), a runnable entry point that exercises Tasks 1–5 end-to-end, and a `.env.example`. + +## Technical Best Practices: +- **Error Handling:** Include specific error-handling blocks for: + - `401 Unauthorized` — prompt token refresh. + - `GRAPHQL_VALIDATION_FAILED` (HTTP 400) — a selected field doesn't exist (e.g. `syncToken`, `clientMutationId`) or a required arg is missing (e.g. `businessPlanningBudget` without `budgetId`). Select only fields that exist on `BusinessPlanning_Budget`. + - **GraphQL errors on HTTP 200** — business/validation errors return 200 with `data: null` and an `errors[]` array carrying `extensions.errorCode`. Common `budgeting-service` codes: `PNB-INPUT-72` (`BUDGET_EXIST_WITH_SAME_LINKED_ENTITY_ID` — project already has a budget), `PNB-INPUT-75` (`INVALID_BUDGET_PROJECT_ID` — bad/unknown `linkedEntityId`), `PNB-INPUT-85` (`INVALID_LOCKED_STATE` — `LOCKED → DRAFT`), `PNB-INPUT-87` (`PROJECT_COST_METHOD_IS_BASIC` — project uses Basic cost method), `PNB-INPUT-024` (`BUDGET_DELETED` — operating on a deleted budget). Also `PNB-PCBM-008` / `REALM_SKU_DOES_NOT_MATCH` — company not Advanced+Construction Pack / IES (Task 1 pre-flight should catch this first). +- **Observability:** Include structured logging. You **MUST** capture and log the `intuit_tid` header from every Intuit API response for traceability. **NEVER** log access tokens, OAuth secrets, or PII. +- **Output (integration mode: `{{integration_mode}}`):** Provide modular, clean code and a runnable verification example. + - **If mode is `new`:** Create a self-contained project in a dedicated folder named `project-budgets-{{language_framework}}` (no spaces). Include a `README.md` explaining how to run the code, a dependency manifest, and a brief architectural diagram showing the data flow from Projects GraphQL → Business Planning GraphQL. + - **If mode is `existing`:** Produce modular, well-documented functions/classes/files designed to be imported into an existing codebase. Do **not** scaffold a new project structure. Provide clear integration notes describing which files to add, what imports are needed, and how to wire the functions into an existing app. + +--- + +## 🛑 AI Guardrails (Anti-Hallucination Constraints) + +**CRITICAL INSTRUCTIONS - YOU MUST ADHERE TO THE FOLLOWING:** +1. **No Hallucinations:** Do not invent, guess, or hallucinate API endpoints, GraphQL properties, or SDK methods not explicitly provided here or in the linked documentation. The operations are exactly `businessPlanningCreateBudget`, `businessPlanningBudget`, `businessPlanningUpdateBudget`, and `businessPlanningDeleteBudgets`. +2. **Strict SDK/Library Usage:** If an official SDK or library is specified, use ONLY methods/classes that exist in its latest public release. There is no typed SDK binding for these `businessPlanning*` GraphQL mutations — build the request as a plain object and POST via a plain HTTP client. +3. **Provided Links Only:** Derive all API syntax, structure, and constraints strictly from the provided links and this template. +4. **Endpoint Strictness:** Budget CRUD is GraphQL (`{{graphql_endpoint_production}}` / `{{graphql_endpoint_sandbox}}`); the Task 1 pre-flight is REST v3 (`https://quickbooks.api.intuit.com` / `https://sandbox-quickbooks.api.intuit.com`). Do not send one to the other's host, and do not generate a REST budget endpoint — there is no REST budget entity. +5. **No `syncToken`:** `BusinessPlanning_Budget` has no `syncToken`. Do not select it in a query or send it in an update — it fails `GRAPHQL_VALIDATION_FAILED`. Do NOT port optimistic-concurrency patterns from REST V3. +6. **No `clientMutationId`:** create/update payloads expose only `budget`; delete exposes only `budgetResponseList`. Do not select `clientMutationId`. +7. **`sequenceId` is server-assigned:** the server assigns line `sequenceId`s and ignores yours on create. On update/delete, always send the exact server-assigned `sequenceId` from a read — a mismatch silently creates a new line. +8. **Line updates MERGE, they do not full-replace:** omitting a line leaves it unchanged (NOT deleted). To delete a line, send it with its `sequenceId` and `deleted: true`. Never assume omission deletes. +9. **`total` is server-computed:** it is the sum of `budgetDetails[*].amount`; the input `total` is ignored. To change the total, change the line amounts. +10. **`state` rules:** create defaults to `LOCKED` (not `DRAFT`); `LOCKED → DRAFT` is forbidden (`PNB-INPUT-85`). Only emit `DRAFT` or `LOCKED`. +11. **`budgetType` MUST be `"PROJECT"`** and `linkedEntityId` MUST be the `projectManagementProject.id` (not a customer/Job id). One budget per project (1:1). +12. **GraphQL errors on 200:** never treat HTTP 200 as unconditional success — inspect `errors[]` (business/validation errors return 200 with `data: null`) and surface `extensions.errorCode`. +13. **If Blocked/Missing Info:** if required fields to compile a functional request are missing, STOP and state what's missing instead of guessing. + +I have provided you with all the necessary context and instructions. Please generate the code and documentation as per the instructions. diff --git a/discover/project-change-orders/README.md b/discover/project-change-orders/README.md new file mode 100644 index 0000000..b6f81f6 --- /dev/null +++ b/discover/project-change-orders/README.md @@ -0,0 +1,105 @@ +# Project Change Orders Prompt + +Generates AI-ready prompts that produce a runnable integration for the **QuickBooks Project Change Order API** (Use Case 7) — verifying eligibility, discovering or creating a project + parent project estimate, then creating, reading, updating, and deleting a project-scoped change order via the Accounting REST V3 `/changeorder` endpoint. + +> ℹ️ **Available only for Intuit Enterprise Suite (IES) or QuickBooks Online Advanced with the Construction Pack add-on.** Change orders are not supported on standard QBO Advanced, QBO Plus, or non-US regions. The generated capability check halts early with a user-friendly error if the company isn't eligible. + +## Folder Contents + +``` +project-change-orders/ +├── README.md # This file +└── prompt-template-project-change-orders.md # The prompt template (do not edit unless updating the workflow) +``` + +The template lives here; the merge script and config live one level up in `discover/`. + +## Authentication & `.env` Setup + +The generated code assumes the caller already has a valid **OAuth 2.0 access token** and **realmId** available in a `.env` file. The prompt is intentionally scoped to API integration logic — it does **not** generate an OAuth client. + +### Required `.env` variables + +```bash +QBO_ACCESS_TOKEN= +QBO_REALM_ID= +QBO_MINOR_VERSION=75 +QBO_ENV=production # or "sandbox" +``` + +### Environment endpoints + +| Env | GraphQL host | REST host | +|---|---|---| +| Production | `https://qb.api.intuit.com/graphql` | `quickbooks.api.intuit.com` | +| Sandbox | `https://qb-sandbox.api.intuit.com/graphql` | `sandbox-quickbooks.api.intuit.com` (change order sandbox) | + +### How to get an access token + +Use the **[Intuit OAuth 2.0 Playground](https://developer.intuit.com/app/developer/playground)** to generate an access token (sandbox or production). Paste the token into `.env` and run the generated app. + +### Required OAuth Scopes + +| Scope | Used For | +|---|---| +| `project-management.project` | Reading/creating projects via the Projects GraphQL API | +| `com.intuit.quickbooks.accounting` | CompanyInfo + Preferences pre-flight, parent-estimate discovery/creation, and ChangeOrder CRUD via REST V3 | + +A missing scope or disabled Projects feature surfaces as `403 Forbidden` — the generated capability check halts early with a user-friendly message pointing the developer at QuickBooks Settings > Account and Settings > Projects. + +## What the Generated Code Does + +| Task | Layer | Purpose | +|---|---|---| +| 1. Pre-flight + Discover | REST + GraphQL | Verify the company is IES or QBO Advanced (Construction Pack) + US + ProjectsEnabled. List/create a project. Then query for an existing project estimate; if none, create one (reusing the markup logic from the Project Estimates prompt). | +| 2. Create Change Order | REST V3 | `POST /v3/company/{realm}/changeorder` with `ProjectRef`, `CustomerRef`, top-level `LinkedTxn` to the parent estimate, AND `LinkedTxn` re-included on every line. | +| 3. Read & Hydrate | REST V3 | `GET /changeorder/{id}` and map `ProjectRef.value` + `LinkedTxn[*].TxnId` back to human-readable names. | +| 4. Update | REST V3 | `sparse: true` at the top level, but the `Line` array is full-replace — always send the complete desired line state with `LinkedTxn` re-included on every line. | +| 5. Delete | REST V3 | `POST /changeorder?operation=delete` with `Id` + current `SyncToken`. The parent estimate is unaffected; the project's contracted total is recalculated. | + +## The `LinkedTxn` Rule (verified live) + +A change order must carry a `LinkedTxn` (Estimate reference) — **at minimum at the top level.** The **top-level `LinkedTxn` is authoritative**: the server applies its `TxnId` to every line and auto-propagates it onto any line that omits its own. Including it on every line is still recommended for clarity, but a per-line omission is **not** rejected while a top-level `LinkedTxn` is present. + +| Field | Required Value | +|---|---| +| Top-level `ProjectRef.value` | Project ID from `projectManagementProjects` | +| Top-level `LinkedTxn[0]` | `{ "TxnId": "", "TxnType": "Estimate" }` — **required; authoritative** | +| Each line's `LinkedTxn[0]` | Recommended; if omitted, the server backfills the top-level value | +| `TotalAmt` | **Omit** — read-only, computed by the server from line `Amount` values | + +Only **total absence** of `LinkedTxn` (no top-level AND no line) is rejected — with error `2020` ("Required parameter LinkedTxn with an Estimate reference is required"). Differing per-line `TxnId`s do **not** error; the server silently normalizes every line to the top-level `TxnId`, so a change order always references exactly one parent estimate. + +## Generating a Prompt + +From the `discover/` directory: + +```bash +# Default (uses prompt-config.json) +node merge-prompt.js + +# Language-specific +node merge-prompt.js --language java +node merge-prompt.js --language python +node merge-prompt.js --language nodejs +``` + +Choose option **6** at the menu prompt. Output is written to `generated-prompts/project-change-orders-ready-prompt.md`. Paste that file into your AI coding assistant (Copilot, Cursor, ChatGPT, Windsurf) to scaffold the integration project. + +## Common Errors + +| Code | Most Likely Cause | +|---|---| +| `400` / `2020` (Required param missing) | **Total absence** of `LinkedTxn` — none at the top level AND none on any line. A per-line omission is NOT an error (the server backfills it from the top-level `LinkedTxn`). | +| `400` / `5010` (Stale Object Error) | The `SyncToken` sent on update/delete is not current — re-read the change order, then retry once. | +| `400` w/ "Parent estimate has no ProjectRef" | The parent estimate referenced by `LinkedTxn` is a standalone (non-project) estimate | +| `400` w/ `TotalAmt` | `TotalAmt` is read-only — remove it from the request body | +| `400` after update | The `Line` array does not support sparse updates — always read-modify-write the complete array | +| `401` | Expired access token — refresh in the [OAuth Playground](https://developer.intuit.com/app/developer/playground) | +| `403` | Missing scope, or company isn't IES/QBO Advanced+Construction Pack, or Projects feature disabled | + +## Reference + +- Project Change Order use case (UC7): https://developer.intuit.com/app/developer/qbo/docs/workflows/manage-projects/use-cases#use-case-7 +- Project change order error codes: https://developer.intuit.com/app/developer/qbo/docs/develop/troubleshooting/error-codes#project-change-order-error-codes +- Accounting REST V3 reference: https://developer.intuit.com/app/developer/qbo/docs/api/accounting/all-entities/account diff --git a/discover/project-change-orders/prompt-template-project-change-orders.md b/discover/project-change-orders/prompt-template-project-change-orders.md new file mode 100644 index 0000000..620f86b --- /dev/null +++ b/discover/project-change-orders/prompt-template-project-change-orders.md @@ -0,0 +1,200 @@ +**Role:** You are a Principal Software Engineer specializing in Intuit Enterprise Suite (IES) integrations. + +**Context:** I am developing a `{{language_framework}}` application. I need to implement a workflow that uses the Projects GraphQL API and the Accounting REST V3 ChangeOrder endpoint to create a **Project Change Order** linked to an existing project estimate. This works for IES or QuickBooks Online Advanced (with the Construction Pack add-on) companies. Assume the application already has a valid OAuth 2.0 access token, realmId (Company ID), and environment (production or sandbox) available in the `.env` file. Focus strictly on the API integration logic. + +**Hosts (select by `QBO_ENV`) — this workflow uses TWO hosts:** +- **REST V3 base URL** (CompanyInfo + Preferences pre-flight in Task 1, and the ChangeOrder entity in Tasks 2–5): + - Production: `https://{{rest_baseurl_production}}` + - Sandbox: `https://{{rest_baseurl_sandbox}}` +- **GraphQL endpoint** (`projectManagement*` discovery in Task 1): + - Production: `{{graphql_endpoint_production}}` + - Sandbox: `{{graphql_endpoint_sandbox}}` + +REST v3 paths below (e.g. the `POST /v3/company/.../changeorder` endpoint) are relative to the REST base URL; `projectManagement*` operations POST to the GraphQL endpoint. Do not modify either host. + +--- + +## Task 1: Pre-flight & Discovery + +**Capability Check:** Before making any Projects or ChangeOrder API calls, verify the connected QuickBooks company meets all three prerequisites below. Run these checks in order and stop at the first failure. + +#### Check 1 — Account Type (REST) +Query the CompanyInfo entity and iterate over the `NameValue` list to find the entry where `Name` equals `"OfferingSku"`. The company is eligible if the `Value` is `"QuickBooks Online Advanced"` (with Construction Pack add-on) **or** indicates Intuit Enterprise Suite. If the condition is not met, trigger a user-friendly error: +> "Project Change Orders are only available for Intuit Enterprise Suite or QuickBooks Advanced (Construction Pack) accounts." + +#### Check 2 — Country (REST) +In the same CompanyInfo response, verify `"Country": "US"`. If the country is not `"US"`, trigger a user-friendly error: +> "Project Change Orders are only available for US-based QuickBooks accounts." + +#### Check 3 — Projects Preference (REST) +Query Company Preferences and iterate over `Preferences.OtherPrefs.NameValue`. Find the entry where `Name` equals `"ProjectsEnabled"` and confirm its `Value` is `"true"`. If the entry is missing or its value is not `"true"`, trigger a user-friendly error: +> "Projects are not enabled for this QuickBooks account." + +Expected entry in the Preferences response: +```json +{ + "Name": "ProjectsEnabled", + "Value": "true" +} +``` + +- **Endpoints:** `{{company_info_rest_v3_api_endpoint}}` and `{{company_preferences_rest_v3_api_endpoint}}` + +### Discovery Flow (three-step, implement in this order): + +#### Step A — Fetch projects for the company +Use `projectManagementProjects` to get projects for the company using the GraphQL endpoint. +- **Extract:** `id`, `name`, `status`, and `customer{id}` from each node. +- **Query:** `{{projects_discovery_query}}` +- Display the list of projects to the user. Store the `id` of any single project and its associated `customer{id}` — these values are required for Step B and Task 2. +- **Empty State Handling:** If zero projects are returned, log a warning: *"No projects found — proceeding to create a new project via GraphQL (Step B-1)."* Then run a project creation step before continuing to Step B. +- **GraphQL Endpoint:** `{{graphql_endpoint_production}}` or `{{graphql_endpoint_sandbox}}`. Refer to `{{graphql_schema}}`. + +##### Step A-1 — Create project (conditional) +> **Execute this step only if Step A returned zero projects.** Otherwise, skip to Step B. + +Use `projectManagementCreateProject`. For the mandatory parameters (`name`, `customer`, `status`), prompt the user. Store the returned `id` and `customer{id}`. +- **Mutation:** `{{project_creation_query}}` + +#### Step B — Find or create the parent project estimate +A change order MUST be linked to an existing project estimate (an Estimate entity that already carries a `ProjectRef` value pointing to the selected project). Run this step in order: + +1. **Query existing estimates for the project (REST V3):** + `GET /v3/company/{{companyid}}/query?query=select * from Estimate where ProjectRef = ''&minorversion={{minorversion}}` + - If at least one Estimate is returned, store its `Id` as `parent_estimate_id` and continue to Task 2. +2. **If no project estimate exists, create one (REST V3):** + {{project_estimate_creation_instructions}} + - **Endpoint:** `{{transaction_v3_estimate_api_endpoint}}` + - The created Estimate request body MUST include both `ProjectRef.value = ` and `CustomerRef.value = ` at the top level so it is recognized as a project estimate. + - Store the returned `Id` as `parent_estimate_id`. + +**Constraint:** The parent estimate MUST itself carry a `ProjectRef`. Referencing a standalone (non-project) estimate from a change order will fail validation. + +--- + +## Task 2: Create the Project Change Order (REST V3) + +Use the `projectId`, `customerId`, and `parent_estimate_id` obtained from Task 1 to create the change order. + +{{project_change_order_creation_instructions}} + +### Data Flow for ProjectRef and LinkedTxn: +- **`projectId`**: from Task 1 Step A (or A-1). +- **`customerId`**: `customer{id}` associated with the project. +- **`parent_estimate_id`**: from Task 1 Step B. +- `ProjectRef.value` = `projectId` (top-level — mandatory). +- `CustomerRef.value` = `customerId` (top-level). +- `LinkedTxn`: array containing `{ "TxnId": "", "TxnType": "Estimate" }` at the top level (**required**), and **recommended** on each line item for clarity. If a line omits it, the server backfills it from the top-level value — see Constraints. + +### API Details: +- **Endpoint:** `{{project_change_order_v3_endpoint}}` +- **Documentation:** Refer to `{{changeorder_rest_v3_api_documentation}}` and `{{project_change_order_documentation}}`. + +### Payload Structure: +Ensure the ChangeOrder request body includes `ProjectRef`, `CustomerRef`, and `LinkedTxn` at the top level (the top-level `LinkedTxn` is required and authoritative). Including `LinkedTxn` on each line is recommended but not required — omitted lines inherit the top-level value (see Constraints). Skeleton: + +```json +{ + "TxnDate": "<>", + "TxnStatus": "Pending", + "DocNumber": "CO-001", + "ProjectRef": { "value": "<>" }, + "CustomerRef": { "value": "<>" }, + "LinkedTxn": [{ "TxnId": "<>", "TxnType": "Estimate" }], + "Line": [ + { + "LineNum": 1, + "Description": "", + "Amount": 0.00, + "DetailType": "SalesItemLineDetail", + "SalesItemLineDetail": { + "ItemRef": { "value": "" }, + "UnitPrice": 0.00, + "Qty": 0, + "TaxCodeRef": { "value": "NON" } + }, + "LinkedTxn": [{ "TxnId": "<>", "TxnType": "Estimate" }] + } + ] +} +``` + +**Constraints (verified live against production):** +- `LinkedTxn` (an Estimate reference) is **required — at minimum at the top level.** If a line omits its own `LinkedTxn`, the server **auto-propagates** the top-level `LinkedTxn` onto that line (confirmed: the stored line comes back *with* it). Including `LinkedTxn` on every line is still recommended for clarity, but a line missing it is **not** rejected while a top-level `LinkedTxn` is present. Only **total absence** (no top-level AND no line `LinkedTxn`) is rejected — with error code **`2020`** ("Required parameter LinkedTxn with an Estimate reference is required for ChangeOrder is missing"). +- **A change order references exactly one parent estimate, and the top-level `LinkedTxn` is authoritative:** the server applies its `TxnId` to every line, **silently overriding** any different per-line `TxnId` (e.g. a line sent with `TxnId:"7"` is stored as the top-level `TxnId:"8"`). This is silent normalization, NOT a validation error — so always intend a single parent and set it at the top level. +- `ProjectRef` at the top level is **mandatory**. Without it the change order will not appear in the Projects dashboard. +- `TotalAmt` is **read-only**. Do not include it in the request body — it is computed from the sum of line `Amount` values. The server also auto-appends a `SubTotalLineDetail` line in the response (exclude it from UI display). +- Use `minorversion={{minorversion}}` on create, update, and delete (POST) calls. + +--- + +## Task 3: Read & Hydrate the Change Order for UI + +Once the change order is created, display it to the user in a readable format. + +- **Fetch:** Retrieve the created ChangeOrder using `{{project_change_order_get_endpoint}}`. +- **Data Hydration:** The API response contains `ProjectRef.value` (project ID) and `LinkedTxn[*].TxnId` (parent estimate ID). Write a helper function that maps these IDs back to human-readable names using the data cached from the GraphQL discovery in Task 1. +- **Display Logic:** Format the output to show: change order DocNumber, status, project name, customer name, parent estimate ID, line items with description / quantity / unit price / Amount, and totals. +- **Line Filtering:** Only display product/service lines. Exclude system-generated lines (`SubTotalLineDetail`, `DiscountLineDetail`, `TaxLineDetail`) that QuickBooks adds automatically. + +--- + +## Task 4: Update the Change Order (sparse update + line full-replace) + +QuickBooks Online supports sparse updates at the top level (only changed fields modified) but the `Line` array is **always full-replace**. + +**Required fields on every update:** +- `Id` — the change order's ID returned by Task 2. +- `SyncToken` — concurrency token from the most recent read. +- `sparse: true` — enables top-level sparse mode. +- `LinkedTxn` at the top level — always re-include. +- The COMPLETE desired `Line` array, with `LinkedTxn` re-included on every line. Any line omitted will be deleted. + +**Recommended pattern:** +1. Re-read the change order (Task 3) to get the latest `SyncToken` and existing lines. +2. Apply changes locally (add/edit/remove lines, update status fields). +3. POST the full revised `Line` array along with the latest `SyncToken`. + +### Endpoint: +- `{{project_change_order_v3_endpoint}}` (same endpoint as create — body shape signals update via `Id` + `SyncToken`). + +--- + +## Task 5: Delete the Change Order + +Use `{{project_change_order_v3_endpoint}}&operation=delete` with `Id` + current `SyncToken` in the body. (The endpoint already carries `?minorversion=…`, so the delete flag is appended with `&`, not a second `?`.) + +**Behavior:** +- Deletion is **permanent** and irreversible. +- The parent estimate is **not** modified by the deletion. +- The project's contracted total is recalculated to remove the change order's contribution. +- A successful delete returns a **sparse** `ChangeOrder` object — `{"ChangeOrder": {"domain": "QBO", "status": "Deleted", "Id": ""}}`. It does **not** echo a `SyncToken` in the delete response. + +--- + +## Technical Best Practices: +- **Error Handling:** Include specific error-handling blocks for: + - `401 Unauthorized` — prompt token refresh. + - `400 Bad Request` (`Fault.Error[].code`) — verified codes: **`2020`** ("Required param missing") when there is **no `LinkedTxn`/Estimate reference anywhere** on the request (neither top-level nor any line); **`5010`** ("Stale Object Error") when the `SyncToken` sent on update/delete is not current — re-read (Task 3) and retry. Other causes: referencing a non-project parent estimate, missing `ProjectRef`, or sending read-only `TotalAmt`. Note: differing per-line `TxnId`s do **not** error — the server silently normalizes them to the top-level `LinkedTxn.TxnId`. + - Other project change order error codes — surface code and message per `{{project_change_order_error_codes_documentation}}`. +- **Observability:** Include structured logging. You **MUST** capture and log the `intuit_tid` header from every Intuit API response for traceability. **NEVER** log access tokens, OAuth secrets, or PII. +- **Output (integration mode: `{{integration_mode}}`):** Provide modular, clean code and a runnable verification example. + - **If mode is `new`:** Create a self-contained project in a dedicated folder named `project-change-orders-{{language_framework}}` (no spaces). Include a `README.md` explaining how to run the code, a dependency manifest, and a brief architectural diagram showing the data flow from Projects GraphQL → Estimate REST → ChangeOrder REST. + - **If mode is `existing`:** Produce modular, well-documented functions/classes/files designed to be imported into an existing codebase. Do **not** scaffold a new project structure. Provide clear integration notes describing which files to add, what imports are needed, and how to wire the functions into an existing app. + +--- + +## 🛑 AI Guardrails (Anti-Hallucination Constraints) + +**CRITICAL INSTRUCTIONS - YOU MUST ADHERE TO THE FOLLOWING:** +1. **No Hallucinations:** Do not invent, guess, or hallucinate API endpoints, GraphQL properties, REST fields, or SDK methods that are not explicitly provided in the context or linked documentation. +2. **Strict SDK/Library Usage:** If an official SDK or library is specified, use ONLY the methods and classes that exist in its latest public release. Do not construct fake SDK models. +3. **Provided Links Only:** You must derive all API syntax, structure, and constraints strictly from the provided links. +4. **Endpoint Strictness:** Use the exact endpoints and query structures provided. Do not modify the base URL or alter the `minorversion={{minorversion}}` requirement. +5. **LinkedTxn (Estimate ref) is required — at minimum at the top level.** Recommended: include it on every line for clarity. The server auto-propagates a top-level `LinkedTxn` onto lines that omit it, and it is authoritative (it overrides differing per-line `TxnId`s by silent normalization). Only *total* absence (no top-level and no line) fails, with error `2020`. Do not claim omitting it on a single line "degrades to a standalone estimate" — that is not the observed behavior. +6. **TotalAmt is Read-Only:** Never include `TotalAmt` in a create or update request body. The server computes it. +7. **No Sparse Line Updates:** The `Line` array does not support true sparse updates. Always read-modify-write the complete line array on update. +8. **One Parent Estimate per Change Order:** All lines must reference the same parent estimate `TxnId`. +9. **If Blocked/Missing Info:** If the provided documentation or payload structures lack required fields to compile a functional request, STOP and clearly state what specific information is missing instead of making an educated guess. + +I have provided you with all the necessary context and instructions. Please generate the code and documentation as per the instructions. diff --git a/discover/projects/prompt-template-projects.md b/discover/projects/prompt-template-projects.md index 3920033..1fca4c5 100644 --- a/discover/projects/prompt-template-projects.md +++ b/discover/projects/prompt-template-projects.md @@ -4,10 +4,20 @@ **References:** - Project Estimate documentation: `{{project-estimate-documentation}}` +- Estimate (REST V3) entity documentation: `{{estimate_rest_v3_api_documentation}}` - GraphQL schema reference: `{{graphql_schema}}` -- REST V3 API documentation: `{{rest_v3_api_documentation}}` - OAuth 2.0 documentation: `{{oauth2-documentation}}` +**Hosts (select by `QBO_ENV`) — this workflow uses TWO hosts:** +- **REST V3 base URL** (CompanyInfo + Preferences pre-flight, and the Estimate entity in Task 2): + - Production: `https://{{rest_baseurl_production}}` + - Sandbox: `https://{{rest_baseurl_sandbox}}` +- **GraphQL endpoint** (`projectManagement*` operations in Task 1): + - Production: `{{graphql_endpoint_production}}` + - Sandbox: `{{graphql_endpoint_sandbox}}` + +REST v3 paths below are relative to the REST base URL; `projectManagement*` operations POST to the GraphQL endpoint. Do not modify either host. + --- ## Task 1: Pre-flight & Discovery @@ -15,7 +25,7 @@ **Capability Check:** Before making any Projects or Estimate API calls, verify the connected QuickBooks company meets all three prerequisites below. Run these checks in order and stop at the first failure. #### Check 1 — Account Type (REST) -Query the CompanyInfo entity and iterate over the `NameValue` list to find the entry where `Name` equals `"OfferingSku"`. The company is eligible if the `Value` is `"QuickBooks Online Advanced"`. If the condition is not met, trigger a user-friendly error: +Query the CompanyInfo entity and iterate over the `NameValue` list to find the entry where `Name` equals `"OfferingSku"`. The company is eligible if the `Value` is `"QuickBooks Online Advanced"` **or** indicates Intuit Enterprise Suite. If the condition is not met, trigger a user-friendly error: > "Project Estimates are only available for Intuit Enterprise Suite and QuickBooks Advanced accounts." #### Check 2 — Country (REST) @@ -71,7 +81,7 @@ Use the `projectId` and `customerId` obtained from Task 1 (Step A or Step B) to ### API Details: - **Endpoint:** `{{transaction_v3_estimate_api_endpoint}}` -- **Documentation:** Refer to `{{rest_v3_api_documentation}}` and `{{project-estimate-documentation}}` +- **Documentation:** Refer to `{{estimate_rest_v3_api_documentation}}` and `{{project-estimate-documentation}}` ### Payload Structure: Ensure the Estimate request body includes **both** `ProjectRef` and `CustomerRef` at the top level, exactly as follows: diff --git a/discover/prompt-config-existing.json b/discover/prompt-config-existing.json new file mode 100644 index 0000000..bd1593e --- /dev/null +++ b/discover/prompt-config-existing.json @@ -0,0 +1,62 @@ +{ +"integration_mode": "existing", +"language_framework": "python", +"type_of_transaction": "salesreceipt", +"typing_system": "hints (dataclasses)", +"transaction_creation_instructions": "Create {{type_of_transaction}} with default item id : 1 and default customer : 1 and {{type_of_transaction}} amount: 111. ", +"markup_percentages": 30, +"project_estimate_creation_instructions": "Create an estimate with default item id : 1 ,UnitPrice: 1, Qty: 100, and ItemAccountRef=5. Amount=UnitPrice*Qty. CostAmount in the line should be {{markup_percentages}} percent more or less than the Amount. ", +"graphql_endpoint_production": "https://qb.api.intuit.com/graphql", +"graphql_endpoint_sandbox": "https://qb-sandbox.api.intuit.com/graphql", +"graphql_schema" : "https://developer.intuit.com/app/developer/gql/docs/api/qbexternal/queries/", +"create_dimension_qbo_ui" : "https://qbo.intuit.com/app/class", +"rest_baseurl_production": "quickbooks.api.intuit.com", +"rest_baseurl_sandbox": "sandbox-quickbooks.api.intuit.com", +"custom_extensions_structure": "[\n {\n \"CustomExtensions\": [\n {\n \"AssociatedValues\": [\n {\n \"Value\": \"{{Value_ID}}\",\n \"Key\": \"{{Definition_ID}}\"\n }\n ],\n \"ExtensionType\": \"DIMENSION\"\n }\n ]\n }\n]", +"transaction_v3_api_endpoint": "POST /v3/company/{{companyid}}/{{type_of_transaction}}?minorversion=75", +"transaction_v3_estimate_api_endpoint": "POST /v3/company/{{companyid}}/estimate?minorversion=75", +"get_transaction_endpoint": "GET /v3/company/{{companyid}}/{{type_of_transaction}}/{{TransactionId}}?minorversion=75", +"get_estimate_transaction_endpoint": "GET /v3/company/{{companyid}}/estimate/{{TransactionId}}", +"company_info_rest_v3_api_endpoint": "GET /v3/company/{{companyid}}/query?minorversion={{minorversion}}&query=select * from CompanyInfo", +"company_preferences_rest_v3_api_endpoint": "GET /v3/company/{{companyid}}/query?minorversion={{minorversion}}&query=select * from preferences", +"dimension_Api_documentation": "https://developer.intuit.com/app/developer/ies/docs/workflows/manage-dimensions/dimensions-use-cases", +"project-estimate-documentation": "https://developer.intuit.com/app/developer/qbo/docs/workflows/manage-projects/use-cases#use-case-5", +"rest_v3_api_documentation": "https://developer.intuit.com/app/developer/qbo/docs/api/accounting/all-entities/account", +"dimension_discovery_query": "query ActiveCustomDimensionDefinitionsQuery {\n appFoundationsActiveCustomDimensionDefinitions(\n first: 20\n after: null\n last: null\n before: null\n ) {\n edges {\n node {\n ...CustomDimensionDefinitionAttributes\n }\n }\n }\n}\nfragment CustomDimensionDefinitionAttributes on AppFoundations_CustomDimensionDefinition {\n id\n label\n active\n}", +"dimension_values_query": "query CustomDimensionsValuesQuery {\n appFoundationsActiveCustomDimensionValues(\n first: 20,\n filters: { definitionId: \"1000000002\", parentId: null }\n ) {\n edges {\n node {\n id\n definitionId\n fullyQualifiedLabel\n }\n }\n }\n}", +"projects_discovery_query": "{\"query\":\"query projectManagementProjects($first: PositiveInt!,$after: String,$filter: ProjectManagement_ProjectFilter!,$orderBy: [ProjectManagement_OrderBy!]){projectManagementProjects(first: $first,after: $after,filter: $filter,orderBy: $orderBy){edges{node{id,name,status,dueDate,customer{id},account{id}}}pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}}}\",\"variables\":{\"first\":4,\"filter\":{\"status\":{\"in\":[\"OPEN\",\"IN_PROGRESS\"]}},\"orderBy\":[\"DUE_DATE_ASC\"]}}", +"project_creation_query": "{\"query\":\"mutation ProjectManagementCreateProject($name: String!,$customer: ProjectManagement_CustomerInput,$status: ProjectManagement_Status){projectManagementCreateProject(input:{name: $name,customer: $customer,status: $status}){... on ProjectManagement_Project {id,name,customer{id},account{id},status}}}\",\"variables\":{\"name\":\"Red testing via API\",\"customer\":{\"id\":\"32\"},\"status\":\"OTHER\"}}", +"project_budget_creation_instructions": "Before creating the budget, dynamically discover Items via REST V3 so the generated code runs against ANY connected company (do NOT hard-code item or account IDs). Step (a): GET /v3/company/{{companyid}}/query?query=select * from Item maxresults 3&minorversion={{minorversion}} to fetch the first 3 active Items. Step (b): for each Item, capture its Id and its IncomeAccountRef.value (use that as accountId; if missing on a non-Service item, fall back to ExpenseAccountRef.value, then to AssetAccountRef.value). Step (c): create a budget named 'Test Project Budget' with budgetType='PROJECT', linkedEntityId=, startDate='2026-01-01', endDate='2026-12-31'. Build 3 budgetDetails line items (one per discovered Item) with sequenceId (1,2,3), order (1,2,3), type='ITEM', date='2026-01-01', quantity 1, unitCost 1000, amount = unitCost * quantity, itemId = discovered Item Id, accountId = discovered account ref value, description = discovered Item Name. If fewer than 3 Items exist, fail with a clear error message instructing the developer to add Items in QuickBooks Settings first.", +"project_budget_documentation": "https://developer.intuit.com/app/developer/qbo/docs/workflows/manage-projects/use-cases#use-case-6", +"project_budget_create_variables_example": "{\n \"budgetInput\": {\n \"budgetName\": \"Test Project Budget\",\n \"budgetType\": \"PROJECT\",\n \"linkedEntityId\": \"\",\n \"startDate\": \"2026-01-01\",\n \"endDate\": \"2026-12-31\",\n \"total\": 3000.00,\n \"budgetDetails\": [\n { \"sequenceId\": 1, \"order\": 1, \"type\": \"ITEM\", \"itemId\": \"\", \"unitCost\": 1000.00, \"quantity\": 1, \"amount\": 1000.00, \"description\": \"\", \"date\": \"2026-01-01\", \"accountId\": \"\" },\n { \"sequenceId\": 2, \"order\": 2, \"type\": \"ITEM\", \"itemId\": \"\", \"unitCost\": 1000.00, \"quantity\": 1, \"amount\": 1000.00, \"description\": \"\", \"date\": \"2026-01-01\", \"accountId\": \"\" },\n { \"sequenceId\": 3, \"order\": 3, \"type\": \"ITEM\", \"itemId\": \"\", \"unitCost\": 1000.00, \"quantity\": 1, \"amount\": 1000.00, \"description\": \"\", \"date\": \"2026-01-01\", \"accountId\": \"\" }\n ]\n }\n}", +"project_budget_update_variables_example": "{\n \"budgetInput\": {\n \"budgetId\": \"\",\n \"syncToken\": \"\",\n \"budgetName\": \"Test Project Budget (revised)\",\n \"budgetType\": \"PROJECT\",\n \"linkedEntityId\": \"\",\n \"startDate\": \"2026-01-01\",\n \"endDate\": \"2026-12-31\",\n \"total\": 3500.00,\n \"budgetDetails\": [\n { \"sequenceId\": 1, \"order\": 1, \"type\": \"ITEM\", \"itemId\": \"\", \"unitCost\": 1500.00, \"quantity\": 1, \"amount\": 1500.00, \"description\": \"\", \"date\": \"2026-01-01\", \"accountId\": \"\" },\n { \"sequenceId\": 2, \"order\": 2, \"type\": \"ITEM\", \"itemId\": \"\", \"unitCost\": 1000.00, \"quantity\": 1, \"amount\": 1000.00, \"description\": \"\", \"date\": \"2026-01-01\", \"accountId\": \"\" },\n { \"sequenceId\": 3, \"order\": 3, \"type\": \"ITEM\", \"itemId\": \"\", \"unitCost\": 1000.00, \"quantity\": 1, \"amount\": 1000.00, \"description\": \"\", \"date\": \"2026-01-01\", \"accountId\": \"\" }\n ]\n }\n}", +"project_budget_delete_variables_example": "{\n \"budgetIdsInput\": {\n \"budgetIds\": [\"\"]\n }\n}", +"project_budget_details_paginated_query": "query BusinessPlanningBudgetDetailsPaginated($budgetId: ID!, $first: PositiveInt!, $after: String) {\n businessPlanningBudget(budgetId: $budgetId) {\n budgetId\n budgetName\n budgetType\n linkedEntityId\n total\n state\n syncToken\n budgetDetailsPaginated(first: $first, after: $after, sortBy: [{ field: SEQUENCE_ID, direction: ASC }]) {\n edges {\n node { sequenceId order type itemId unitCost quantity amount description date accountId klassId locationId }\n cursor\n }\n pageInfo { hasNextPage hasPreviousPage startCursor endCursor }\n }\n }\n}", +"project_change_order_creation_instructions": "Create a Change Order linked to the discovered project and parent estimate. Use TxnStatus='Pending', DocNumber='CO-001', and 2 line items: 'Additional electrical work' $500 (item id 1, qty 1, UnitPrice 500) and 'Permit fees' $750 (item id 1, qty 1, UnitPrice 750). Top-level ProjectRef and LinkedTxn are mandatory, AND every line MUST re-include LinkedTxn referencing the parent estimate. Omit TotalAmt (read-only).", +"project_change_order_documentation": "https://developer.intuit.com/app/developer/qbo/docs/workflows/manage-projects/use-cases#use-case-7", +"project_change_order_error_codes_documentation": "https://developer.intuit.com/app/developer/qbo/docs/develop/troubleshooting/error-codes#project-change-order-error-codes", +"project_change_order_v3_endpoint": "POST /v3/company/{{companyid}}/changeorder?minorversion={{minorversion}}", +"project_change_order_get_endpoint": "GET /v3/company/{{companyid}}/changeorder/{{ChangeOrderId}}?minorversion={{minorversion}}", +"php-sdk-documentation":"https://developer.intuit.com/app/developer/qbo/docs/develop/sdks-and-samples-collections/php/install-the-php-sdk AND https://developer.intuit.com/app/developer/qbo/docs/develop/sdks-and-samples-collections/php", +"oauth2-documentation":"https://developer.intuit.com/app/developer/qbo/docs/develop/authentication-and-authorization/oauth-2.0", +"java-sdk-official": "https://central.sonatype.com/search?q=g:com.intuit.quickbooks-online&smo=true", +"java-sdk-dependency":"Gradle", +"java_sdk_version":"6.7.0", +"java-sdk-documentation": "https://developer.intuit.com/app/developer/qbo/docs/develop/sdks-and-samples-collections/java/install-the-java-sdk#jar-files", +"dotnet-sdk-documentation": "https://developer.intuit.com/app/developer/qbo/docs/develop/sdks-and-samples-collections/dot-net", +"minorversion":"75", +"custom_field_documentation": "https://developer.intuit.com/app/developer/qbo/docs/workflows/create-custom-fields", +"custom_field_definitions_query": "query GetCustomFieldDefinitions($cursor: String) {\n appFoundationsCustomFieldDefinitions(\n filters: { active: true }\n first: 50\n after: $cursor\n ) {\n edges {\n cursor\n node {\n id\n legacyID\n legacyIDV2\n label\n dataType\n required\n active\n colorCode\n entityVersion\n createdSource\n dropDownOptions { id value active order }\n associations {\n associatedEntity\n associationCondition\n active\n allowedOperations\n subAssociations { associatedSubEntity active }\n validationOptions { maxLength minLength }\n }\n }\n }\n pageInfo { hasNextPage hasPreviousPage startCursor endCursor }\n }\n}", +"custom_field_create_definition_mutation": "mutation CreateCustomFieldDefinition($input: AppFoundations_CustomFieldDefinitionCreateInput!) {\n appFoundationsCreateCustomFieldDefinition(input: $input) {\n id\n legacyIDV2\n label\n dataType\n required\n active\n dropDownOptions { id value active order }\n associations {\n associatedEntity\n associationCondition\n active\n allowedOperations\n }\n }\n}", +"custom_field_create_definition_variables_example": "{\n \"input\": {\n \"label\": \"Project Code\",\n \"dataType\": \"STRING\",\n \"active\": true,\n \"associations\": [\n {\n \"associatedEntity\": \"transaction\",\n \"associationCondition\": \"INCLUDED\",\n \"active\": true,\n \"allowedOperations\": [\"SEARCH\", \"PRINT\", \"REPORTS\"]\n }\n ]\n }\n}", +"custom_field_update_definition_mutation": "mutation UpdateCustomFieldDefinition($input: AppFoundations_CustomFieldDefinitionUpdateInput!) {\n appFoundationsUpdateCustomFieldDefinition(input: $input) {\n id\n legacyIDV2\n label\n dataType\n active\n required\n }\n}", +"custom_field_update_definition_variables_example": "{\n \"input\": {\n \"id\": \"\",\n \"legacyIDV2\": \"\",\n \"label\": \"Project Code (renamed)\"\n }\n}", +"custom_field_disable_variables_example": "{\n \"input\": {\n \"id\": \"\",\n \"legacyIDV2\": \"\",\n \"active\": false\n }\n}", +"custom_field_sample_app_java": "https://github.com/IntuitDeveloper/SampleApp-CustomFields-Java", +"custom_field_sample_app_python": "https://github.com/IntuitDeveloper/SampleApp-CustomFields-Python", +"custom_field_scope_read": "app-foundations.custom-field-definitions.read", +"custom_field_scope_readwrite": "app-foundations.custom-field-definitions", +"custom_field_definition_endpoint": "GET /v3/company/{{companyid}}/query?query=select * from CustomFieldDefinition&minorversion={{minorversion}}", +"custom_field_payload_structure": "[\n {\n \"DefinitionId\": \"{{DefinitionId}}\",\n \"Name\": \"{{FieldName}}\",\n \"Type\": \"StringType\",\n \"StringValue\": \"{{FieldValue}}\"\n }\n]", +"custom_field_transaction_creation_instructions": "Create one {{type_of_transaction}} with default item id: 1, default customer: 1, and amount: 111. Attach at least one custom field value using a DefinitionId discovered in Task 1." +} diff --git a/discover/prompt-config.json b/discover/prompt-config.json index e805afd..79e00cc 100644 --- a/discover/prompt-config.json +++ b/discover/prompt-config.json @@ -10,32 +10,55 @@ "graphql_endpoint_sandbox": "https://qb-sandbox.api.intuit.com/graphql", "graphql_schema": "https://developer.intuit.com/app/developer/gql/docs/api/qbexternal/queries/", "create_dimension_qbo_ui": "https://qbo.intuit.com/app/class", + "rest_baseurl_production": "quickbooks.api.intuit.com", + "rest_baseurl_sandbox": "sandbox-quickbooks.api.intuit.com", "custom_extensions_structure": "[\n {\n \"CustomExtensions\": [\n {\n \"AssociatedValues\": [\n {\n \"Value\": \"{{Value_ID}}\",\n \"Key\": \"{{Definition_ID}}\"\n }\n ],\n \"ExtensionType\": \"DIMENSION\"\n }\n ]\n }\n]", "transaction_v3_api_endpoint": "POST /v3/company/{{companyid}}/{{type_of_transaction}}?minorversion=75", "transaction_v3_estimate_api_endpoint": "POST /v3/company/{{companyid}}/estimate?minorversion=75", - "get_transaction_endpoint": "GET /v3/company/{{companyid}}/{{type_of_transaction}}/{{TransactionId}}", - "get_estimate_transaction_endpoint": "GET /v3/company/{{companyid}}/estimate/{{TransactionId}}", + "get_transaction_endpoint": "GET /v3/company/{{companyid}}/{{type_of_transaction}}/{{TransactionId}}?minorversion=75", + "get_estimate_transaction_endpoint": "GET /v3/company/{{companyid}}/estimate/{{TransactionId}}?minorversion={{minorversion}}", "company_info_rest_v3_api_endpoint": "GET /v3/company/{{companyid}}/query?minorversion={{minorversion}}&query=select * from CompanyInfo", "company_preferences_rest_v3_api_endpoint": "GET /v3/company/{{companyid}}/query?minorversion={{minorversion}}&query=select * from preferences", "dimension_Api_documentation": "https://developer.intuit.com/app/developer/ies/docs/workflows/manage-dimensions/dimensions-use-cases", "project-estimate-documentation": "https://developer.intuit.com/app/developer/qbo/docs/workflows/manage-projects/use-cases#use-case-5", "rest_v3_api_documentation": "https://developer.intuit.com/app/developer/qbo/docs/api/accounting/all-entities/{{type_of_transaction}}", + "estimate_rest_v3_api_documentation": "https://developer.intuit.com/app/developer/qbo/docs/api/accounting/all-entities/estimate", + "changeorder_rest_v3_api_documentation": "https://developer.intuit.com/app/developer/qbo/docs/api/accounting/all-entities/changeorder", "dimension_discovery_query": "query ActiveCustomDimensionDefinitionsQuery {\n appFoundationsActiveCustomDimensionDefinitions(\n first: 20\n after: null\n last: null\n before: null\n ) {\n edges {\n node {\n ...CustomDimensionDefinitionAttributes\n }\n }\n }\n}\nfragment CustomDimensionDefinitionAttributes on AppFoundations_CustomDimensionDefinition {\n id\n label\n active\n}", "dimension_values_query": "query CustomDimensionsValuesQuery {\n appFoundationsActiveCustomDimensionValues(\n first: 20,\n filters: { definitionId: \"1000000002\", parentId: null }\n ) {\n edges {\n node {\n id\n definitionId\n fullyQualifiedLabel\n }\n }\n }\n}", "projects_discovery_query": "{\"query\":\"query projectManagementProjects($first: PositiveInt!,$after: String,$filter: ProjectManagement_ProjectFilter!,$orderBy: [ProjectManagement_OrderBy!]){projectManagementProjects(first: $first,after: $after,filter: $filter,orderBy: $orderBy){edges{node{id,name,status,dueDate,customer{id},account{id}}}pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor}}}\",\"variables\":{\"first\":4,\"filter\":{\"status\":{\"in\":[\"OPEN\",\"IN_PROGRESS\"]}},\"orderBy\":[\"DUE_DATE_ASC\"]}}", "project_creation_query": "{\"query\":\"mutation ProjectManagementCreateProject($name: String!,$customer: ProjectManagement_CustomerInput,$status: ProjectManagement_Status){projectManagementCreateProject(input:{name: $name,customer: $customer,status: $status}){... on ProjectManagement_Project {id,name,customer{id},account{id},status}}}\",\"variables\":{\"name\":\"Red testing via API\",\"customer\":{\"id\":\"32\"},\"status\":\"OTHER\"}}", + "project_budget_creation_instructions": "Before creating the budget, dynamically discover Items via REST V3 so the generated code runs against ANY connected company (do NOT hard-code item or account IDs). Step (a): GET /v3/company/{{companyid}}/query?query=select * from Item maxresults 3&minorversion={{minorversion}} to fetch the first 3 active Items. Step (b): for each Item, capture its Id and its IncomeAccountRef.value (use that as accountId; if missing on a non-Service item, fall back to ExpenseAccountRef.value, then to AssetAccountRef.value). Step (c): create a budget named 'Test Project Budget' with budgetType='PROJECT', linkedEntityId=, startDate='2026-01-01', endDate='2026-12-31'. Build 3 budgetDetails line items (one per discovered Item) with sequenceId (1,2,3), order (1,2,3), type='ITEM', date='2026-01-01', quantity 1, unitCost 1000, amount = unitCost * quantity, itemId = discovered Item Id, accountId = discovered account ref value, description = discovered Item Name. If fewer than 3 Items exist, fail with a clear error message instructing the developer to add Items in QuickBooks Settings first.", + "project_budget_documentation": "https://developer.intuit.com/app/developer/qbo/docs/workflows/manage-projects/use-cases#use-case-6", + "project_budget_create_variables_example": "{\n \"budgetInput\": {\n \"budgetName\": \"Test Project Budget\",\n \"budgetType\": \"PROJECT\",\n \"linkedEntityId\": \"\",\n \"startDate\": \"2026-01-01\",\n \"endDate\": \"2026-12-31\",\n \"total\": 3000.00,\n \"budgetDetails\": [\n { \"sequenceId\": 1, \"order\": 1, \"type\": \"ITEM\", \"itemId\": \"\", \"unitCost\": 1000.00, \"quantity\": 1, \"amount\": 1000.00, \"description\": \"\", \"date\": \"2026-01-01\", \"accountId\": \"\" },\n { \"sequenceId\": 2, \"order\": 2, \"type\": \"ITEM\", \"itemId\": \"\", \"unitCost\": 1000.00, \"quantity\": 1, \"amount\": 1000.00, \"description\": \"\", \"date\": \"2026-01-01\", \"accountId\": \"\" },\n { \"sequenceId\": 3, \"order\": 3, \"type\": \"ITEM\", \"itemId\": \"\", \"unitCost\": 1000.00, \"quantity\": 1, \"amount\": 1000.00, \"description\": \"\", \"date\": \"2026-01-01\", \"accountId\": \"\" }\n ]\n }\n}", + "project_budget_update_variables_example": "{\n \"_comment\": \"No syncToken (does not exist on BusinessPlanning_Budget). No total (server-computed). Updates MERGE by sequenceId, so send ONLY the lines you are changing: here we EDIT the server-assigned line and DELETE another via deleted:true. Use the SERVER-ASSIGNED sequenceId values read back in Task 3 — not 1/2/3.\",\n \"budgetInput\": {\n \"budgetId\": \"\",\n \"budgetName\": \"Test Project Budget (revised)\",\n \"budgetType\": \"PROJECT\",\n \"linkedEntityId\": \"\",\n \"startDate\": \"2026-01-01\",\n \"endDate\": \"2026-12-31\",\n \"budgetDetails\": [\n { \"sequenceId\": \"\", \"order\": 1, \"type\": \"ITEM\", \"itemId\": \"\", \"unitCost\": 1500.00, \"quantity\": 1, \"amount\": 1500.00, \"description\": \"\", \"date\": \"2026-01-01\", \"accountId\": \"\" },\n { \"sequenceId\": \"\", \"deleted\": true }\n ]\n }\n}", + "project_budget_delete_variables_example": "{\n \"budgetIdsInput\": {\n \"budgetIds\": [\"\"]\n }\n}", + "project_budget_details_paginated_query": "query BusinessPlanningBudgetDetailsPaginated($budgetId: ID!, $budgetDetailsInput: BusinessPlanning_FetchBudgetDetailsInput) {\n businessPlanningBudget(budgetId: $budgetId) {\n budgetId\n budgetName\n budgetType\n linkedEntityId\n total\n state\n budgetDetailsPaginated(budgetDetailsInput: $budgetDetailsInput) {\n edges {\n node { sequenceId order type itemId unitCost quantity amount description date accountId klassId locationId }\n cursor\n }\n pageInfo { hasNextPage hasPreviousPage startCursor endCursor }\n totalCount\n }\n }\n}\n\n# Variables: { \"budgetId\": \"\", \"budgetDetailsInput\": { \"first\": 50, \"after\": null, \"sortBy\": [{ \"field\": \"SEQUENCE_ID\", \"direction\": \"ASC\" }] } }", + "project_change_order_creation_instructions": "Create a Change Order linked to the discovered project and parent estimate. Use TxnStatus='Pending', DocNumber='CO-001', and 2 line items: 'Additional electrical work' $500 (item id 1, qty 1, UnitPrice 500) and 'Permit fees' $750 (item id 1, qty 1, UnitPrice 750). Top-level ProjectRef and LinkedTxn are mandatory, AND every line MUST re-include LinkedTxn referencing the parent estimate. Omit TotalAmt (read-only).", + "project_change_order_documentation": "https://developer.intuit.com/app/developer/qbo/docs/workflows/manage-projects/use-cases#use-case-7", + "project_change_order_error_codes_documentation": "https://developer.intuit.com/app/developer/qbo/docs/develop/troubleshooting/error-codes#project-change-order-error-codes", + "project_change_order_v3_endpoint": "POST /v3/company/{{companyid}}/changeorder?minorversion={{minorversion}}", + "project_change_order_get_endpoint": "GET /v3/company/{{companyid}}/changeorder/{{ChangeOrderId}}?minorversion={{minorversion}}", + "php-sdk-documentation": "https://developer.intuit.com/app/developer/qbo/docs/develop/sdks-and-samples-collections/php/install-the-php-sdk AND https://developer.intuit.com/app/developer/qbo/docs/develop/sdks-and-samples-collections/php", "oauth2-documentation": "https://developer.intuit.com/app/developer/qbo/docs/develop/authentication-and-authorization/oauth-2.0", + "java-sdk-official": "https://central.sonatype.com/search?q=g:com.intuit.quickbooks-online&smo=true", + "java-sdk-dependency": "Gradle", + "java_sdk_version": "6.7.0", + "java-sdk-documentation": "https://developer.intuit.com/app/developer/qbo/docs/develop/sdks-and-samples-collections/java/install-the-java-sdk#jar-files", + "dotnet-sdk-documentation": "https://developer.intuit.com/app/developer/qbo/docs/develop/sdks-and-samples-collections/dot-net", "minorversion": "75", "custom_field_documentation": "https://developer.intuit.com/app/developer/qbo/docs/workflows/create-custom-fields", + "custom_field_scope_read": "app-foundations.custom-field-definitions.read", "custom_field_scope_readwrite": "app-foundations.custom-field-definitions", "custom_field_definitions_query": "query GetCustomFieldDefinitions($cursor: String) {\n appFoundationsCustomFieldDefinitions(\n filters: { active: true }\n first: 50\n after: $cursor\n ) {\n edges {\n node {\n id\n legacyID\n legacyIDV2\n label\n dataType\n required\n active\n colorCode\n entityVersion\n createdSource\n dropDownOptions { id value active order }\n associations {\n associatedEntity\n associationCondition\n active\n allowedOperations\n subAssociations { associatedEntity active allowedOperations }\n }\n }\n }\n pageInfo { hasNextPage hasPreviousPage startCursor endCursor }\n }\n}", "custom_field_create_definition_mutation": "mutation CreateCustomFieldDefinition($input: AppFoundations_CustomFieldDefinitionCreateInput!) {\n appFoundationsCreateCustomFieldDefinition(input: $input) {\n id\n legacyIDV2\n label\n dataType\n required\n active\n dropDownOptions { id value active order }\n associations {\n associatedEntity\n associationCondition\n active\n allowedOperations\n subAssociations { associatedEntity active allowedOperations }\n }\n }\n}", - "custom_field_create_definition_variables_example": "{\n \"input\": {\n \"active\": true,\n \"label\": \"Project Code\",\n \"dataType\": \"STRING\",\n \"associations\": [\n {\n \"active\": true,\n \"associatedEntity\": \"/transactions/Transaction\",\n \"associationCondition\": \"INCLUDED\",\n \"allowedOperations\": [],\n \"subAssociations\": [\n { \"active\": true, \"associatedEntity\": \"SALE_INVOICE\", \"allowedOperations\": [] }\n ]\n }\n ]\n }\n}", + "custom_field_create_definition_variables_example": "{\n \"input\": {\n \"active\": true,\n \"label\": \"Project Code\",\n \"dataType\": \"STRING\",\n \"associations\": [\n {\n \"active\": true,\n \"associatedEntity\": \"/transactions/Transaction\",\n \"associationCondition\": \"INCLUDED\",\n \"allowedOperations\": [],\n \"subAssociations\": [\n { \"active\": true, \"associatedEntity\": \"SALE\", \"allowedOperations\": [] }\n ]\n }\n ]\n }\n}", "custom_field_update_definition_mutation": "mutation UpdateCustomFieldDefinition($input: AppFoundations_CustomFieldDefinitionUpdateInput!) {\n appFoundationsUpdateCustomFieldDefinition(input: $input) {\n id\n legacyIDV2\n label\n dataType\n active\n required\n associations {\n associatedEntity\n associationCondition\n active\n allowedOperations\n }\n }\n}", "custom_field_update_definition_variables_example": "{\n \"input\": {\n \"id\": \"\",\n \"legacyIDV2\": \"\",\n \"label\": \"Project Code (renamed)\"\n }\n}", "custom_field_disable_variables_example": "{\n \"input\": {\n \"id\": \"\",\n \"legacyIDV2\": \"\",\n \"label\": \"\",\n \"active\": false\n }\n}", "custom_field_sample_app_java": "https://github.com/IntuitDeveloper/SampleApp-CustomFields-Java", "custom_field_sample_app_python": "https://github.com/IntuitDeveloper/SampleApp-CustomFields-Python", + "custom_field_definition_endpoint": "GET /v3/company/{{companyid}}/query?query=select * from CustomFieldDefinition&minorversion={{minorversion}}", "custom_field_payload_structure": "[\n {\n \"DefinitionId\": \"{{DefinitionId}}\",\n \"Name\": \"{{FieldName}}\",\n \"Type\": \"StringType\",\n \"StringValue\": \"{{FieldValue}}\"\n }\n]", "custom_field_transaction_creation_instructions": "Create one {{type_of_transaction}} with default item id: 1, default customer: 1, and amount: 111. Attach at least one custom field value using a DefinitionId discovered in Task 1.", "sales_tax_documentation": "https://developer.intuit.com/app/developer/gql/docs/api/qbexternal/queries/tax", @@ -45,10 +68,15 @@ "sales_tax_sample_app_java": "https://github.com/IntuitDeveloper/SampleApp-SalesTax-Java", "sales_tax_sample_app_python": "https://github.com/IntuitDeveloper/SampleApp-SalesTax-Python", "sales_tax_sample_app_nodejs": "https://github.com/IntuitDeveloper/SampleApp-SalesTax-NodeJS", - "rest_baseurl_production": "quickbooks.api.intuit.com", - "rest_baseurl_sandbox": "qb-sandbox.api.intuit.com", - "php-sdk-documentation": "https://developer.intuit.com/app/developer/qbo/docs/develop/sdks-and-samples-collections/php/install-the-php-sdk AND https://developer.intuit.com/app/developer/qbo/docs/develop/sdks-and-samples-collections/php", - "java-sdk-official": "https://central.sonatype.com/search?q=g:com.intuit.quickbooks-online&smo=true", - "java-sdk-dependency": "Gradle", - "java-sdk-documentation": "https://developer.intuit.com/app/developer/qbo/docs/develop/sdks-and-samples-collections/java/install-the-java-sdk#jar-files" + "rest_v3_limits_documentation": "https://developer.intuit.com/app/developer/qbo/docs/learn/rest-api-features#limits-and-throttles", + "developer_portal_url": "https://developer.intuit.com", + "oauth_discovery_production": "https://developer.api.intuit.com/.well-known/openid_configuration", + "oauth_discovery_sandbox": "https://developer.api.intuit.com/.well-known/openid_sandbox_configuration", + "oauth_authorization_endpoint": "https://appcenter.intuit.com/connect/oauth2", + "oauth_token_endpoint": "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer", + "oauth_revocation_endpoint": "https://developer.api.intuit.com/v2/oauth2/tokens/revoke", + "oauth_userinfo_endpoint": "https://accounts.platform.intuit.com/v1/openid_connect/userinfo (production) / https://sandbox-accounts.platform.intuit.com/v1/openid_connect/userinfo (sandbox)", + "oauth_hard_expiry_request_header": "x-include-refresh-token-hard-expires-in", + "oauth_hard_expiry_response_field": "x_refresh_token_hard_expires_in", + "oauth_reconnect_url": "https://appcenter.intuit.com/app/connect/oauth2/request?appId={{appId}}&realmId={{realmId}}&mode=reconnect" } diff --git a/discover/prompt-config.schema.json b/discover/prompt-config.schema.json index a49dcd9..37d687e 100644 --- a/discover/prompt-config.schema.json +++ b/discover/prompt-config.schema.json @@ -21,12 +21,16 @@ "rest_v3_api_documentation", "project-estimate-documentation", "projects_discovery_query", - "project_creation_query" + "project_creation_query", + "custom_field_transaction_creation_instructions" ], "properties": { "integration_mode": { "type": "string", - "enum": ["new", "existing"], + "enum": [ + "new", + "existing" + ], "description": "Whether to scaffold a new project or produce importable modules for an existing codebase" }, "language_framework": { @@ -156,6 +160,50 @@ "type": "string", "description": "GraphQL mutation for creating a project" }, + "project_budget_creation_instructions": { + "type": "string", + "description": "Free-form instructions for creating a Project Budget (Business Planning GraphQL)" + }, + "project_budget_documentation": { + "type": "string", + "description": "URL to the Project Budget use-case documentation" + }, + "project_budget_create_variables_example": { + "type": "string", + "description": "Best-effort sample GraphQL variables payload for businessPlanningCreateBudget (verify against live schema)" + }, + "project_budget_update_variables_example": { + "type": "string", + "description": "Best-effort sample GraphQL variables payload for businessPlanningUpdateBudget — send only the changed/deleted lines (updates MERGE by sequenceId); no syncToken (does not exist on BusinessPlanning_Budget)" + }, + "project_budget_delete_variables_example": { + "type": "string", + "description": "Best-effort sample GraphQL variables payload for businessPlanningDeleteBudgets" + }, + "project_budget_details_paginated_query": { + "type": "string", + "description": "GraphQL query template for cursor-based budgetDetailsPaginated traversal" + }, + "project_change_order_creation_instructions": { + "type": "string", + "description": "Free-form instructions for creating a Project Change Order (REST V3)" + }, + "project_change_order_documentation": { + "type": "string", + "description": "URL to the Project Change Order use-case documentation" + }, + "project_change_order_error_codes_documentation": { + "type": "string", + "description": "URL to the Project Change Order error-codes documentation" + }, + "project_change_order_v3_endpoint": { + "type": "string", + "description": "REST V3 endpoint for creating/updating a ChangeOrder" + }, + "project_change_order_get_endpoint": { + "type": "string", + "description": "REST V3 endpoint for reading a ChangeOrder by ID" + }, "php-sdk-documentation": { "type": "string", "description": "URL to PHP SDK documentation" @@ -178,79 +226,131 @@ }, "custom_field_documentation": { "type": "string", - "description": "URL to the Custom Fields use-case documentation" + "description": "URL to the QuickBooks Custom Fields API documentation" + }, + "custom_field_scope_read": { + "type": "string", + "description": "OAuth scope for reading custom field definitions via GraphQL" }, "custom_field_scope_readwrite": { "type": "string", - "description": "OAuth scope required for read/write access to custom field definitions" + "description": "OAuth scope for creating/updating custom field definitions via GraphQL" }, "custom_field_definitions_query": { "type": "string", - "description": "GraphQL query for fetching custom field definitions" + "description": "GraphQL query for discovering custom field definitions (with pagination cursor)" }, "custom_field_create_definition_mutation": { "type": "string", - "description": "GraphQL mutation for creating a custom field definition" + "description": "GraphQL mutation for creating a new custom field definition" }, "custom_field_create_definition_variables_example": { "type": "string", - "description": "Example variables payload for the create custom field definition mutation" + "description": "Example variables JSON for the create-definition mutation" }, "custom_field_update_definition_mutation": { "type": "string", - "description": "GraphQL mutation for updating a custom field definition" + "description": "GraphQL mutation for updating an existing custom field definition (appFoundationsUpdateCustomFieldDefinition). Also used to disable by setting input.active = false." }, "custom_field_update_definition_variables_example": { "type": "string", - "description": "Example variables payload for the update custom field definition mutation" + "description": "Example variables JSON for renaming a custom field definition via the update mutation" }, "custom_field_disable_variables_example": { "type": "string", - "description": "Example variables payload for disabling a custom field definition" + "description": "Example variables JSON for disabling a custom field definition (update mutation with active=false)" }, - "custom_field_payload_structure": { + "custom_field_sample_app_java": { "type": "string", - "description": "JSON shape of the custom field values attached to a transaction" + "description": "URL of the official Intuit Java sample app for Custom Fields" }, - "custom_field_transaction_creation_instructions": { + "custom_field_sample_app_python": { "type": "string", - "description": "Free-form instructions for creating a transaction with custom field values" + "description": "URL of the official Intuit Python sample app for Custom Fields" }, - "custom_field_sample_app_java": { + "custom_field_definition_endpoint": { "type": "string", - "description": "URL to the official Custom Fields Java sample app" + "description": "REST V3 endpoint for querying CustomFieldDefinition entities" }, - "custom_field_sample_app_python": { + "custom_field_payload_structure": { "type": "string", - "description": "URL to the official Custom Fields Python sample app" + "description": "JSON structure for the CustomField array in transaction payloads" + }, + "custom_field_transaction_creation_instructions": { + "type": "string", + "description": "Free-form instructions for creating a transaction with custom field values" }, "sales_tax_documentation": { "type": "string", - "description": "URL to the Sales Tax use-case documentation" + "description": "URL to QBO GraphQL Sales Tax API documentation" }, "sales_tax_scope": { "type": "string", - "description": "OAuth scope required for the Sales Tax calculation API" + "description": "OAuth scope required to call the Sales Tax calculation mutation" }, "sales_tax_calculate_mutation": { "type": "string", - "description": "GraphQL mutation for calculating sale transaction tax" + "description": "Verbatim GraphQL mutation for indirectTaxCalculateSaleTransactionTax" }, "sales_tax_calculate_variables_example": { "type": "string", - "description": "Example variables payload for the sales tax calculation mutation" + "description": "Example variables JSON for the sales tax calculation mutation" }, "sales_tax_sample_app_java": { "type": "string", - "description": "URL to the official Sales Tax Java sample app" + "description": "URL of the official Intuit Java sample app for Sales Tax" }, "sales_tax_sample_app_python": { "type": "string", - "description": "URL to the official Sales Tax Python sample app" + "description": "URL of the official Intuit Python sample app for Sales Tax" }, "sales_tax_sample_app_nodejs": { "type": "string", - "description": "URL to the official Sales Tax Node.js sample app" + "description": "URL of the official Intuit Node.js sample app for Sales Tax" + }, + "rest_v3_limits_documentation": { + "type": "string", + "description": "URL to QBO REST V3 limits & throttles documentation" + }, + "developer_portal_url": { + "type": "string", + "description": "Intuit developer portal root URL" + }, + "oauth_discovery_production": { + "type": "string", + "description": "OpenID discovery document URL (production)" + }, + "oauth_discovery_sandbox": { + "type": "string", + "description": "OpenID discovery document URL (sandbox)" + }, + "oauth_authorization_endpoint": { + "type": "string", + "description": "OAuth 2.0 authorization endpoint (consent screen)" + }, + "oauth_token_endpoint": { + "type": "string", + "description": "OAuth 2.0 token endpoint (code exchange + refresh)" + }, + "oauth_revocation_endpoint": { + "type": "string", + "description": "OAuth 2.0 token revocation endpoint" + }, + "oauth_userinfo_endpoint": { + "type": "string", + "description": "OpenID Connect UserInfo endpoint (prod + sandbox)" + }, + "oauth_hard_expiry_request_header": { + "type": "string", + "description": "Opt-in request header to include the refresh-token hard-expiry field" + }, + "oauth_hard_expiry_response_field": { + "type": "string", + "description": "Response field carrying the refresh-token hard (5-year) expiry in seconds" + }, + "oauth_reconnect_url": { + "type": "string", + "description": "Intuit-proxied reconnect URL template for re-auth on hard expiry" } }, "additionalProperties": false diff --git a/discover/sales-tax/README.md b/discover/sales-tax/README.md new file mode 100644 index 0000000..7017632 --- /dev/null +++ b/discover/sales-tax/README.md @@ -0,0 +1,148 @@ +# Sales Tax Prompt + +Generates AI-ready prompts that produce a runnable integration for the **QuickBooks Online Sales Tax GraphQL API** — specifically the `indirectTaxCalculateSaleTransactionTax` mutation. The generated code calculates jurisdiction-aware sales tax for transactions your app builds **outside** QuickBooks Online (custom invoicing UI, checkout flow, quote tool, third-party ERP). + +> ℹ️ **GraphQL only.** This prompt does **not** generate REST V3 / Automated Sales Tax (AST) integration code. The Sales Tax mutation is stateless — it calculates tax without persisting anything in QBO. + +## Folder Contents + +``` +sales-tax/ +├── README.md # This file +├── prompt-template-sales-tax.md # The prompt template (do not edit unless updating the workflow) +├── postman/ # Reference Postman collection for manual testing +└── sdk-notes/ # Per-language SDK guidance, injected at merge time + ├── dotnet.md + ├── java.md + ├── nodejs.md + ├── php.md + ├── python.md + └── ruby.md +``` + +The template lives here; the merge script and config live one level up in `discover/`. + +## Use case covered + +**Invoicing outside of QuickBooks Online.** Your app builds the transaction (cart, quote, invoice draft, etc.) in your own UI and storage, then calls the QBO Sales Tax mutation to get accurate jurisdiction-aware tax for that transaction. The mutation returns the calculated tax and walks away — nothing is persisted in QBO. + +## What the generated code does + +| Task | Layer | Purpose | +|---|---|---| +| 1. Calculate | GraphQL | Call `indirectTaxCalculateSaleTransactionTax` with customer + ship-from/ship-to addresses + line items. Receive total tax + per-jurisdiction breakdown + per-line breakdown. | +| 2. Verify | GraphQL response | Confirm `totalTaxAmountExcludingShipping` is present, sum per-rate amounts, detect the "NO TAX SALES" no-nexus edge case, and print a human-readable breakdown. | + +## What's covered in the generated code + +- The full mutation with all useful response fields: tax totals, per-jurisdiction breakdown, per-line breakdown, customer exemption status, EUC classification codes, tax rate reference IDs. +- Exclusive pricing (`pricePerUnitExcludingTaxes`) — the documented US sales tax path. +- Empty `lineItems: []` accepted (returns zero tax). +- Free-form addresses (`freeFormAddressLine`) — the API parses them server-side. +- Shipping fees calculated as their own line item in the response. +- Tax-exemption status echoed back via `subject.taxExemption` (the customer's exemption is honored automatically server-side). +- Error handling for the documented gateway and service error patterns, plus empirically verified quirks. + +## What's NOT covered (out of scope) + +- **REST V3 / Automated Sales Tax (AST)** — see Intuit's separate [AST documentation](https://developer.intuit.com/app/developer/qbo/docs/workflows/manage-sales-tax-for-us-locales) if you need to persist transactions directly in QBO and let QBO compute tax. +- **Inclusive pricing** (`pricePerUnitIncludingTaxes`) — schema supports it but the prompt generates the exclusive path. Swap the field in the generated code if you need VAT-style inclusive pricing. +- **Tax-exemption certificate management** — the API honors a customer's existing exemption status, but creating/managing the exemption certificate itself is a separate workflow. +- **Non-US locales** — the GraphQL mutation works internationally, but the prompt's examples and error guidance are US-centric. +- **Multi-realm / multi-tenant routing** — the generated code reads one realm from one env var. + +## Authentication & `.env` Setup + +The generated code assumes the caller already has a valid **OAuth 2.0 access token** and **realmId** available in a `.env` file. The prompt is intentionally scoped to API integration logic — it does **not** generate an OAuth client. + +### Required `.env` variables + +```bash +QBO_ACCESS_TOKEN= +QBO_REALM_ID= +QBO_ENV=production # or "sandbox" +``` + +### Environment endpoints + +| Env | GraphQL host | +|---|---| +| Production | `https://qb.api.intuit.com/graphql` | +| Sandbox | `https://qb-sandbox.api.intuit.com/graphql` | + +### How to get an access token + +Use the **[Intuit OAuth 2.0 Playground](https://developer.intuit.com/app/developer/playground)** to generate an access token (sandbox or production) with the required scope (see below). Paste the token into `.env` and run the generated app. + +### Required OAuth Scope + +| Scope | Used For | +|---|---| +| `indirect-tax.tax-calculation.quickbooks` | Calling the Sales Tax calculation mutation | + +> ⚠️ `com.intuit.quickbooks.accounting` is **not** sufficient. The Sales Tax mutation requires the dedicated `indirect-tax.tax-calculation.quickbooks` scope. A missing scope surfaces as `403`. + +## Important: There Is No Tax Code "Discovery" Step + +Unlike most QBO entity flows, the Sales Tax GraphQL API does **not** expose root queries to list tax codes or tax rates. The mutation derives the applicable rates server-side from the addresses you pass in and the company's nexus configuration. **Do not** add code that pre-fetches a tax-code list before calling the mutation — the schema does not support it (`taxCodes`, `salesTaxCodes`, `taxRates`, and similar root fields do not exist and will return `GRAPHQL_VALIDATION_FAILED`). + +## Generating a Prompt + +From the `discover/` directory: + +```bash +# Default (uses prompt-config.json) +node merge-prompt.js + +# Language-specific (selects the right sdk-notes/.md) +node merge-prompt.js --language java +node merge-prompt.js --language python +node merge-prompt.js --language dotnet +``` + +Choose option **4** at the menu prompt. Output is written to `generated-prompts/sales-tax-ready-prompt.md`. Paste that file into your AI coding assistant (Copilot, Cursor, ChatGPT, Windsurf) to scaffold the integration. + +## SDK Notes + +The `sdk-notes/` folder contains per-language guidance that is injected into the prompt at merge time via the `{{sdk_notes}}` placeholder. If you run `--language java`, only `sdk-notes/java.md` is included. None of the official QBO SDKs include typed bindings for the Indirect Tax GraphQL mutation — even when an SDK is present, you call this mutation via raw HTTP. + +## Common Errors + +| HTTP | Response shape | Cause | +|---|---|---| +| `200` | `data.indirectTaxCalculateSaleTransactionTax.taxCalculation` populated, no `errors[]` | Success — check for the `NO TAX SALES` no-nexus case below | +| `200` | `data: null`, `errors[].extensions.classification = "ValidationError"` | Variable input fails schema validation (e.g. bad `transactionDate` RFC3339 format). Surface `errors[0].message`. | +| `200` | `data.indirectTaxCalculateSaleTransactionTax: null`, `errors[].extensions.errorType = "INTERNAL"` | Service exception — e.g. missing `subject`. Server does not return a numeric code; match on the message string. | +| `400` | JSON with `errors[].extensions.code = "GRAPHQL_VALIDATION_FAILED"` | Code bug — you queried a field that doesn't exist on the schema. Do not retry. | +| `401` | **XML** with `AuthenticationFailed` | Token invalid or expired. Refresh in the [OAuth Playground](https://developer.intuit.com/app/developer/playground). | +| `403` | XML or JSON depending on layer | Missing `indirect-tax.tax-calculation.quickbooks` scope, or Sales Tax not entitled on the company | + +### Empirical quirks (verified against production, May 2026) + +| Behavior | Implication | +|---|---| +| `realmId` header is **silently ignored** if the access token is valid | Do not rely on the API to enforce realm match. If your app must talk to a specific realm, verify it in your own code. | +| Invalid ship-from / ship-to address (e.g. "gibberish nowhere") | Server **does not error** — silently falls back to a default address (observed: Georgia / Atlanta). Validate addresses client-side. | +| Empty `lineItems: []` | Accepted by the schema; returns a zero-tax calculation. | +| Realm with Sales Tax flags on but **no nexus configured** | Returns HTTP 200 with `totalTaxAmountExcludingShipping = 0` and a single rate `taxRate.name = "NO TAX SALES"` (`rate = 0`). **Not** an error — but generated code should detect this and surface a clearer message to the end user. | +| Documented service error codes (`19833`, `19834`, `19835`, `19837`, `37108`, `37109`, `37111`, `37138`) | Listed in Intuit docs but **not consistently returned as machine-readable codes** in the GraphQL response. Prefer matching on `extensions.classification` / `extensions.errorType` over chasing numeric codes. | + +## Worked Example + +Calling the mutation with a $100 product shipped within Mountain View, CA (94043), against a realm with nexus configured in California: + +``` +Total tax: $9.75 + California State 6.25% $6.25 + California, Santa Clara County 1.00% $1.00 + California, Santa Clara County District 2.50% $2.50 +``` + +Same payload against a realm with no nexus configured: + +``` +Total tax: $0.00 + NO TAX SALES 0.00% $0.00 +``` + +Both responses are HTTP 200. Generated code should distinguish these for the end user. diff --git a/discover/sales-tax/prompt-template-sales-tax.md b/discover/sales-tax/prompt-template-sales-tax.md index d7f0474..a0bb8c5 100644 --- a/discover/sales-tax/prompt-template-sales-tax.md +++ b/discover/sales-tax/prompt-template-sales-tax.md @@ -107,7 +107,7 @@ Shipping tax: $ --- -## Technical Best Practices: +## Technical Best Practices - **No discovery caching needed.** Unlike most QBO entity flows, there's nothing to cache up front — the mutation computes everything per-call from the addresses you pass. - **`realmId` is NOT validated against the access token.** Empirically (verified May 2026): passing a bogus `realmId` header with a valid token returns a successful tax calculation against the token's bound company. Do **not** rely on the API to reject mismatched realm IDs. Validate the `QBO_REALM_ID` env var matches the token holder's company in your own app code if this matters. @@ -138,7 +138,7 @@ Shipping tax: $ - **Observability:** Capture and log the `intuit_tid` response header on every call. **NEVER** log access tokens, OAuth secrets, or PII (addresses, customer names). - **Typing:** Provide `{{typing_system}}` models for `IndirectTax_TaxCalculationInput`, `IndirectTax_TaxCalculationPayload`, `IndirectTax_TaxCalculationLineInput`, and `IndirectTax_ShipmentInput`. - **Output (integration mode: `{{integration_mode}}`):** Provide modular, clean code and a runnable verification example. - - **If mode is `new`:** Create a self-contained project in a folder named `qbo-sales-tax-{{language_framework}}` (no spaces). Include a `README.md` with setup and environment variable instructions, a dependency manifest, and a runnable main entry point that executes Task 1 against a hardcoded example transaction and prints the breakdown. + - **If mode is `new`:** Create a self-contained project in a folder named `qbo-sales-tax-{{language_framework}}` (no spaces, lowercase). Include a `README.md` with setup and environment variable instructions, a dependency manifest, and a runnable main entry point that executes Task 1 against a hardcoded example transaction and prints the breakdown. - **If mode is `existing`:** Produce modular, well-documented functions/classes/files designed to be imported into an existing codebase. Do **not** scaffold a new project structure. Before writing code, scan the workspace: 1. Look for a dependency manifest (`pom.xml`, `build.gradle`, `package.json`, `requirements.txt`, `go.mod`, etc.) to confirm the build system. 2. Look for existing service classes that make QBO API calls (e.g., files containing `QBO_REALM_ID`, `QBO_ACCESS_TOKEN`, `DataService`, or `OAuth2Authorizer`). @@ -146,13 +146,19 @@ Shipping tax: $ State your finding in one sentence before writing code (e.g., "Found existing Express app with `qboClient.js` — adding `salesTaxCalc.js` as a new module.") and match the project's package names, logging style, and error-handling patterns. -> **SDK note:** No official QBO SDK includes typed bindings for the Indirect Tax GraphQL mutation. Regardless of language, use your preferred HTTP client to POST GraphQL requests to `{{graphql_endpoint_production}}` (or `{{graphql_endpoint_sandbox}}`) — even when an SDK is present, you call this mutation via raw HTTP. +--- + +## Language-Specific SDK Notes + +{{sdk_notes}} + +> If no SDK notes appear above, no official entity SDK exists for your language. Use your preferred HTTP client to POST GraphQL requests to `{{graphql_endpoint_production}}` (or `{{graphql_endpoint_sandbox}}`). The official QBO SDKs do **not** include typed bindings for the Indirect Tax GraphQL mutation — even when an SDK is present, you call this mutation via raw HTTP. --- ## 🛑 AI Guardrails (Anti-Hallucination Constraints) -**CRITICAL INSTRUCTIONS - YOU MUST ADHERE TO THE FOLLOWING:** +**CRITICAL INSTRUCTIONS — YOU MUST ADHERE TO THE FOLLOWING:** 1. **No Hallucinations:** Do not invent or guess GraphQL fields, types, or arguments not present in the mutation/variables provided above. The schema does **not** expose `taxCodes`, `salesTaxCodes`, `taxRates`, or any other root query field for sales tax discovery — only the `indirectTaxCalculateSaleTransactionTax` mutation. 2. **Exact Mutation:** Use the mutation provided in `{{sales_tax_calculate_mutation}}` verbatim. Do not add, remove, or rename fields. If the caller doesn't need a field in the response, you may omit it — but do not invent new fields. 3. **No REST V3 / AST:** This prompt is **GraphQL-only**. Do **NOT** generate code that calls the REST V3 `/v3/company/{realmId}/{invoice,salesreceipt,...}` endpoints, queries `TaxCode` / `TaxRate` / `TaxAgency` via REST, or applies `TxnTaxDetail.TxnTaxCodeRef` in a transaction payload. That's a different integration path and is out of scope. diff --git a/discover/sales-tax/sdk-notes/dotnet.md b/discover/sales-tax/sdk-notes/dotnet.md new file mode 100644 index 0000000..17ced59 --- /dev/null +++ b/discover/sales-tax/sdk-notes/dotnet.md @@ -0,0 +1,10 @@ +**If generating .NET / C# code (`{{language_framework}}` = dotnet):** + +The Intuit .NET SDK does **not** support GraphQL. Use a plain HTTP client. + +- Use `System.Net.Http.HttpClient` to POST to `{{graphql_endpoint_production}}` (or `{{graphql_endpoint_sandbox}}` when `QBO_ENV=sandbox`). No extra package needed — `HttpClient` is part of the .NET standard library. +- Required headers: `Authorization: Bearer `, `Content-Type: application/json`, `realmId: `. +- Recommended: set a unique `intuit_tid` header per request for log correlation. +- Use `System.Text.Json` (built-in) or Newtonsoft.Json to serialize variables and parse the response. + +> ℹ️ If your project already depends on `IppDotNetSdkForQuickBooksApiV3` for other QBO work, you can reuse its `OAuth2Authorizer` for token refresh, but you'll bypass `DataService` entirely for the Sales Tax mutation. diff --git a/discover/sales-tax/sdk-notes/java.md b/discover/sales-tax/sdk-notes/java.md new file mode 100644 index 0000000..78bd4b1 --- /dev/null +++ b/discover/sales-tax/sdk-notes/java.md @@ -0,0 +1,11 @@ +**If generating Java code (`{{language_framework}}` = java):** + +The Intuit Java SDK (`ipp-v3-java-devkit`) **does not support GraphQL**. Use a plain HTTP client for the Sales Tax mutation. + +- Use `java.net.http.HttpClient` (JDK 11+) or `CloseableHttpClient` from Apache HttpClient. POST to `{{graphql_endpoint_production}}` (or `{{graphql_endpoint_sandbox}}` when `QBO_ENV=sandbox`). +- Required headers: `Authorization: Bearer `, `Content-Type: application/json`, `realmId: `. +- Recommended: set a unique `intuit_tid` header per request for log correlation. +- Use Jackson or Gson to serialize the variables JSON and parse the response. +- **SDK reference (for unrelated REST V3 work):** `{{java-sdk-documentation}}` — note that this SDK is **not** needed for this prompt. + +> ℹ️ If your project already depends on `ipp-v3-java-devkit` for other QBO work, you can reuse its `OAuth2Authorizer` for token refresh, but you'll bypass `DataService` entirely for the Sales Tax mutation. diff --git a/discover/sales-tax/sdk-notes/nodejs.md b/discover/sales-tax/sdk-notes/nodejs.md new file mode 100644 index 0000000..7ae2f3d --- /dev/null +++ b/discover/sales-tax/sdk-notes/nodejs.md @@ -0,0 +1,8 @@ +**If generating Node.js code (`{{language_framework}}` = nodejs):** + +There is no official Intuit entity SDK for Node.js, and the Sales Tax mutation has no SDK bindings regardless. Use plain HTTP. + +- Use `axios` or native `fetch` (Node 18+) to POST to `{{graphql_endpoint_production}}` (or `{{graphql_endpoint_sandbox}}` when `QBO_ENV=sandbox`). +- Required headers: `Authorization: Bearer `, `Content-Type: application/json`, `realmId: `. +- Recommended: set a unique `intuit_tid` header per request for log correlation. +- No additional package required beyond your HTTP client of choice (`npm install axios` if preferred). diff --git a/discover/sales-tax/sdk-notes/php.md b/discover/sales-tax/sdk-notes/php.md new file mode 100644 index 0000000..7d710eb --- /dev/null +++ b/discover/sales-tax/sdk-notes/php.md @@ -0,0 +1,14 @@ +**If generating PHP code (`{{language_framework}}` = php):** + +The PHP SDK does **not** support GraphQL. Use a plain HTTP client. + +- Use `GuzzleHttp\Client` or PHP's built-in `curl` to POST to `{{graphql_endpoint_production}}` (or `{{graphql_endpoint_sandbox}}` when `QBO_ENV=sandbox`). +- Required headers: `Authorization: Bearer `, `Content-Type: application/json`, `realmId: `. +- Recommended: set a unique `intuit_tid` header per request for log correlation. +- Use `json_encode` / `json_decode` for variables and response. +- **Composer install (if using Guzzle):** + ```bash + composer require guzzlehttp/guzzle + ``` + +> ℹ️ If your project already depends on `quickbooks/v3-php-sdk` for other QBO work, you can reuse its OAuth helpers for token refresh, but you'll bypass `DataService` entirely for the Sales Tax mutation. diff --git a/discover/sales-tax/sdk-notes/python.md b/discover/sales-tax/sdk-notes/python.md new file mode 100644 index 0000000..04b0bd9 --- /dev/null +++ b/discover/sales-tax/sdk-notes/python.md @@ -0,0 +1,11 @@ +**If generating Python code (`{{language_framework}}` = python):** + +There is no official Intuit entity SDK for Python, and no Python SDK bindings for the Indirect Tax GraphQL mutation regardless. Use plain HTTP. + +- Use `requests` to POST to `{{graphql_endpoint_production}}` (or `{{graphql_endpoint_sandbox}}` when `QBO_ENV=sandbox`). +- Required headers: `Authorization: Bearer `, `Content-Type: application/json`, `realmId: `. +- Recommended: set a unique `intuit_tid` header per request for log correlation. +- **Install:** + ```bash + pip install requests + ``` diff --git a/discover/sales-tax/sdk-notes/ruby.md b/discover/sales-tax/sdk-notes/ruby.md new file mode 100644 index 0000000..a30ba10 --- /dev/null +++ b/discover/sales-tax/sdk-notes/ruby.md @@ -0,0 +1,11 @@ +**If generating Ruby code (`{{language_framework}}` = ruby):** + +There is no official Intuit entity SDK for Ruby, and no Ruby bindings for the Sales Tax mutation regardless. Use plain HTTP. + +- Use Ruby's built-in `Net::HTTP` or the `faraday` gem to POST to `{{graphql_endpoint_production}}` (or `{{graphql_endpoint_sandbox}}` when `QBO_ENV=sandbox`). +- Required headers: `Authorization: Bearer `, `Content-Type: application/json`, `realmId: `. +- Recommended: set a unique `intuit_tid` header per request for log correlation. +- **Optional install (if using Faraday):** + ```bash + gem install faraday + ```