From 91c794c7efe392d014d200f6e1fa5b3ca238407c Mon Sep 17 00:00:00 2001 From: raunak-cispl1 Date: Mon, 21 Sep 2026 20:33:50 +0530 Subject: [PATCH] feat(sales): add payment/verification support to treatments, checkout-events, carts, sessions BREAKING: treatments().sync() gains optional payment and verification options; userAgent becomes a required, non-empty option since the API now requires the User-Agent header on this endpoint. Also documents the already-pass-through payment field on checkoutEvents().update() order events and verification field on sessions().create()/update(), and adds an optional variant_id to carts() items. Mirrors the AsterMD PHP SDK's feat/treatments-sync-payment-verification branch. Cuts CHANGELOG.md as 0.0.3. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 17 ++++ docs/INTEGRATION_GUIDE.md | 4 +- package.json | 2 +- src/index.ts | 12 ++- src/resource/carts.ts | 7 +- src/resource/checkout-events.ts | 8 +- src/resource/sessions.ts | 9 +- src/resource/treatments.ts | 110 ++++++++++++++++++++++-- test/resource/treatments.spec.ts | 142 ++++++++++++++++++++++++++++--- 9 files changed, 285 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 579fb0e..93971bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +## [0.0.3] - 2026-09-21 + +### Changed + +- **BREAKING:** `treatments().sync()` now requires `userAgent`; an empty or + missing value throws `TypeError`. Adds optional `payment` and `verification` + fields, with new exported types `TreatmentPaymentOptions`, + `TreatmentCardOptions`, `TreatmentVerificationOptions`, and + `TreatmentIdVerificationOptions`. +- Documented the optional `variant_id` cart-item field on `carts().create()` + and `carts().update()`, the optional `payment` field on an `OrderPlaced` + `checkoutEvents().update()` event, and the optional `verification` field on + `sessions().create()` and `sessions().update()`. All three were already + forwarded as pass-through payload; only the documentation changed. + ## [0.0.2] - 2026-09-08 First public release of the Node.js SDK, at feature parity with the PHP SDK diff --git a/docs/INTEGRATION_GUIDE.md b/docs/INTEGRATION_GUIDE.md index 8a2a68f..ae6ca00 100644 --- a/docs/INTEGRATION_GUIDE.md +++ b/docs/INTEGRATION_GUIDE.md @@ -588,7 +588,9 @@ a debug log. | `list(query?: QueryParams): Promise>` | GET | `/v1/sales/treatments/list` | | `sync(options: TreatmentSyncOptions): Promise>` | POST | `/v1/sales/treatments/sync` | -`TreatmentSyncOptions` is `{ session, orderIds, utmSource?, userAgent? }`. Use +`TreatmentSyncOptions` is +`{ session, orderIds, userAgent, utmSource?, payment?, verification? }`. +`userAgent` is required - an empty or missing value throws `TypeError`. Use `sync()` to push an order settled in an external CRM into AsterMD after settlement happens outside the SDK. diff --git a/package.json b/package.json index 7527f20..972ff1b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@astermd-hq/sdk", - "version": "0.0.2", + "version": "0.0.3", "description": "Official Node.js SDK for the AsterMD order-flow API.", "keywords": [ "astermd", diff --git a/src/index.ts b/src/index.ts index 4587307..c7cf1bf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,7 +36,17 @@ export type { } from './resource/intake-submissions.js'; export { Patients, type SubmitHealthInformationOptions } from './resource/patients.js'; export { Opportunities } from './resource/opportunities.js'; -export { Treatments, type TreatmentSyncOptions } from './resource/treatments.js'; +export { + Treatments, + type TreatmentSyncOptions, + type TreatmentPaymentOptions, + type TreatmentCardOptions, + type TreatmentCardType, + type TreatmentPaymentType, + type TreatmentVerificationOptions, + type TreatmentIdVerificationOptions, + type TreatmentIdVerificationMethod, +} from './resource/treatments.js'; export { Channels } from './resource/channels.js'; export { DoctorsNetworks } from './resource/doctors-networks.js'; export { Categories } from './resource/categories.js'; diff --git a/src/resource/carts.ts b/src/resource/carts.ts index e246e08..454c9a1 100644 --- a/src/resource/carts.ts +++ b/src/resource/carts.ts @@ -7,9 +7,10 @@ export interface CartWriteOptions { /** The session the cart belongs to. */ session: string; /** - * The cart's full contents. Each entry identifies a product and quantity, and - * a variant where the product has them. This is the complete list, not a - * delta - see the API reference in your AsterMD dashboard for the fields. + * The cart's full contents. Each entry requires `product_id`, `name`, and + * `qty`, plus an optional `variant_id` when the product has variants. This is + * the complete list, not a delta - see the API reference in your AsterMD + * dashboard for the fields. */ items: Record[]; } diff --git a/src/resource/checkout-events.ts b/src/resource/checkout-events.ts index 9925807..b30ebc4 100644 --- a/src/resource/checkout-events.ts +++ b/src/resource/checkout-events.ts @@ -89,7 +89,13 @@ export class CheckoutEvents extends AbstractResource { * Send one call per state the visitor reaches: an upsell offered, then accepted * or declined, then the order placed or declined. The payload carries whatever * context that state needs, but it can never override `event` - the state - * recorded is always the one you passed explicitly. + * recorded is always the one you passed explicitly. For an `OrderPlaced` event, + * `data` may include an optional `payment` object describing the settled + * payment method: `type` (`paypal` | `apple_pay` | `gpay` | `credit_card` | + * `pre_paid`), `pre_auth` (boolean), `pre_auth_qa` and `pre_auth_amount` + * (optional), and an optional `card` object (`type` - `amex` | `visa` | + * `mastercard` | `discover` | `diners_club` | `jcb`, optional `bin`, required + * `exp`). * * @param options The session, the new state, and any context for it. * @returns The updated checkout-event record. diff --git a/src/resource/sessions.ts b/src/resource/sessions.ts index 1ce2c86..a54caa7 100644 --- a/src/resource/sessions.ts +++ b/src/resource/sessions.ts @@ -7,7 +7,11 @@ export interface SessionCreateOptions { /** * Attribution and context to record against the session - UTM parameters, * referrer, landing page. Forwarded unmodified; see the API reference in your - * AsterMD dashboard for the recognised keys. + * AsterMD dashboard for the recognised keys. May include an optional + * `verification` object recording identity/contact verification already + * performed by the caller - every field inside it is optional too: `email` + * (boolean), `address` (boolean), and `id` (`verified` boolean, `method` - + * `ssn` | `dob` | `cross_check` | `document_upload`, `value` string). */ data?: Record; /** The visitor's user agent, forwarded as the `User-Agent` header. */ @@ -135,7 +139,8 @@ export class Sessions extends AbstractResource { * * @param session The session identifier to update. * @param data Fields to record. Forwarded unmodified; see the API reference in - * your AsterMD dashboard for the recognised keys. + * your AsterMD dashboard for the recognised keys. Accepts the same optional + * `verification` object documented on {@link Sessions.create}. * @returns The updated session. * @throws {NotFoundError} If the session does not exist. * @throws {ValidationError} If the payload is rejected. diff --git a/src/resource/treatments.ts b/src/resource/treatments.ts index ff08b54..12fceac 100644 --- a/src/resource/treatments.ts +++ b/src/resource/treatments.ts @@ -3,16 +3,77 @@ import type { Transport } from '../http/transport.js'; import type { Response as ApiResponse } from '../response.js'; import { AbstractResource } from './abstract-resource.js'; +/** The card network for {@link TreatmentCardOptions.type}. */ +export type TreatmentCardType = 'amex' | 'visa' | 'mastercard' | 'discover' | 'diners_club' | 'jcb'; + +/** The settled payment method for {@link TreatmentPaymentOptions.type}. */ +export type TreatmentPaymentType = 'paypal' | 'apple_pay' | 'gpay' | 'credit_card' | 'pre_paid'; + +/** The identity check performed for {@link TreatmentIdVerificationOptions.method}. */ +export type TreatmentIdVerificationMethod = 'ssn' | 'dob' | 'cross_check' | 'document_upload'; + +/** Card details nested inside {@link TreatmentPaymentOptions.card}. */ +export interface TreatmentCardOptions { + /** The card network. */ + type: TreatmentCardType; + /** The card's bank identification number, when available. */ + bin?: string; + /** The card's expiry, in the format your integration agreed with AsterMD. */ + exp: string; +} + +/** The settled payment method for {@link Treatments.sync}. */ +export interface TreatmentPaymentOptions { + /** How the order was paid for. */ + type: TreatmentPaymentType; + /** Whether the payment was a pre-authorisation rather than a capture. */ + preAuth: boolean; + /** Whether the pre-authorisation was a quality-assurance hold rather than a real charge. */ + preAuthQa?: boolean; + /** The pre-authorised amount, when `preAuth` is true. */ + preAuthAmount?: number; + /** Card details, when the payment method is card-based. */ + card?: TreatmentCardOptions; +} + +/** The identity check nested inside {@link TreatmentVerificationOptions.id}. */ +export interface TreatmentIdVerificationOptions { + /** Whether the check passed. */ + verified: boolean; + /** Which identity check was performed. */ + method: TreatmentIdVerificationMethod; + /** The value that was checked, e.g. the SSN or date of birth submitted. */ + value: string; +} + +/** Identity/contact verification already performed by the caller, for {@link Treatments.sync}. */ +export interface TreatmentVerificationOptions { + /** Whether the patient's email was verified. */ + email: boolean; + /** Whether the patient's address was verified. */ + address: boolean; + /** The identity check performed, if any. */ + id?: TreatmentIdVerificationOptions; +} + /** Options for {@link Treatments.sync}. */ export interface TreatmentSyncOptions { /** The session the orders belong to. */ session: string; /** Order identifiers from your own commerce system. */ orderIds: string[]; + /** + * The visitor's user agent, forwarded as the required `User-Agent` header. + * Since the SDK runs server-to-server, only the consuming application knows + * the real value - read it from the incoming request and pass it here. + */ + userAgent: string; /** Campaign attribution to record alongside the orders. */ utmSource?: string; - /** The visitor's user agent, forwarded as the `User-Agent` header. */ - userAgent?: string; + /** The settled payment method for the order. */ + payment?: TreatmentPaymentOptions; + /** Identity/contact verification already performed by the caller. */ + verification?: TreatmentVerificationOptions; } /** @@ -119,16 +180,25 @@ export class Treatments extends AbstractResource { * The alternative to {@link Treatments.create} for flows where checkout happens * outside your storefront: hand over the session and your order identifiers and * the server creates the treatments and attributes them to that session's - * journey. Several orders can be reconciled in one call. + * journey. Several orders can be reconciled in one call. `payment`, when + * supplied, carries the settled payment method for the order; `verification`, + * when supplied, records identity/contact verification already performed by + * the caller. * - * @param options The session, your order identifiers, and optional attribution. + * @param options The session, your order identifiers, the required user agent, + * and optional attribution, payment, and verification details. * @returns The created treatments. + * @throws {TypeError} If `userAgent` is missing or empty. * @throws {ValidationError} If the session or an order identifier is rejected. * @throws {NotFoundError} If the session does not exist. * @throws {ApiError} On any other non-2xx status. * @throws {TransportError} If the request never completed. */ async sync>(options: TreatmentSyncOptions): Promise> { + if (options.userAgent === undefined || options.userAgent === '') { + throw new TypeError('Treatments.sync requires a non-empty userAgent.'); + } + const body: Record = { session_id: options.session, order_ids: options.orderIds, @@ -138,15 +208,41 @@ export class Treatments extends AbstractResource { body.utm_source = options.utmSource; } - const headers = - options.userAgent !== undefined && options.userAgent !== '' ? { 'User-Agent': options.userAgent } : {}; + if (options.payment !== undefined) { + const payment: Record = { + type: options.payment.type, + pre_auth: options.payment.preAuth, + }; + + if (options.payment.preAuthQa !== undefined) { + payment.pre_auth_qa = options.payment.preAuthQa; + } + + if (options.payment.preAuthAmount !== undefined) { + payment.pre_auth_amount = options.payment.preAuthAmount; + } + + if (options.payment.card !== undefined) { + payment.card = options.payment.card; + } + + body.payment = payment; + } + + if (options.verification !== undefined) { + body.verification = { + email: options.verification.email, + address: options.verification.address, + ...(options.verification.id !== undefined ? { id: options.verification.id } : {}), + }; + } return await this.transport.send({ service: 'sales', method: 'POST', path: '/treatments/sync', body, - headers, + headers: { 'User-Agent': options.userAgent }, }); } } diff --git a/test/resource/treatments.spec.ts b/test/resource/treatments.spec.ts index 7bc4a12..209aa10 100644 --- a/test/resource/treatments.spec.ts +++ b/test/resource/treatments.spec.ts @@ -58,7 +58,11 @@ describe('Treatments.sync', () => { const { transport, http } = makeHarness(); http.enqueueJson(200, OK_ENVELOPE); - await new Treatments(transport).sync({ session: 's-1', orderIds: ['EXT-1', 'EXT-2'] }); + await new Treatments(transport).sync({ + session: 's-1', + orderIds: ['EXT-1', 'EXT-2'], + userAgent: 'Mozilla/5.0 (test)', + }); const request = http.lastRequest(); expect(request.url).toBe('https://api.astermd.com/v1/sales/treatments/sync'); @@ -76,10 +80,20 @@ describe('Treatments.sync', () => { const treatments = new Treatments(transport); - await treatments.sync({ session: 's-1', orderIds: ['EXT-1'], utmSource: 'newsletter' }); + await treatments.sync({ + session: 's-1', + orderIds: ['EXT-1'], + userAgent: 'Mozilla/5.0 (test)', + utmSource: 'newsletter', + }); expect(JSON.parse(http.lastRequest().body)).toMatchObject({ utm_source: 'newsletter' }); - await treatments.sync({ session: 's-1', orderIds: ['EXT-1'], utmSource: '' }); + await treatments.sync({ + session: 's-1', + orderIds: ['EXT-1'], + userAgent: 'Mozilla/5.0 (test)', + utmSource: '', + }); expect(JSON.parse(http.lastRequest().body)).toMatchObject({ utm_source: '' }); }); @@ -87,27 +101,135 @@ describe('Treatments.sync', () => { const { transport, http } = makeHarness(); http.enqueueJson(200, OK_ENVELOPE); - await new Treatments(transport).sync({ session: 's-1', orderIds: ['EXT-1'] }); + await new Treatments(transport).sync({ session: 's-1', orderIds: ['EXT-1'], userAgent: 'Mozilla/5.0 (test)' }); const body = JSON.parse(http.lastRequest().body) as Record; expect(Object.keys(body)).not.toContain('utm_source'); }); - it('forwards a non-empty user agent as a header and omits an empty one', async () => { + it('forwards the required user agent as a header', async () => { const { transport, http } = makeHarness(); http.enqueueJson(200, OK_ENVELOPE); + + await new Treatments(transport).sync({ + session: 's-1', + orderIds: ['EXT-1'], + userAgent: 'Mozilla/5.0 (test)', + }); + + expect(http.lastRequest().headers['user-agent']).toBe('Mozilla/5.0 (test)'); + }); + + it('throws TypeError for an empty user agent and sends no request', async () => { + const { transport, http } = makeHarness(); + + await expect( + new Treatments(transport).sync({ session: 's-1', orderIds: ['EXT-1'], userAgent: '' }), + ).rejects.toThrow(TypeError); + expect(http.requests).toHaveLength(0); + }); + + it('throws TypeError for a missing user agent and sends no request', async () => { + const { transport, http } = makeHarness(); + + await expect( + new Treatments(transport).sync({ + session: 's-1', + orderIds: ['EXT-1'], + } as unknown as Parameters[0]), + ).rejects.toThrow(TypeError); + expect(http.requests).toHaveLength(0); + }); + + it('includes payment when given, omitting unset optional fields', async () => { + const { transport, http } = makeHarness(); http.enqueueJson(200, OK_ENVELOPE); - const treatments = new Treatments(transport); + await new Treatments(transport).sync({ + session: 's-1', + orderIds: ['EXT-1'], + userAgent: 'Mozilla/5.0 (test)', + payment: { type: 'credit_card', preAuth: false }, + }); - await treatments.sync({ + expect(JSON.parse(http.lastRequest().body)).toMatchObject({ + payment: { type: 'credit_card', pre_auth: false }, + }); + const payment = (JSON.parse(http.lastRequest().body) as Record).payment as Record; + expect(Object.keys(payment)).not.toContain('pre_auth_qa'); + expect(Object.keys(payment)).not.toContain('pre_auth_amount'); + expect(Object.keys(payment)).not.toContain('card'); + }); + + it('includes a full payment with card and pre-auth details', async () => { + const { transport, http } = makeHarness(); + http.enqueueJson(200, OK_ENVELOPE); + + await new Treatments(transport).sync({ session: 's-1', orderIds: ['EXT-1'], userAgent: 'Mozilla/5.0 (test)', + payment: { + type: 'credit_card', + preAuth: true, + preAuthQa: false, + preAuthAmount: 100.99, + card: { type: 'visa', bin: '411111', exp: '12/29' }, + }, }); - expect(http.lastRequest().headers['user-agent']).toBe('Mozilla/5.0 (test)'); - await treatments.sync({ session: 's-1', orderIds: ['EXT-1'], userAgent: '' }); - expect(http.lastRequest().headers['user-agent']).not.toBe(''); + expect(JSON.parse(http.lastRequest().body)).toMatchObject({ + payment: { + type: 'credit_card', + pre_auth: true, + pre_auth_qa: false, + pre_auth_amount: 100.99, + card: { type: 'visa', bin: '411111', exp: '12/29' }, + }, + }); + }); + + it('omits payment when it is not supplied', async () => { + const { transport, http } = makeHarness(); + http.enqueueJson(200, OK_ENVELOPE); + + await new Treatments(transport).sync({ session: 's-1', orderIds: ['EXT-1'], userAgent: 'Mozilla/5.0 (test)' }); + + const body = JSON.parse(http.lastRequest().body) as Record; + expect(Object.keys(body)).not.toContain('payment'); + }); + + it('includes verification when given, with and without the id check', async () => { + const { transport, http } = makeHarness(); + http.enqueueJson(200, OK_ENVELOPE); + + await new Treatments(transport).sync({ + session: 's-1', + orderIds: ['EXT-1'], + userAgent: 'Mozilla/5.0 (test)', + verification: { + email: true, + address: false, + id: { verified: true, method: 'ssn', value: '1234' }, + }, + }); + + expect(JSON.parse(http.lastRequest().body)).toMatchObject({ + verification: { + email: true, + address: false, + id: { verified: true, method: 'ssn', value: '1234' }, + }, + }); + }); + + it('omits verification when it is not supplied', async () => { + const { transport, http } = makeHarness(); + http.enqueueJson(200, OK_ENVELOPE); + + await new Treatments(transport).sync({ session: 's-1', orderIds: ['EXT-1'], userAgent: 'Mozilla/5.0 (test)' }); + + const body = JSON.parse(http.lastRequest().body) as Record; + expect(Object.keys(body)).not.toContain('verification'); }); });