# Node SDK

The Inovio gateway SDK for Node 18 and TypeScript, with a typed promise-based API and no runtime dependencies.

Source: https://developer.inoviopay.com/sdks/node.html  
Markdown: https://developer.inoviopay.com/sdks/node.md
Repository: https://github.com/Inoviopay/inovio-gateway-sdk-node

The Inovio payment gateway for Node. Card transactions covering authorize,
capture, refund and tokenize, with a typed, promise-based API and no runtime
dependencies.

This is the **reference implementation**. It defined the canonical method
surface, the naming and the conformance fixtures that the PHP, Python and Java
SDKs were ported against, so where the four disagree, this is the intended
shape.

> **The SDK is alpha**
> Version 0.1.0-alpha and **not published to npm**. Install from the public
> GitHub repository as shown below, and pin a commit until a tagged release
> lands.

## Status and install

npm installs directly from the GitHub repository:

```bash
npm install github:Inoviopay/inovio-gateway-sdk-node
```

Pin a commit rather than tracking `main` while the package is alpha:

```bash
npm install github:Inoviopay/inovio-gateway-sdk-node#<commit-sha>
```

The `package.json` sets `"private": true`, which blocks an accidental
`npm publish` before the package is ready. It does not affect installing from a
git URL.

> **A git install does not build the package**
> The repository ships TypeScript sources and `main` points at `dist/`, but
> there is no `prepare` script, so `npm install github:...` leaves you without a
> compiled `dist/`. Until a build step is wired in, clone and build:
>
> ```bash
> git clone https://github.com/Inoviopay/inovio-gateway-sdk-node.git
> cd inovio-gateway-sdk-node
> npm install && npm run build
> ```
>
> Then depend on it with `npm install /path/to/inovio-gateway-sdk-node`, or run
> `npm link` in the clone followed by `npm link @inovio/gateway-sdk` in your
> project.

## Requirements

Node **18 or newer**. Zero runtime dependencies: HTTP goes through the built-in
`fetch`, and the flat key-value response is parsed without a JSON library
beyond the platform's own.

The package is **ESM** (`"type": "module"`), with `.d.ts` declarations shipped
alongside. Import it with `import`, not `require`. From a CommonJS file, use a
dynamic import:

```js
const { InovioClient } = await import('@inovio/gateway-sdk');
```

## Quick start

```ts
import { InovioClient, Money, PaymentMethods, Refs } from '@inovio/gateway-sdk';

const client = new InovioClient(
  { reqUsername: process.env.INOVIO_USER!, reqPassword: process.env.INOVIO_PASS!, siteId: '123' },
  { environment: 'SANDBOX' }
);

const result = await client.sale({
  paymentMethod: PaymentMethods.card('4111111111111111', '122030', '123'),
  lineItems: [{ productId: 'SKU-1', count: 1, value: Money.of('10.00', 'USD') }],
  idempotency: { xtlOrderId: 'ORDER-555' },   // retry-safe by default
});

switch (result.status) {
  case 'APPROVED': /* fulfil */ break;
  case 'DECLINED': /* result.outcome.service, result.serviceClassification */ break;
  case 'PENDING':  /* result.nextAction — 3DS challenge, redirect, voucher */ break;
  case 'RUNNING':
  case 'FAILED':   break;
}
```

The second constructor argument is a `ClientOptions` object. Everything on it is
optional:

```ts
const client = new InovioClient(creds, {
  environment: 'PRODUCTION',
  endpoint: 'http://localhost:8080/payment/pmt_service.cfm',  // overrides environment
  apiVersion: '4.14',
  timeoutMs: 30_000,
  httpClient: myInstrumentedClient,
  siteKey: process.env.INOVIO_SITE_KEY,      // required only for tokenize()
});
```

## Node-specific notes

**Requests are plain object literals.** There is no request builder class.
`sale()` and `authorize()` take a `SaleRequest` / `AuthorizeRequest` interface,
so you write the object inline and TypeScript checks it. That is why the quick
start passes `lineItems` as an array of literals rather than constructing a
`LineItem`.

**`Money` refuses JavaScript numbers.** `Money.of(1.25, 'USD')` throws a
`TypeError`; pass `'1.25'`. Binary floats cannot represent decimal amounts
exactly, and the wire format is a decimal string, so rounding has to be your
explicit decision.

**The timeout error is `TimeoutError`,** a subclass of `TransportError`. It
carries `xtlOrderId` and a `recoveryHint` getter.

**Payment methods are a discriminated union.** `PaymentMethods.card()`,
`.token()` and `.savedCard()` return objects tagged with a `kind` field, so a
`switch` over a payment method narrows correctly. The variants declared but not
implemented in v1 are rejected at request-build time with a clear message rather
than sent to the gateway.

**References are branded types.** `OrderRef` and friends are structurally
`{ poId: string }` but branded, so a plain object literal will not type-check
where a ref is expected. Build them with the `Refs` factory:
`Refs.order('18800001')`, `Refs.xtlOrder('ORDER-555')`, `Refs.lineItem('9000001')`.

## Operations

Every public method on `InovioClient`, with the `REQUEST_ACTION` it maps to. All
of them return a promise.

| Method | Action | Notes |
|---|---|---|
| `sale(req: SaleRequest): Promise<TransactionResult>` | `CCAUTHCAP` | Authorize and capture in one step. See [Sale](https://developer.inoviopay.com/api/sale.md). |
| `authorize(req: AuthorizeRequest): Promise<TransactionResult>` | `CCAUTHORIZE` | Hold funds; capture later. See [Authorize](https://developer.inoviopay.com/api/authorize.md). |
| `capture(order: OrderRef, amount?: Money): Promise<TransactionResult>` | `CCCAPTURE` | Omit the amount to capture in full. See [Capture](https://developer.inoviopay.com/api/capture.md). |
| `captureLineItem(order: OrderRef, item: LineItemRef, amount: Money): Promise<TransactionResult>` | `CCCAPTURE` | All three arguments are required. |
| `reverse(order: OrderRef): Promise<TransactionResult>` | `CCREVERSE` | Void the original authorization. See [Reversal](https://developer.inoviopay.com/api/reversal.md). |
| `reverseCapture(order: OrderRef): Promise<TransactionResult>` | `CCREVERSECAP` | Void a capture rather than the original auth. |
| `refund(order: OrderRef, amount?: Money): Promise<TransactionResult>` | `CCCREDIT` | Omit the amount to refund in full. See [Credit](https://developer.inoviopay.com/api/credit.md). |
| `forceCredit(req: CreditRequest): Promise<TransactionResult>` | `CCCREDIT` + `FORCE_CREDIT` | Credit with no referenced original. Needs MID provisioning. |
| `status(ref: OrderRef \| XtlOrderId): Promise<OrderStatus>` | `CCSTATUS` | Order-level net position. See [Status](https://developer.inoviopay.com/api/status.md). |
| `updateOrder(order: OrderRef, update: OrderUpdate): Promise<TransactionResult>` | `CCTRANSUPDATE` | Attach a receipt and UDF metadata. |
| `tokenize(card: Card, options?: { uniqueId?: string }): Promise<TokenizeResult>` | token service | Needs `siteKey`. See [Tokenization](https://developer.inoviopay.com/api/tokenization.md). |
| `testAuth(): Promise<HealthResult>` | `TESTAUTH` | Verify credentials without transacting. |
| `testAvailability(): Promise<HealthResult>` | `TESTGW` | Verify gateway availability. Safe to poll. |

`captureLineItem()` requires the parent order as well as the line item. The
gateway rejects `REQUEST_REF_PO_LI_ID` on its own with API 113 "Invalid Data",
and `LineItemRef` does not carry its order, so both must be passed. Omitting the
amount throws a `ValidationError` locally rather than letting the gateway reject
it. Both behaviours were verified against the live gateway.

> **Two capabilities are PHP-only in this alpha**
> `reverse()` and `reverseCapture()` take only the order reference here and
> never send `CREDIT_ON_FAIL`, and there is no `threeDSecure()` sub-client. See
> [Reverse versus credit](https://developer.inoviopay.com/sdks/concepts.md#reverse-versus-credit) and
> [3D Secure](https://developer.inoviopay.com/sdks/concepts.md#3d-secure) for what to do instead.

## Handling results

`result.status` carries the answer. A decline resolves normally with
`status: 'DECLINED'`; rejections are reserved for cases where you never got a
payment answer at all.

```ts
const result = await client.sale({
  paymentMethod: PaymentMethods.card(pan, expiry, cvv),
  lineItems: [{ productId: 'SKU-1', count: 1, value: Money.of('10.00', 'USD') }],
  customer, billingAddress,
  // Setting an order id makes the call retry-safe: a repeat returns the
  // original result instead of charging twice.
  idempotency: { xtlOrderId: 'ORDER-555' },
});

switch (result.status) {
  case 'APPROVED':
    // fulfil the order
    break;
  case 'DECLINED':
    // The service tier carries the decline taxonomy your dunning logic needs.
    if (result.serviceClassification?.retryable) { /* retry later */ }
    break;
  case 'PENDING':
    // complete result.nextAction.kind
    break;
  default:
    // inspect result.outcome
}
```

Reference keys sit flat on the result, not inside a nested bag, because they are
the fields you reach for most: `client.capture(result.orderRef, amount)`. The
available refs are `orderRef`, `xtlOrderRef`, `transactionId`, `requestId`,
`batchId`, `customerRef`, `savedCardRef`, `membershipRef` and `lineItemRefs`.

The gateway sends codes; the SDK adds labels. `result.serviceClassification.retryable`,
`.terminal` and `.stopRecurring`, plus `result.avs.classification`, are labels
the SDK derives from the response codes, not values the gateway sent. The codes
themselves are on `result.outcome` and the untouched wire fields on
`result.raw`. See [Outcome tiers](https://developer.inoviopay.com/sdks/concepts.md#outcome-tiers).

Errors extend `InovioError`: `AuthenticationError`, `ValidationError` (carrying
the offending `refField`), `ConfigurationError`, `TransportError`,
`TimeoutError` (a subclass of `TransportError`) and `RateLimitError`.

## Tokenization

`tokenize()` exchanges a PAN for a single-use `TOKEN_GUID` that replaces
`PMT_NUMB` on a later sale or authorize. It hits `token_service.cfm` with HMAC
header authentication rather than username and password, so it needs `siteKey`
in `ClientOptions`. Without it the call throws a `ValidationError` before any
network traffic, and the service itself would answer error 121.

```ts
// Tokenize on the site that holds the HMAC key...
const t = await tokenClient().tokenize(PaymentMethods.card(pan, expiry, cvv));

t.token.guid;
t.tokenReqId;   // quote this to support

// BIN metadata is best-effort — undefined when the BIN is not in the table.
[t.card.brand, t.card.type, t.card.bank].filter(Boolean).join(' / ');

// The token replaces the PAN ONLY: expiry (and CVV) still travel with it, which
// tokenize() carries forward for you.
// ...then transact on the gateway site.
const sale = await client().sale({
  paymentMethod: t.token,
  lineItems: [{ productId: 'SKU-1', count: 1, value: Money.of('10.00', 'USD') }],
  customer, billingAddress,
  idempotency: { xtlOrderId: 'ORDER-556' },
});
```

The signed message **excludes the PAN**, contrary to what the v4.14 PDF says.
The gateway validates `hmac_sha256(timestamp || unique_id || site_id, site_key)`,
verified against the live token service. The SDK signs the way the gateway
behaves.

For a merchant-hosted signature endpoint that lets a browser post the PAN
directly, use the exported helpers on their own:

```ts
import { signTokenRequest, tokenTimestamp, verifyTokenResponse } from '@inovio/gateway-sdk';

const timestamp = tokenTimestamp();                                   // YYYYMMDDHHMMSS UTC
const signature = signTokenRequest(siteKey, timestamp, uniqueId, siteId);
// Return { signature, timestamp, siteId } to the browser. Never the site key.
```

> **tokenize() runs on your server**
> The card number passes through your infrastructure. The browser Hosted Fields
> client that would keep it in the cardholder's browser is not yet available.
> See [Where the card number goes](https://developer.inoviopay.com/sdks/index.md#where-the-card-number-goes).

## 3D Secure

This SDK carries the request-side and result-side 3DS surface, but not the
server-leg driver.

What is here:

- **`BrowserData`** on a request, which the request builder maps to the
  `P3DS_BROWSER_LANGUAGE`, `USER_AGENT_XTL` and `P3DS_BROWSER_HEADER` fields
  plus the optional EMVCo device fields. **These are required for gateway 3DS**:
  without them the gateway silently skips authentication entirely.
- **The challenge `nextAction`.** When a result is `PENDING` because a challenge
  is required, `result.nextAction` narrows to
  `{ kind: 'threeDSChallenge'; redirectUrl?; jwt?; procTransId?; pareq? }`,
  which is everything your page needs to post into the visible iframe.

```ts
if (result.status === 'PENDING' && result.nextAction?.kind === 'threeDSChallenge') {
  const { redirectUrl, jwt } = result.nextAction;
  // Browser: POST jwt to redirectUrl in a visible iframe.
}
```

> **The 3DS server legs are PHP-only in this alpha**
> There is no `client.threeDSecure()` here. The prepare leg against
> `3dsrequest.cfm` and the completion leg after the ACS challenge have to be
> driven against [the 3DS endpoints](https://developer.inoviopay.com/api/3ds-gateway.md) directly, or through
> the [PHP SDK](https://developer.inoviopay.com/sdks/php.md#3d-secure), which implements both.

## Timeouts and reconciliation

A timeout means the state is **unknown**, not failed. The gateway may have
approved the charge and lost the response.

```ts
try {
  await client.sale({ ...req, idempotency: { xtlOrderId: 'ORDER-555' } });
} catch (e) {
  if (e instanceof TimeoutError) {
    console.warn(e.recoveryHint);
    const actual = await client.status(Refs.xtlOrder('ORDER-555'));
    // a blind retry here could double-charge
  }
}
```

Idempotency defaults to `RETURN_ORIGINAL` when `xtlOrderId` is set, so a retry
returns the original result rather than charging twice.

`status()` is also the reconciliation primitive. Partial captures, refunds and
voids are separate transactions sharing one order, so net position is an
order-level question:

```ts
// Build a multi-leg order: authorize 100, capture 60, refund 10.
const order = await seedOrder(c, 'STATUS', { amount: '100.00' });
await c.capture(order.orderRef, Money.of('60.00', 'USD'));
await c.refund(order.orderRef, Money.of('10.00', 'USD'));

const s = await c.status(order.orderRef);

s.transactions.length;    // every leg: auth, captures, refunds, voids
s.authorized?.amount;
s.captured?.amount;
s.refunded?.amount;
s.net?.amount;            // captured - refunded
s.outstanding?.amount;    // authorized - captured

// You can also look an order up by YOUR id:
//   await c.status(Refs.xtlOrder('ORDER-555'));
```

## Running the conformance tests

```bash
npm install
npm run generate    # regenerate enums from spec/spec-enums.json
npm run build
npm test
```

`npm test` runs both suites through the Node test runner. `npm run conformance`
runs only the cross-language corpus in `spec/conformance-fixtures.json`, the
same fixtures the other three SDKs replay, which is what keeps the four honest
about producing identically shaped results. `npm run typecheck` type-checks
without emitting.

The enums in `src/enums/generated.ts` are generated from `spec/spec-enums.json`,
extracted from the v4.14 specification appendices. Do not hand-edit them;
regenerate.

## Examples in the repo

Fourteen runnable files in `examples/`, one per operation. They are real,
executed code rather than markdown snippets, so they cannot silently drift from
the API.

```bash
npm run examples             # all 14, against a mock transport
node examples/03-sale.mjs    # just one
```

| File | Operation |
|---|---|
| `01-test-availability.mjs` | `testAvailability()`, the `TESTGW` health check |
| `02-test-auth.mjs` | `testAuth()`, credential verification with no transaction |
| `03-sale.mjs` | `sale()`, authorize and capture in one step |
| `04-authorize.mjs` | `authorize()`, holding funds and keeping the `orderRef` |
| `05-capture.mjs` | `capture()`, full or partial |
| `06-capture-line-item.mjs` | `captureLineItem()`, which needs order, item and amount |
| `07-reverse.mjs` | `reverse()`, voiding an authorization |
| `08-reverse-capture.mjs` | `reverseCapture()`, voiding a capture pre-settlement |
| `09-refund.mjs` | `refund()`, returning captured funds |
| `10-force-credit.mjs` | `forceCredit()`, which needs MID provisioning |
| `11-status.mjs` | `status()`, reconciliation and net position |
| `12-update-order.mjs` | `updateOrder()`, attaching receipts for compliance |
| `13-tokenize.mjs` | `tokenize()`, PAN to single-use token |
| `14-timeout-recovery.mjs` | The pattern that prevents double charges |

By default they run against a mock transport: no credentials, no network, no
money moves, safe in CI. Set `INOVIO_LIVE=1` plus credentials to run the same
code against a real gateway:

```bash
INOVIO_LIVE=1 \
INOVIO_USER=... INOVIO_PASS=... INOVIO_SITE_ID=... INOVIO_MERCH_ACCT_ID=... \
INOVIO_SITE_KEY=... \
npm run examples
```

Live mode creates **real transactions**, so point it at a test environment.
Running live is worth doing before you trust an integration: mocks only replay
responses you already believed in, so they cannot catch request-side errors.
Every example that operates on an existing order builds its own first, rather
than hardcoding an id that resolves only against a mock.

Two examples need provisioning that a fresh account will not have.
`forceCredit` fails with API 104 "Invalid service action" unless the MID has
`FORCE_CREDIT` enabled, which arrives as an `AuthenticationError` rather than a
decline. `tokenize` needs the per-site HMAC key from Inovio support.
