Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions packages/js-sdk/e2e/turboquote-payments.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -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=<org-admin TDX api key> \
* E2E_ORG_ID=<org uuid> \
* E2E_QUOTE_ID=<a quote owned by the org> \
* 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<T>(fn: () => Promise<T>, predicate: (v: T) => boolean, attempts = 12, delayMs = 1000): Promise<T> {
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);
});
248 changes: 248 additions & 0 deletions packages/js-sdk/examples/turboquote-payments.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
// =============================================
// 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=<hex>')
// 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();
13 changes: 13 additions & 0 deletions packages/js-sdk/jest.e2e.config.js
Original file line number Diff line number Diff line change
@@ -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: ['<rootDir>/e2e'],
testMatch: ['**/*.e2e.test.ts'],
moduleFileExtensions: ['ts', 'js', 'json'],
verbose: true,
};
1 change: 1 addition & 0 deletions packages/js-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
1 change: 1 addition & 0 deletions packages/js-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading
Loading