From e8e1e8571c3d717738bb55ac11dbb454f39fab37 Mon Sep 17 00:00:00 2001 From: Nicolas Fry Date: Fri, 12 Jun 2026 10:12:42 -0400 Subject: [PATCH 1/3] feat: add TurboQuote payments to the JS SDK [js-sdk] Add TurboQuote payment methods + types - TurboQuote.createPaymentLink(quoteId, { buyerEmail? }) -> { checkoutUrl, paymentId } - TurboQuote.getPaymentStatus(quoteId) -> status/amount/currency/... - TurboQuote.getPaymentConnectionStatus() -> connected/charges/payouts + capabilities - types/quote-payment.ts (incl. QuotePaymentEvents + QuotePaymentSucceededPayload for the quote.payment.succeeded webhook) - Reads the backend's { data: { results } } envelope. - Tests: 5 (mocked HttpClient); full suite 263/263. Port to the other 5 SDKs after. --- packages/js-sdk/src/index.ts | 1 + packages/js-sdk/src/modules/quote.ts | 36 +++++++++ packages/js-sdk/src/types/quote-payment.ts | 72 +++++++++++++++++ packages/js-sdk/tests/quote-payment.test.ts | 86 +++++++++++++++++++++ 4 files changed, 195 insertions(+) create mode 100644 packages/js-sdk/src/types/quote-payment.ts create mode 100644 packages/js-sdk/tests/quote-payment.test.ts diff --git a/packages/js-sdk/src/index.ts b/packages/js-sdk/src/index.ts index 9f3dd834..878e0f03 100644 --- a/packages/js-sdk/src/index.ts +++ b/packages/js-sdk/src/index.ts @@ -27,6 +27,7 @@ export { type SuccessResponse, } from './types/quote-shared'; export * from './types/quote'; +export * from './types/quote-payment'; export * from './types/quote-line-item'; export * from './types/product'; export * from './types/bundle'; diff --git a/packages/js-sdk/src/modules/quote.ts b/packages/js-sdk/src/modules/quote.ts index 63cdca20..fdf70513 100644 --- a/packages/js-sdk/src/modules/quote.ts +++ b/packages/js-sdk/src/modules/quote.ts @@ -6,6 +6,12 @@ import * as fs from 'fs'; import * as nodePath from 'path'; import { HttpClient, QuoteClientConfig, detectFileType } from '../http'; import type { PaginationParams, SuccessResponse } from '../types/quote-shared'; +import type { + CreatePaymentLinkOptions, + QuotePaymentConnectionStatus, + QuotePaymentLink, + QuotePaymentStatus, +} from '../types/quote-payment'; import type { Quote, QuoteStatusInfo, @@ -548,4 +554,34 @@ export class TurboQuote { quote: sendResponse.result, }; } + + // ── Payments ───────────────────────────────────────────────────────────────────────────────── + + /** + * Create a hosted pay link for a quote. Returns the checkout URL to send the buyer to and the + * stable payment id. Requires the org to have a connected payment provider that can charge — see + * {@link getPaymentConnectionStatus}. + */ + static async createPaymentLink(quoteId: string, options?: CreatePaymentLinkOptions): Promise { + const client = this.getClient(); + const response = await client.post<{ results: QuotePaymentLink }>( + `/v1/quotes/${quoteId}/payment/checkout`, + options?.buyerEmail ? { buyerEmail: options.buyerEmail } : {} + ); + return response.results; + } + + /** Get a quote's current payment status (the latest active payment), or `status: 'none'` if unpaid. */ + static async getPaymentStatus(quoteId: string): Promise { + const client = this.getClient(); + const response = await client.get<{ results: QuotePaymentStatus }>(`/v1/quotes/${quoteId}/payment`); + return response.results; + } + + /** Check whether the org is set up to collect payments, plus the active provider's capabilities. */ + static async getPaymentConnectionStatus(): Promise { + const client = this.getClient(); + const response = await client.get<{ results: QuotePaymentConnectionStatus }>(`/v1/quote-payments/status`); + return response.results; + } } diff --git a/packages/js-sdk/src/types/quote-payment.ts b/packages/js-sdk/src/types/quote-payment.ts new file mode 100644 index 00000000..b0b1cef3 --- /dev/null +++ b/packages/js-sdk/src/types/quote-payment.ts @@ -0,0 +1,72 @@ +/** + * TurboQuote payments — public types. + * + * Sellers connect a payment provider (Stripe Connect today) and collect for quotes directly. This + * surface lets integrators create a pay link, check a quote's payment status, check the org can + * collect, and consume the `quote.payment.succeeded` webhook. The shape is provider-agnostic; a + * provider's `capabilities` declares which optional features it supports. + */ + +/** Lifecycle status of a quote's payment. */ +export type QuotePaymentStatusValue = 'none' | 'pending' | 'partial' | 'paid' | 'failed' | 'overdue'; + +/** Result of creating a pay link for a quote. */ +export interface QuotePaymentLink { + /** Hosted checkout URL to send the buyer to. */ + checkoutUrl: string; + /** The TurboQuotePayment id (stable id for this payment of record). */ + paymentId: string; +} + +export interface CreatePaymentLinkOptions { + /** Buyer email override; falls back to the quote's contact email. */ + buyerEmail?: string; +} + +/** A quote's current payment status (latest active payment). */ +export interface QuotePaymentStatus { + status: QuotePaymentStatusValue; + paymentId: string | null; + amountDueToday: number | null; + currency: string | null; + providerName: string | null; + checkoutId: string | null; + updatedOn: string | null; +} + +/** Optional capabilities the active payment provider supports (per-vendor limitations). */ +export interface PaymentProviderCapabilities { + supportsReferenceMetadata: boolean; + supportsWebhookEvents: boolean; + supportsSubscriptions: boolean; + supportsCustomerPortal: boolean; +} + +/** Whether the org is set up to collect payments. */ +export interface QuotePaymentConnectionStatus { + connected: boolean; + chargesEnabled: boolean; + payoutsEnabled: boolean; + requirementsDue: string[]; + capabilities: PaymentProviderCapabilities; +} + +/** TurboQuote-native payment webhook event types (consume via TurboWebhooks). */ +export const QuotePaymentEvents = { + PAYMENT_SUCCEEDED: 'quote.payment.succeeded', +} as const; + +export type QuotePaymentEvent = (typeof QuotePaymentEvents)[keyof typeof QuotePaymentEvents]; + +/** Payload `data` of a `quote.payment.succeeded` webhook. */ +export interface QuotePaymentSucceededPayload { + quote_id: string; + quote_number: string | null; + quote_name: string | null; + payment_id: string; + status: string; + amount: number | null; + currency: string | null; + provider: string | null; + paid_at: string; +} diff --git a/packages/js-sdk/tests/quote-payment.test.ts b/packages/js-sdk/tests/quote-payment.test.ts new file mode 100644 index 00000000..244524ab --- /dev/null +++ b/packages/js-sdk/tests/quote-payment.test.ts @@ -0,0 +1,86 @@ +/** + * TurboQuote Payments — SDK method tests. + * + * Verifies each payments method calls the correct path/verb and unwraps the backend's + * `{ data: { results } }` envelope (the HttpClient strips `data`; methods read `results`). + * The HTTP layer is mocked — no real calls. + */ +import { TurboQuote } from "../src/modules/quote"; +import { HttpClient } from "../src/http"; + +jest.mock("../src/http", () => { + const actual = jest.requireActual("../src/http"); + return { ...actual, HttpClient: jest.fn() }; +}); + +const MockedHttpClient = HttpClient as jest.MockedClass; + +describe("TurboQuote Payments", () => { + let mockClient: { get: jest.Mock; post: jest.Mock; patch: jest.Mock; delete: jest.Mock; getRaw: jest.Mock }; + + beforeEach(() => { + mockClient = { get: jest.fn(), post: jest.fn(), patch: jest.fn(), delete: jest.fn(), getRaw: jest.fn() }; + MockedHttpClient.mockImplementation(() => mockClient as unknown as HttpClient); + TurboQuote.configure({ apiKey: "tdx_test", orgId: "org-1" }); + }); + + describe("createPaymentLink", () => { + it("POSTs to the quote checkout endpoint with the buyer email and returns the link", async () => { + mockClient.post.mockResolvedValue({ results: { checkoutUrl: "https://checkout/x", paymentId: "pay-1" } }); + + const result = await TurboQuote.createPaymentLink("q-1", { buyerEmail: "buyer@example.com" }); + + expect(mockClient.post).toHaveBeenCalledWith("/v1/quotes/q-1/payment/checkout", { buyerEmail: "buyer@example.com" }); + expect(result).toEqual({ checkoutUrl: "https://checkout/x", paymentId: "pay-1" }); + }); + + it("POSTs an empty body when no buyer email is given", async () => { + mockClient.post.mockResolvedValue({ results: { checkoutUrl: "u", paymentId: "p" } }); + + await TurboQuote.createPaymentLink("q-2"); + + expect(mockClient.post).toHaveBeenCalledWith("/v1/quotes/q-2/payment/checkout", {}); + }); + }); + + describe("getPaymentStatus", () => { + it("GETs the quote payment status and returns the unwrapped result", async () => { + mockClient.get.mockResolvedValue({ + results: { status: "paid", paymentId: "pay-1", amountDueToday: 341.5, currency: "USD", providerName: "stripe_connect", checkoutId: "cs_1", updatedOn: "2026-06-12T12:00:00Z" }, + }); + + const result = await TurboQuote.getPaymentStatus("q-1"); + + expect(mockClient.get).toHaveBeenCalledWith("/v1/quotes/q-1/payment"); + expect(result.status).toBe("paid"); + expect(result.amountDueToday).toBe(341.5); + }); + + it("surfaces a 'none' status for an unpaid quote", async () => { + mockClient.get.mockResolvedValue({ results: { status: "none", paymentId: null } }); + const result = await TurboQuote.getPaymentStatus("q-9"); + expect(result.status).toBe("none"); + expect(result.paymentId).toBeNull(); + }); + }); + + describe("getPaymentConnectionStatus", () => { + it("GETs the connection status with provider capabilities", async () => { + mockClient.get.mockResolvedValue({ + results: { + connected: true, + chargesEnabled: true, + payoutsEnabled: true, + requirementsDue: [], + capabilities: { supportsReferenceMetadata: true, supportsWebhookEvents: true, supportsSubscriptions: true, supportsCustomerPortal: true }, + }, + }); + + const result = await TurboQuote.getPaymentConnectionStatus(); + + expect(mockClient.get).toHaveBeenCalledWith("/v1/quote-payments/status"); + expect(result.chargesEnabled).toBe(true); + expect(result.capabilities.supportsSubscriptions).toBe(true); + }); + }); +}); From 5c139a1690fb1309fcc74a5d27169e4a9e9c87a3 Mon Sep 17 00:00:00 2001 From: Nicolas Fry Date: Fri, 12 Jun 2026 10:16:57 -0400 Subject: [PATCH 2/3] test: add live E2E test bed for TurboQuote payments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [js-sdk] E2E test bed (Approach A) for the payments chain - e2e/turboquote-payments.e2e.test.ts: SDK getPaymentConnectionStatus → createPaymentLink → getPaymentStatus(pending) → sign+POST a checkout.session.completed to the backend (Stripe's card capture is bot-blocked, so we feed the provider event ourselves) → getPaymentStatus(paid). - Guarded by E2E_* env (api key OR access token); describe.skip without it, so npm test stays hermetic. jest.e2e.config.js + 'npm run test:e2e'. --- .../e2e/turboquote-payments.e2e.test.ts | 105 ++++++++++++++++++ packages/js-sdk/jest.e2e.config.js | 13 +++ packages/js-sdk/package.json | 1 + 3 files changed, 119 insertions(+) create mode 100644 packages/js-sdk/e2e/turboquote-payments.e2e.test.ts create mode 100644 packages/js-sdk/jest.e2e.config.js diff --git a/packages/js-sdk/e2e/turboquote-payments.e2e.test.ts b/packages/js-sdk/e2e/turboquote-payments.e2e.test.ts new file mode 100644 index 00000000..38e52d91 --- /dev/null +++ b/packages/js-sdk/e2e/turboquote-payments.e2e.test.ts @@ -0,0 +1,105 @@ +/** + * TurboQuote Payments — END-TO-END test bed (Approach A). + * + * Exercises the full chain we own, against a LIVE backend + Stripe sandbox, using the public SDK: + * + * SDK.getPaymentConnectionStatus → org can collect (dev/staging auto-onboarded, captcha-free) + * SDK.createPaymentLink → real Stripe Checkout session on the connected account + * SDK.getPaymentStatus → 'pending' + * sign + POST checkout.session.completed → the backend's Connect webhook (exactly as Stripe would; + * Stripe's hosted card capture is bot-blocked, so we feed + * the provider event ourselves — it's Stripe's code, not ours) + * SDK.getPaymentStatus → 'paid' (reconciliation flipped the row) + * + * It is GUARDED: without the env below it `describe.skip`s, so `npm test` stays green. Run it with: + * + * E2E_PAYMENTS=1 \ + * E2E_BASE_URL=http://localhost:3000 \ + * E2E_API_KEY= \ + * E2E_ORG_ID= \ + * E2E_QUOTE_ID= \ + * E2E_WEBHOOK_URL=http://localhost:3000/v1/quote-payments/webhook \ + * E2E_STRIPE_CONNECT_WEBHOOK_SECRET=whsec_... \ + * npx jest e2e/turboquote-payments.e2e.test.ts + */ +import * as crypto from 'crypto'; + +import { TurboQuote } from '../src/modules/quote'; + +const env = process.env; +const enabled = + env.E2E_PAYMENTS === '1' && + !!env.E2E_BASE_URL && + (!!env.E2E_API_KEY || !!env.E2E_ACCESS_TOKEN) && + !!env.E2E_ORG_ID && + !!env.E2E_QUOTE_ID && + !!env.E2E_WEBHOOK_URL && + !!env.E2E_STRIPE_CONNECT_WEBHOOK_SECRET; + +/** Build a Stripe-signed webhook POST body + header for a checkout.session.completed event. */ +function signStripeEvent(payload: object, secret: string): { body: string; header: string } { + const body = JSON.stringify(payload); + const timestamp = Math.floor(Date.now() / 1000); + const signedPayload = `${timestamp}.${body}`; + const signature = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex'); + return { body, header: `t=${timestamp},v1=${signature}` }; +} + +async function poll(fn: () => Promise, predicate: (v: T) => boolean, attempts = 12, delayMs = 1000): Promise { + let last: T; + for (let i = 0; i < attempts; i++) { + last = await fn(); + if (predicate(last)) return last; + await new Promise((r) => setTimeout(r, delayMs)); + } + return last!; +} + +(enabled ? describe : describe.skip)('E2E: TurboQuote payments (live)', () => { + beforeAll(() => { + TurboQuote.configure({ + apiKey: env.E2E_API_KEY, + accessToken: env.E2E_ACCESS_TOKEN, + orgId: env.E2E_ORG_ID!, + baseUrl: env.E2E_BASE_URL!, + }); + }); + + it('runs create-link → signed completion → paid, end to end', async () => { + const quoteId = env.E2E_QUOTE_ID!; + + // 1) Org can collect. + const connection = await TurboQuote.getPaymentConnectionStatus(); + expect(connection.chargesEnabled).toBe(true); + + // 2) Create a real pay link (Stripe Checkout session on the connected account). + const link = await TurboQuote.createPaymentLink(quoteId, { buyerEmail: 'buyer-e2e@example.com' }); + expect(link.checkoutUrl).toMatch(/^https:\/\/checkout\.stripe\.com\//); + expect(link.paymentId).toBeTruthy(); + + // 3) Status is pending; capture the Stripe checkout session id. + const pending = await TurboQuote.getPaymentStatus(quoteId); + expect(pending.status).toBe('pending'); + const checkoutId = pending.checkoutId!; + expect(checkoutId).toMatch(/^cs_/); + + // 4) Feed a correctly-signed checkout.session.completed to the backend (as Stripe would). + const event = { + id: `evt_e2e_${Date.now()}`, + object: 'event', + type: 'checkout.session.completed', + data: { object: { id: checkoutId, object: 'checkout.session', customer: 'cus_e2e' } }, + }; + const { body, header } = signStripeEvent(event, env.E2E_STRIPE_CONNECT_WEBHOOK_SECRET!); + const resp = await fetch(env.E2E_WEBHOOK_URL!, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Stripe-Signature': header }, + body, + }); + expect(resp.status).toBe(200); + + // 5) Reconciliation flips the row to paid. + const settled = await poll(() => TurboQuote.getPaymentStatus(quoteId), (s) => s.status === 'paid'); + expect(settled.status).toBe('paid'); + }, 30000); +}); diff --git a/packages/js-sdk/jest.e2e.config.js b/packages/js-sdk/jest.e2e.config.js new file mode 100644 index 00000000..a2e383b1 --- /dev/null +++ b/packages/js-sdk/jest.e2e.config.js @@ -0,0 +1,13 @@ +/** + * Jest config for the live E2E test bed (separate from unit tests so `npm test` stays hermetic). + * Run: `npm run test:e2e` with the E2E_* env vars set (see e2e/*.e2e.test.ts headers). + */ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/e2e'], + testMatch: ['**/*.e2e.test.ts'], + moduleFileExtensions: ['ts', 'js', 'json'], + verbose: true, +}; diff --git a/packages/js-sdk/package.json b/packages/js-sdk/package.json index 82be6101..8b44722c 100644 --- a/packages/js-sdk/package.json +++ b/packages/js-sdk/package.json @@ -19,6 +19,7 @@ "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage", + "test:e2e": "jest --config jest.e2e.config.js", "lint": "echo 'add eslint here'", "prepublishOnly": "npm run build" }, From 6b4115fa233b26070c7abee603674d9cf68f3ff6 Mon Sep 17 00:00:00 2001 From: Nicolas Fry Date: Fri, 12 Jun 2026 12:43:13 -0400 Subject: [PATCH 3/3] docs(js-sdk): add runnable TurboQuote payments E2E example --- .../js-sdk/examples/turboquote-payments.ts | 248 ++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 packages/js-sdk/examples/turboquote-payments.ts diff --git a/packages/js-sdk/examples/turboquote-payments.ts b/packages/js-sdk/examples/turboquote-payments.ts new file mode 100644 index 00000000..8d368266 --- /dev/null +++ b/packages/js-sdk/examples/turboquote-payments.ts @@ -0,0 +1,248 @@ +/** + * TurboQuote Example: Online Payments (Stripe Connect) + * + * Sellers connect their own payment provider (Stripe Connect today) and collect for quotes + * directly from their customers — the org is the merchant of record, TurboDocx is the platform. + * This example is the runnable, end-to-end version of the live smoke test: it builds a real + * quote, then exercises every public payment method against it. + * + * Fully self-contained — creates all data it needs, then cleans up. Add your API key and run. + * + * Methods demonstrated: + * - configure() + * - getPaymentConnectionStatus() — is the org set up to collect? what can the provider do? + * - createPaymentLink() — hosted checkout URL to send the buyer to + * - getPaymentStatus() — the quote's latest payment status + * - verifyWebhookSignature() — verify an incoming `quote.payment.succeeded` webhook + * + * Run: npx tsx examples/turboquote-payments.ts + * + * Prerequisite: the org must have a connected payment provider that can charge. In a + * development/staging backend the connect flow auto-provisions a chargeable Stripe test account; + * in production the org admin completes Stripe onboarding first (Settings → Connect Stripe Account). + * If the org can't charge yet, this example prints what's missing and skips the pay-link step. + */ + +import { + TurboQuote, + verifyWebhookSignature, + QuotePaymentEvents, + QuotePaymentSucceededPayload, +} from '@turbodocx/sdk'; + +async function quotePaymentsExample(): Promise { + // ============================================= + // 1. CONFIGURE + // ============================================= + TurboQuote.configure({ + apiKey: process.env.TURBODOCX_API_KEY || 'your-api-key-here', + orgId: process.env.TURBODOCX_ORG_ID || 'your-org-id-here', + baseUrl: process.env.TURBODOCX_BASE_URL || 'https://api.turbodocx.com', + }); + + // Track what we create so cleanup runs even if a step throws. + let companyId: string | undefined; + let contactId: string | undefined; + let categoryId: string | undefined; + let productId: string | undefined; + let quoteId: string | undefined; + + try { + // ============================================= + // 2. CHECK THE ORG CAN COLLECT PAYMENTS + // ============================================= + // Always check connection status before creating a pay link — createPaymentLink() fails if the + // provider can't charge. `capabilities` tells you which optional features the provider supports + // (reference metadata on the charge, native webhook events, subscriptions, customer portal). + console.log('2. Checking payment connection status...'); + + const connection = await TurboQuote.getPaymentConnectionStatus(); + console.log(` Connected: ${connection.connected}`); + console.log(` Charges enabled: ${connection.chargesEnabled}`); + console.log(` Payouts enabled: ${connection.payoutsEnabled}`); + console.log(` Capabilities: ${JSON.stringify(connection.capabilities)}`); + + if (!connection.chargesEnabled) { + console.log( + `\n ⚠️ Org cannot collect payments yet. Outstanding requirements: ${ + connection.requirementsDue.join(', ') || '(provider not connected)' + }`, + ); + console.log(' Connect a payment provider (Settings → Connect Stripe Account), then re-run.'); + return; + } + console.log(); + + // ============================================= + // 3. BUILD A QUOTE TO CHARGE FOR + // ============================================= + console.log('3. Building a quote to charge for...'); + + const category = await TurboQuote.createType({ + name: 'Payments Example Products', + categoryType: 'product_category', + }); + categoryId = category.id; + + const product = await TurboQuote.createProduct({ + name: 'Annual License', + listPrice: 5000.0, + billingFrequency: 'one-time', + categoryId: category.id, + currency: 'USD', + }); + productId = product.id; + + const company = await TurboQuote.createCompany({ + name: 'Buyer Co', + contacts: [{ name: 'Sam Buyer', email: 'buyer-e2e@example.com' }], + }); + companyId = company.id; + + const contact = await TurboQuote.createContact({ + name: 'Sam Buyer', + companyId: company.id, + email: 'buyer-e2e@example.com', + }); + contactId = contact.id; + + const quote = await TurboQuote.createQuote({ + name: 'Buyer Co - Annual License', + companyId: company.id, + contactId: contact.id, + currency: 'USD', + termDays: 0, // one-time purchase + }); + quoteId = quote.id; + + await TurboQuote.addLineItems(quote.id, { + productId: product.id, + productName: product.name, + quantity: 1, + unitPrice: product.listPrice, + billingFrequency: 'one-time', + }); + + console.log(` Quote ${quote.quoteNumber} ready (${quote.id})\n`); + + // ============================================= + // 4. CREATE A HOSTED PAY LINK + // ============================================= + // Returns the hosted checkout URL to send the buyer to, plus a stable paymentId (the payment of + // record). buyerEmail is optional — it falls back to the quote's contact email. + console.log('4. Creating a hosted pay link...'); + + const link = await TurboQuote.createPaymentLink(quote.id, { + buyerEmail: 'buyer-e2e@example.com', + }); + console.log(` Payment id: ${link.paymentId}`); + console.log(` Checkout URL: ${link.checkoutUrl}\n`); + + // ============================================= + // 5. CHECK PAYMENT STATUS + // ============================================= + // Before the buyer pays, status is 'pending'. After they complete checkout (reconciled from the + // provider webhook), it flips to 'paid'. An unpaid quote with no payment row reports 'none'. + console.log('5. Checking payment status...'); + + const status = await TurboQuote.getPaymentStatus(quote.id); + console.log(` Status: ${status.status}`); + console.log(` Amount due: ${status.amountDueToday} ${status.currency}`); + console.log(` Provider: ${status.providerName}`); + console.log(` Checkout id: ${status.checkoutId}\n`); + + console.log(' Send the buyer to the checkout URL above to complete the payment.'); + console.log(' When they pay, TurboDocx fires a `quote.payment.succeeded` webhook (next).\n'); + + // ============================================= + // 6. CONSUME THE quote.payment.succeeded WEBHOOK + // ============================================= + // TurboDocx delivers a provider-agnostic `quote.payment.succeeded` webhook when a quote is paid. + // In your receiver, verify the signature against the RAW request body (never JSON.parse first), + // then act on the typed payload. Below is a self-contained illustration of a receiver. + console.log('6. Verifying a `quote.payment.succeeded` webhook (illustration)...'); + + // These three values arrive on the incoming HTTP request in your webhook receiver: + // rawBody = the exact bytes of req.body (use express.raw({ type: 'application/json' })) + // signature = req.headers['x-turbodocx-signature'] (format: 'sha256=') + // timestamp = req.headers['x-turbodocx-timestamp'] (unix seconds, as string) + // secret = the webhook secret from TurboWebhooks.createWebhook() + function handleQuotePaymentWebhook( + rawBody: string, + signature: string, + timestamp: string, + secret: string, + ): void { + if (!verifyWebhookSignature(rawBody, signature, timestamp, secret)) { + console.log(' ✗ Invalid signature — reject (401).'); + return; + } + + // Real delivery envelope (from the backend WebhookService): the event name is the top-level + // `event` key; the typed payload is under `data`. `event_id`/`created_at`/`version` ride along. + const event = JSON.parse(rawBody) as { + event: string; + event_id: string; + created_at: string; + version: string; + data: QuotePaymentSucceededPayload; + }; + + if (event.event === QuotePaymentEvents.PAYMENT_SUCCEEDED) { + const p = event.data; + console.log(` ✓ Verified. Quote ${p.quote_number} paid: ${p.amount} ${p.currency} at ${p.paid_at}`); + // Fulfill the order, mark the deal closed-won, notify the seller, etc. + } + } + + // Demonstration only — sign a sample payload with a sample secret so the helper returns true. + // (In your receiver you do NOT compute the signature; it arrives on the request.) We use the + // current time so the payload falls inside the helper's default 300s replay-protection window. + const sampleSecret = 'whsec_example_secret'; + const now = new Date(); + const samplePayload = JSON.stringify({ + event: QuotePaymentEvents.PAYMENT_SUCCEEDED, + event_id: 'evt_example0000000000000000000000', + created_at: now.toISOString(), + version: '1.0', + data: { + quote_id: quote.id, + quote_number: quote.quoteNumber, + quote_name: quote.name, + payment_id: link.paymentId, + status: 'paid', + amount: 5000.0, + currency: 'USD', + provider: 'stripe_connect', + paid_at: now.toISOString(), + } satisfies QuotePaymentSucceededPayload, + }); + const sampleTimestamp = String(Math.floor(now.getTime() / 1000)); + const { createHmac } = await import('crypto'); + const sampleSignature = + 'sha256=' + createHmac('sha256', sampleSecret).update(`${sampleTimestamp}.${samplePayload}`).digest('hex'); + + handleQuotePaymentWebhook(samplePayload, sampleSignature, sampleTimestamp, sampleSecret); + console.log(); + + console.log('=== Payments example completed successfully! ==='); + } catch (error: any) { + console.error(`Error: ${error.message}`); + if (error.statusCode) { + console.error(`Status Code: ${error.statusCode}`); + } + } finally { + // ============================================= + // 7. CLEANUP + // ============================================= + console.log('\n7. Cleaning up...'); + if (quoteId) await TurboQuote.deleteQuote(quoteId).catch(() => {}); + if (contactId) await TurboQuote.deleteContact(contactId).catch(() => {}); + if (companyId) await TurboQuote.deleteCompany(companyId).catch(() => {}); + if (productId) await TurboQuote.deleteProduct(productId).catch(() => {}); + if (categoryId) await TurboQuote.deleteType(categoryId).catch(() => {}); + console.log(' ✅ Test data removed'); + } +} + +quotePaymentsExample();