Skip to content

Deal Offer Editor

The offer editor has two entry points sharing templates/offers.html and templates/offers.js: a CRM Deal widget and the standalone /offers?deal_id=<CRM ID> page. The widget receives one Deal ID from CRM's PageLoad event; the standalone page requires the Deal ID in its URL. Neither entry point offers a Deal picker. Both read the Deal and Product catalogue through Pepperback's existing CRM client.

The widget package and backend support are implemented and tested offline. The backend was deployed from main on 27 October 2026. The existing Test Oc26 draft was backed up and restored into the persistent volume with its checksum, version and Qwilr page ID unchanged. Live checks confirmed that the offer key loads the draft while anonymous and invalid-key requests are rejected; the key does not grant access to the standalone editor. The Pepperback service and connection both use link name pepperback; login-user credentials are disabled. The Peppermint Quote Builder widget (42659000019296007, API name Peppermint_Quote_Builder) and Prepare offer button (42659000019296013) are installed on the Standard Deal layout for Administrator and Standard profiles.

The CRM smoke test in the existing administrator session verified loading the preserved Test Oc26 draft, loading 827 Product choices, saving, closing/reopening, the quote action returning the same already-generated page, and selection refresh. The offer content and version were unchanged; no duplicate proposal was created. The page has no customer acceptance yet. This test did not rebuild changed Qwilr content or create another page. The 28 October category release subsequently verified controlled creation, changed-content updates on the same page, category review guards and version 6.0 widget saving and quote generation. See the release evidence. A separate Standard-user check and Qwilr acceptance verification remain.

The initial /offers/catalog?deal_id=<CRM ID>&initial=1 response includes the saved draft, version, linked Qwilr page and selections. It fetches only distinct Product IDs used by the draft, in batches of 100 using the CRM Get Records ids filter. A new MSP offer instead uses Search Records for its six preset Product Codes; this requires ZohoSearch.securesearch.READ in addition to Product read access. Search results are paginated and filtered to exact codes, retaining duplicate matches as unlinked defaults. New non-MSP offers do not fetch Products during initial loading.

After rendering, the browser requests the full catalogue from /offers/catalog. It merges search options and refreshes Product labels without re-rendering rows or replacing quantities, prices, descriptions or other edits. Separate loading and retry controls keep catalogue failures from blocking the editor. Saved MSP drafts display their compact configuration immediately. New offers initialise from Deal Category automatically, without a preset/reset button.

The standalone page retains report authentication through EXCEL_BASIC_USER and EXCEL_BASIC_PASS. The widget uses a dedicated OFFER_WIDGET_TOKEN, stored in a shared Zoho CRM connection and sent as the X-Offer-Token header by Zoho. This key authorizes only the offer catalogue, draft, quote and selection APIs; it does not authorize report pages, webhooks or other backend operations. Missing configuration disables widget API access. A supplied invalid key is rejected without falling back to Basic auth. All CRM staff use the same connection; there is no additional role model or separate Pepperback login.

ZOHO.CRM.CONNECTION.invoke proxies widget requests through Zoho; browser code contains only the backend origin and connection link name. No backend key, report password, CRM OAuth token or QWILR_ACCESS_TOKEN is packaged into the widget. Pepperback retains validation, CRM access, Qwilr operations and persistence. Offer writes require JSON and X-Offer-Editor: 1; standalone Basic-auth writes also retain browser origin checks. Explicit connection credentials do not depend on browser cookies. No cross-origin browser access is enabled on Pepperback.

Private drafts are saved under .local/offers; OFFERS_DIRECTORY can override that directory. All application workers must share it. Each Deal has an atomic JSON file and process-shared lock. Docker Compose mounts the offer-drafts volume at /app/.local/offers and sets OFFERS_DIRECTORY to that path. Preserve existing drafts before first enabling the volume, as described below.

The six-category backend and CRM configuration were deployed on 28 October 2026; the rebuilt widget was uploaded and its loading confirmed by the operator. Follow the category and template release for coordinated deployment, CRM field/workflow changes and validation.

app/deal_categories.py owns six labels, explicit legacy aliases, design selection, new-line billing defaults, MSP seeding and project routing. Unknown/blank categories fail offer preparation. Business_Category remains the CRM API field name; its new display label is Deal Category. app/offer_templates.py holds ordered saved block compositions and their quote block IDs. New Retainer and Standard identities are release-specific keys; old MSP and Tech Project snapshot identities remain. Hardware has a separate composition identity, initially using the Standard blocks. No caller-supplied template ID is trusted when saving.

Initial catalogue data includes canonical category, default billing, legacy warning and whether a saved draft needs review. Saving records the category and requires reviewed_category equal to the current canonical category when an existing draft has a different or missing snapshot. The widget retains its lines and provides an explicit review checkbox. It never reseeds a saved draft. Generation reads CRM again and rejects a category mismatch before any Qwilr call, including a same-version retry. Category and composition identity participate in the saved version hash. Page-level metadata records the initial creation only: Qwilr's update API rejects the metadata property. Current offer identity travels in the replaced quote-line metadata and the backend's last successfully generated offer snapshot. Acceptance mapping must use that snapshot, not assume page-level metadata tracks later edits.

Qwilr saved blocks provide the document design. Complete quoteSections replacement supplies every Product, quantity, price and billing schedule from the offer. quoteSettings explicitly clears inherited quote discount and taxes; amounts are exclusive of tax. The verified variable Account_Name receives the Deal's Account name. New plain blocks have neutral wording and no assumed MSP onboarding or project terms. The old quote/accept block environment overrides are not used.

Master edits do not propagate automatically. Save release copies, verify their identities/types and order, and activate them through a reviewed code release. Keep old blocks and composition keys for historical drafts and rollback. See the release procedure for the browser workflow and source templates.

For MSP, the full catalogue response and a new offer's initial response include starting sections from app/msp_offers.py. Products are matched by unique Product_Code: MSP-SEAT-L1/L2/L3 for support levels, MSP-DEVICE for extra devices, MSP-DISCOVERY for Discovery and MSP-ONBOARDING-USER for per-user Onboarding. Missing or duplicate matches remain unlinked for staff to resolve; runtime code never creates Products. The two new setup Products are separate from PepBus001 and SIP001; initial catalogue rates requested from the reference proposal are $4,500 per Discovery pack and $247.50 per onboarding user (one-off). Rates use Unit_Price, with the approved $25 fallback for an unpriced extra device. The UI automatically seeds new offers from Business Category and preserves saved drafts on reopening. Shared user count updates plan quantities and the MSP-ONBOARDING-USER line; staff can separately override onboarding. Initial inclusions from the reference proposal are retained in the offer's features and emitted as editable Qwilr featuresList; no inclusion editor or marketing copy is displayed in the configurator. They are quote content, not a separate reusable text block, and are overwritten on the next quote update. Preselection (Growth initially) and recommendation (Flow initially) are independent. Plan cards use single selection, followed by IT Infrastructure Setup and then Additional products and services. The latter retains the msp-devices section ID for preset identity compatibility, starts with Extra Devices optional and unselected, and uses the normal editable product table. Tests cover selection-to-Product mapping.

Create uses saved blocks and stores the returned page ID. Update uses the same ID and replaces its content, with no fallback to creating a new page. The page must be a draft. An uncertain write leaves a pending marker: inspect Qwilr and reconcile the saved file before resuming, rather than retrying creation blindly. The last generated offer and fetched acceptance data are retained locally.

The interface shows customer selections, not raw API payloads. Actual invoice creation, webhook subscriptions and CRM schema installation are separate work. The standalone editor and installed CRM widget have been smoke-tested. The category-specific block assembly has offline coverage and controlled live API creation/update checks. Version 6.0 of the installed CRM widget displays the Deal category and saves through the shared connection. Preview acceptance and a separate Standard-user session remain follow-up checks. See the Qwilr API reference and preview acceptance guidance.

Implementation is in app/blueprints/offers.py, templates/offers.html and templates/offers.js; regression coverage is in tests/test_offers.py. See the business rules and staff procedure.

Install the CRM offer widget

Optional test-only reset

Set OFFER_TEST_DEAL_IDS to a comma-separated allowlist of dedicated CRM test Deal IDs in the backend environment; the default empty value disables reset. An allowed Deal must also have a name beginning with TEST (case-insensitive). The catalogue exposes eligibility; POST /offers/<deal_id>/reset independently checks the allowlist, live CRM record, confirm_reset: true, saved version, pending-write guard and existing page's draft status. Existing offer authentication and origin protections apply. No caller-supplied test flag grants permission.

Reset backs up the complete state to OFFERS_DIRECTORY/reset-history/, retains the page ID and last quoted snapshot, clears the current offer and local acceptance result, and assigns a fresh reset generation. Subsequent version hashes include that generation, preventing old preview acceptance from matching identical new test inputs. Selection retrieval stays blocked until generation succeeds. Reset only reads Qwilr; the normal next generation updates the preserved page ID, with no fallback creation. Failed validation or Qwilr reads leave the draft untouched.

The updated widget ZIP must be uploaded to expose the reset controls. This is a proposal-only debug tool, not a rollback of downstream workflow side effects.

Widget installation

  1. Preserve any existing offer drafts before recreating the backend container. Stop offer editing, copy the configured offers directory to a private backup, then restore its complete contents (including page IDs, versions and pending markers) into the offer-drafts volume before resuming. Verify a known Deal reopens its existing Qwilr page. An empty replacement directory can cause a duplicate proposal on the next creation. Do not run docker compose down -v.
  2. Generate a separate random credential for OFFER_WIDGET_TOKEN, place it in the backend's untracked deployment environment, and deploy the backend with the persistent offers volume. Use a high-entropy value, such as a 32-byte random token. Do not reuse the report password, inbound webhook token or Qwilr key. Changing this value requires restarting the backend and updating the CRM connection.
  3. In Setup → Developer Hub → Connections, create a custom API-key service named Pepperback, with service link name pepperback and credential parameter X-Offer-Token, located in the header. Create its connection with link name pepperback and authorize it once with the backend's offer credential. Leave Use credentials of login user off so staff use the shared connection without authenticating individually. Keep connection management with the CRM administrators.
  4. Build the upload from the shared editor, specifying the deployed HTTPS origin:
python scripts/build_offer_widget.py --base-url https://pepit.fxbn.dev

The result is .local/widgets/peppermint-quote-builder.zip, containing app/widget.html. The builder uses the CRM ZIP layout from Zoho Extension Toolkit 1.0.30 and only renders the editor assets and public connection settings; it does not load .env or copy application files. Use --connection if the installed connection has a different link name. 5. In Setup → Developer Hub → Widgets, create a Button widget with Zoho/internal hosting, upload the ZIP and set its index URL to /widget.html (Zoho's base URL already ends in /app). Add a Prepare offer button to the Deal record detail view with action Open a Widget. Make it available to all staff profiles and relevant Deal layouts. No user-specific access rules are implemented. 6. Verify with a normal staff account: open an existing Deal, save and reopen its offer, generate a controlled Qwilr draft and update the same page. Check Product search, MSP defaults, the Qwilr link and manual selection refresh. Confirm credentials are absent from the widget source/browser requests and unauthenticated backend requests are rejected. Test a revoked connection and interrupted generation before broad use. The administrator smoke test is recorded above; Standard-user and failure-path checks remain.

The widget rejects missing, multiple or non-Deal contexts. A changed CRM record requires closing and reopening the widget, preventing a late response from being applied to another Deal. Connection failures are shown in the editor; writes are never automatically retried. Reopen the saved offer and inspect Qwilr after an uncertain result, preserving the backend's pending-write safeguard. Editing narrative in Qwilr still uses the staff member's Qwilr access.

Tests: tests/test_offer_widget.py, tests/test_offers.py, tests/js/offers-widget.cjs and tests/js/offers.cjs. They cover connection auth, shared editor packaging, CRM context, draft and generation guards, response handling and the browser flow with a simulated Zoho SDK. Installation and live smoke-test evidence are recorded above separately from automated coverage.

Platform references: widget hosting, PageLoad and SDK initialization, connection invocation, Zoho's connection request/response example, and shared connection credentials.

Product pricing configuration

app/offer_pricing.py owns rate snapshots, discount validation and Decimal arithmetic. The catalogue requests the existing Usage_Unit and four custom Product fields:

API name CRM label Type
Standard_Hourly_Rate Standard Hourly Rate Currency, 2 decimals
Standard_Daily_Rate Standard Daily Rate Currency, 2 decimals
Nonprofit_Discount Nonprofit Discount Percent, 2 decimals
Offer_Default_Unit Offer Default Unit Picklist

The default-unit choices are Hour, Day, Unit, User, Device, Pack, Month and Year. These fields were created in the Standard Product layout on 28 October 2026 using Zoho's custom-field API. Existing Consulting (42659000018016030, code 003) retains its identity and legacy Unit Price. Senior Consultant (42659000019316007, code CONSULTING-SENIOR) is the second Product. Configured rates and percentages are listed in the offer rules. No existing historical invoice or offer was repriced.

A configured line carries rate_unit, pricing, rate_snapshot, unit_label and discount (type, amount, label). The backend takes snapshots from CRM for new configuration and explicit reprice: true, otherwise from the previously persisted line with matching Product identity. It does not trust browser-supplied snapshots. The transient reprice flag is stripped after save. Manual prices remain supported. Sections carry their own discount; absence means zero. Old offers remain readable.

The Qwilr API receives lineItemDiscount and settings.sectionDiscount with type fixed or percentage. It has no documented custom-label attribute on these objects, so labels also appear in descriptions. The quote enables showTotalDiscount, clears quote-level discount and taxes, and keeps the standard unit price. Fixed line discounts apply to quantity × unit price, verified against an existing test draft at quantity two. No additional pages were created for this verification; its original configuration was restored afterwards.

Preview/accepted selections retain quoted unit and discount snapshots and flag missing/changed returned line discount details. Section discounts always require review against the Qwilr total; no per-Product allocation for billing is inferred. Pricing tests cover snapshot preservation, explicit refresh, missing configuration, invalid percentages, minimum quantity/optional/plan guards, mixed billing, payloads and browser unit/nonprofit selection. Widget updates require rebuilding and uploading the ZIP as well as deploying the backend.

The widget requests a wider popup once after its first valid Deal context using ZOHO.CRM.UI.Resize. It requests up to 1,400 px width and 900 px height, leaving margins within the browser window (screen bounds are the fallback). Resize failure does not block loading. This changes the host popup, not just the editor's CSS width. The Unit selector consults both saved rate snapshots and linked CRM Product fields, so legacy lines can select Hour/Day directly. Snapshot creation occurs only when staff select a rate/pricing option; loading alone does not reprice the line. Client pricing and Billing frequency are separately labelled below the Product; Unit has its own column. Regression checks cover the legacy label-only case, independent billing frequency, snapshot preservation and non-blocking resize.