# Open Border Medusa payment plugin

Medusa payment and tax provider bridge for Open Border. Medusa owns the storefront, cart,
order, and fulfillment flow; Open Border owns Merchant-of-Record tax, duty, payment-intent,
entity-routing, and ledger state.

This package is intentionally thin. It stores no authoritative money state and calls the
Open Border API through the Node SDK.

> **Setup instructions are canonical at
> [`/guides/medusa`](https://developers.openborderpayments.com/guides/medusa).** That published page
> is the step-by-step setup guide; this README stays the detailed integration reference for package
> consumers. When the two disagree about a setup step, the published guide wins — update it in the
> same change.

## Package pages

- npmjs public package: [`@open-border/medusa-payment-openborder`](https://www.npmjs.com/package/@open-border/medusa-payment-openborder)
- Browser checkout element: [`@open-border/js`](https://www.npmjs.com/package/@open-border/js)
- Node SDK: [`@open-border/node`](https://www.npmjs.com/package/@open-border/node)
- Public demo source: [`OpenBorder/openborder-medusa-demo`](https://github.com/OpenBorder/openborder-medusa-demo)

## Upgrading from 0.7.x

A tax quote is now **required** on every charge. Open Border rejects a payment intent with no
`tax_quote_id` as a `validation_error`, so a cart that reaches payment-session creation without a
quote id fails closed in the plugin rather than charging an untaxed total. Run the Open Border tax
provider on the cart before creating the payment session, and keep the server-issued
`amount_breakdown` alongside the quote id.

If your storefront relied on creating a session for an unquoted cart, that path no longer produces a
charge — quote first. This narrows the published input contract, so the first release containing it
is a breaking `0.x` minor (`0.8.0`), never a `0.7.x` patch.

## Upgrading from 0.6.x

The unified Medusa major-unit boundary is a breaking change for the sibling money fields. The
first release containing it must be a new `0.x` minor (`0.7.0` or later), never a `0.6.x` patch.

In `0.6.x`, the provider already treated Medusa's payment `amount` as major units, but
`shipping_amount` and `line_items[].unit_amount` were documented and forwarded as Open Border
minor units. Starting with `0.7.0`, pass all three as Medusa major-unit values:

| Field                       | `0.6.x` input | `0.7.0` input |
| --------------------------- | ------------- | ------------- |
| Payment total               | `160.30`      | `160.30`      |
| `shipping_amount`           | `990`         | `9.90`        |
| `line_items[].unit_amount`  | `12900`       | `129.00`      |
| Server `amount_breakdown.*` | minor units   | minor units   |

Update the payment-session and tax-provider inputs atomically before upgrading. Integer major and
minor values are ambiguous, so the plugin cannot safely auto-detect a mixed old/new payload.
Quoted payment sessions must also retain the server-issued `amount_breakdown` alongside the quote
id so the provider can reconcile Medusa's payment total before authorization. Keep that breakdown
exactly as returned; its fields remain integer minor units.

## Install

Install the public npm packages. No `.npmrc` or registry token is required:

```bash
npm install @open-border/medusa-payment-openborder @open-border/js
```

## Configuration

Configure the Medusa server with an Open Border **secret** key. Keep this key server-side only.

```env
# Local development
OPENBORDER_API_URL=http://localhost:3000
OPENBORDER_API_KEY=sk_test_dev
OPENBORDER_PUBLISHABLE_KEY=pk_test_dev

# Sandbox / staging
# OPENBORDER_API_URL=https://api-sandbox.openborderpayments.com
# OPENBORDER_API_KEY=sk_test_...
# OPENBORDER_PUBLISHABLE_KEY=pk_test_...

# Production
# OPENBORDER_API_URL=https://api.openborderpayments.com
# OPENBORDER_API_KEY=sk_live_...
# OPENBORDER_PUBLISHABLE_KEY=pk_live_...
```

Key rules:

- `OPENBORDER_API_KEY` is the `sk_...` key. It belongs on the Medusa server and is used by the
  plugin to quote tax/duty and create payment intents.
- `OPENBORDER_PUBLISHABLE_KEY` is the `pk_...` key. It is safe for browser code and is used by
  `@open-border/js` to tokenize the buyer's card into a `pm_...` payment method.
- Test and live rails do not mix. Use `sk_test_...` with `pk_test_...`, and `sk_live_...` with
  `pk_live_...`.
- `OPENBORDER_API_URL` is optional outside local development because the SDK can choose the API
  host from the key rail. Pass it explicitly for local or internal staging environments.

## Register the payment provider

Add the Open Border provider to the Medusa v2 Payment Module.

```ts
// medusa-config.ts
module.exports = {
  modules: [
    {
      resolve: '@medusajs/medusa/payment',
      options: {
        providers: [
          {
            resolve: '@open-border/medusa-payment-openborder/providers/openborder',
            id: 'openborder',
            options: {
              apiKey: process.env.OPENBORDER_API_KEY,
              baseUrl: process.env.OPENBORDER_API_URL,
            },
          },
        ],
      },
    },
  ],
};
```

The provider identifier is `openborder`. With the provider `id` also set to `openborder`,
Medusa stores the resolved provider id as `pp_openborder_openborder`.

## Checkout flow

The integration is quote-before-pay:

1. Build Open Border line items from the Medusa cart.
2. Quote tax/duty for the buyer's ship-to destination.
3. Mount the browser checkout element with the quoted landed-cost total.
4. Send the resulting `pm_...` payment method back to the Medusa server.
5. Create or update the Medusa payment session with the payment method and quote id.
6. The Open Border provider creates the payment intent server-side.
7. Store the Open Border payment intent id and show the buyer the receipt/order ids.

### 1. Build quote line items

Use Medusa's major-unit values at the plugin boundary (for example, `25.00` for USD). The plugin
converts every amount to Open Border's integer minor-unit contract after exactness checks. Each
line item also needs an HS tariff code before the tax quote can be created.

```ts
const openBorderLineItems = cart.items.map((item) => ({
  sku: item.variant?.sku,
  description: item.title,
  quantity: item.quantity,
  unit_amount: item.unit_price,
  hs_code: item.metadata?.hs_code,
}));
```

If your catalog does not already store HS codes, classify products with Open Border before quoting
and persist the resulting code back to your product/variant metadata.

### 2. Quote tax and duty

The plugin exposes a thin Open Border client seam for testable Medusa integrations:

```ts
const {
  createOpenBorderApiClient,
  OpenBorderTaxProvider,
} = require('@open-border/medusa-payment-openborder');

const openBorder = createOpenBorderApiClient({
  apiKey: process.env.OPENBORDER_API_KEY,
  baseUrl: process.env.OPENBORDER_API_URL,
});
const taxProvider = new OpenBorderTaxProvider(openBorder);

const quote = await taxProvider.getTaxLines(openBorderLineItems, {
  destination_country: shippingAddress.country_code.toUpperCase(),
  destination_region: shippingAddress.province,
  destination_postal_code: shippingAddress.postal_code,
  ship_from_country: 'US', // your dispatch origin — the other half of the priced lane
  currency: cart.currency_code.toUpperCase(),
  shipping_amount: cart.shipping_total,
  customer: { email: cart.email },
});
```

US and CA destinations require at least one of `destination_region` or
`destination_postal_code`. Forward the Medusa shipping address values as shown; other destinations
may omit both.

`quote.amount_breakdown.total` is the landed-cost total to display before card collection.
`quote.tax_quote_id` must travel with the payment session so Open Border can revalidate the quote
when creating the payment intent.

### 3. Collect the payment method in the browser

Use the publishable key with `@open-border/js`. The browser element only tokenizes the card. It
does not charge the buyer and never receives the secret key.

```html
<div id="openborder-checkout"></div>
<script src="https://unpkg.com/@open-border/js"></script>
<script>
  const checkout = OpenBorder(window.OPENBORDER_PUBLISHABLE_KEY, {
    apiBaseUrl: window.OPENBORDER_API_URL,
  });

  checkout.mount('#openborder-checkout', {
    currency: quote.amount_breakdown.currency,
    amount: quote.amount_breakdown.total,
    billingDetails: {
      email: cart.email,
      name: cart.shipping_address?.first_name,
      address: cart.shipping_address,
    },
    onSuccess: async ({ paymentMethodId }) => {
      await fetch('/store/checkout/openborder-payment-method', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          cart_id: cart.id,
          payment_method: paymentMethodId,
          tax_quote_id: quote.tax_quote_id,
        }),
      });
    },
    onError: (message) => console.error(message),
  });
</script>
```

Only expose `OPENBORDER_PUBLISHABLE_KEY` and, when needed, a non-secret browser API URL. Do not
serialize `OPENBORDER_API_KEY` into HTML, JavaScript, logs, analytics, or error telemetry.

### 4. Create or update the payment session

When the buyer submits the card element, put the Open Border payment method and quote data into
the Medusa payment-session data. The provider reads these fields:

```ts
const sessionData = {
  cart_id: cart.id,
  payment_method: paymentMethodId,
  openborder_tax_quote_id: quote.tax_quote_id,
  amount_breakdown: quote.amount_breakdown,
  shipping_amount: cart.shipping_total,
  merchant_reference: cart.id,
  customer: {
    email: cart.email,
    name: `${cart.shipping_address.first_name} ${cart.shipping_address.last_name}`.trim(),
  },
  billing_address: toOpenBorderAddress(cart.billing_address ?? cart.shipping_address),
  shipping_address: toOpenBorderAddress(cart.shipping_address),
  line_items: openBorderLineItems,
  metadata: {
    medusa_cart_id: cart.id,
  },
};
```

Required values:

- `cart_id`, `session_id`, or `medusa_session_id` - a persisted Medusa identity that remains stable
  across retries of the same payment session.
- `payment_method` or `openborder_payment_method` - the `pm_...` token from the browser element.
- `openborder_tax_quote_id` or `tax_quote_id` - the server-issued quote id. Required: Open
  Border prices every charge from a revalidated quote, so a cart with no quote is refused before
  any authorization.
- `amount_breakdown` - the server-issued quote breakdown, retained in minor units so the provider
  can reconcile Medusa's payment-collection total before authorization.
- `line_items` - the same item fingerprint used for the quote.
- `shipping_amount` - a Medusa major-unit value, when shipping was included in the quote.
- `customer.email`
- `billing_address.line1` and `billing_address.country`
- `shipping_address.line1` and `shipping_address.country`

Optional values:

- `merchant_reference` - usually the Medusa cart, order, or payment collection id. When omitted,
  the provider uses the required stable session identity.

## Provider behavior

The payment provider maps a Medusa-shaped payment-session request into a manual-capture
`createPaymentIntent`, so Medusa can authorize first and capture or cancel through its later payment
lifecycle. Medusa passes money in major units (for example, `160.30` for USD), so the payment and
tax providers exactness-check and convert the payment-collection total, shipping amount, and every
line-item unit amount to Open Border minor units. The payment provider derives Open Border's
merchandise subtotal from `sum(quantity * unit_amount)` because Medusa's `amount` is the full
payment-collection total, including shipping and quoted tax or duty. Every payment requires the
server-issued `amount_breakdown`; the provider checks its total, subtotal, shipping, and currency
before authorization. The tax provider maps the normalized line items
and destination context into `createTaxQuote`, returning the Open Border quote id and aggregate
quote response. The plugin does not allocate tax or duty per line locally; Open Border remains the
authoritative pricing source.

The Medusa wrapper derives operation-scoped Open Border idempotency keys. Initiate and update use
the persisted payment-session identity, prior intent id, and canonical request body; capture and
cancel use the persisted Open Border payment-intent id. A changed `context.idempotency_key` on a
retry does not change those keys. Refunds keep their existing behavior: a stable Medusa refund
reference is preferred, with the request context key used only when no refund reference exists.
Status lookup reads the current Open Border payment-intent status and does not own local money state.
An update cancels a live authorization before creating its replacement, and a canceled intent can
be replaced. An update against a captured intent fails closed before cancellation or
re-authorization so a completed payment cannot produce a second buyer hold.

If the provider is called before a payment method exists, it returns a pending session with
`openborder_requires_payment_method: true`. After the browser returns a `pm_...` token, update the
session and the provider will create the Open Border payment intent.

## Receipt fields

After a successful payment-intent create, persist the returned Open Border ids alongside the Medusa
order/payment collection:

- `openborder_payment_intent_id`
- `openborder_status`
- `entity` - the Open Border entity resolved from charge currency.
- `amount_breakdown` - subtotal, shipping, tax, duty, total, and currency.
- `tax_quote_id`
- `client_secret`, when returned by the payment processor flow.

These fields let support staff reconcile Medusa orders to Open Border transactions without making
Medusa the source of truth for money.

## Local and staging test mode

Local development:

```bash
# Open Border API
pnpm dev

# Medusa app
OPENBORDER_API_URL=http://localhost:3000
OPENBORDER_API_KEY=sk_test_dev
OPENBORDER_PUBLISHABLE_KEY=pk_test_dev
```

Sandbox or internal staging:

```env
OPENBORDER_API_URL=https://api-sandbox.openborderpayments.com
OPENBORDER_API_KEY=sk_test_...
OPENBORDER_PUBLISHABLE_KEY=pk_test_...
```

Use test card numbers from the payment processor account attached to the resolved Open Border
entity. The publishable key must belong to the same test rail as the secret key.

## Demo preview vs public package guide

The demo source is maintained publicly in
[`OpenBorder/openborder-medusa-demo`](https://github.com/OpenBorder/openborder-medusa-demo). Its
deterministic, keyless static build is hosted as a public preview and does not submit a real
payment.

This README is the public integration guide for package consumers. Do not document the private
demo-only helper routes in Scalar or treat the hosted preview as the required package integration
path. External developers should install the package, register the provider, quote tax/duty
server-side, collect a browser payment method with the publishable key, and let Open Border create
the payment intent with the secret key on the Medusa server.

## Current limitations

- The browser payment element comes from `@open-border/js`; this plugin does not render checkout
  UI by itself.
- Medusa webhook action handling currently returns `not_supported`. Wire provider webhooks and
  async reconciliation in a separate integration slice.
- Capture, cancel, refund, and payment-status lookup delegate to Open Border APIs. Capture and
  cancel keys are intent-derived; refunds need a stable refund reference or a request context key.
- The plugin does not allocate tax or duty per line locally. Open Border's quote and transaction
  snapshot are authoritative.
- The plugin does not persist Open Border ids into Medusa order metadata for you; do that in your
  checkout/order completion workflow.
- `OpenBorderApiClient.reportFulfillment(...)`, `correctFulfillment(...)`, and
  `getFulfillment(...)` are explicit server-side fulfillment seams. Responses keep OpenBorder's
  fulfillment state separate from nullable advisory Ship24 state in `tracking_provider`. The
  `tracking_number` is required; `carrier` is optional and may be omitted for Ship24 auto-detection.
  `reportFulfillment` also requires `{ idempotencyKey }` because accepted tracking can release held
  funds. The package does not install an automatic Medusa lifecycle subscriber or guess fulfillment
  events.

## Security checklist

- Keep `sk_...` keys server-side.
- Expose only `pk_...` keys to browser code.
- Use test keys and sandbox URLs until production activation is approved.
- Send payment-method tokens from the browser to your backend over HTTPS.
- Never log full API keys, payment method ids, card data, or customer addresses in public logs.
- Treat Open Border's payment intent, transaction snapshot, and amount breakdown as the
  authoritative money record.
