# Changelog

All notable changes to `@easypayment/medusa-payment-paypal` are documented here.

## 1.2.7 — 2026-09-23

### Changed
- Code formatting only, no behavior changes. Version aligned with
  `@easypayment/medusa-paypal-ui` 1.2.7 — both packages now share the same
  version number.

## 1.2.6 — 2026-09-21

### Changed
- Maintenance release, no code changes. Published alongside
  `@easypayment/medusa-paypal-ui` 1.2.3 (formatting-only).

## 1.2.5 — 2026-09-14

### Fixed
- **Partial captures after the first one were never sent to PayPal.** Both
  providers' `capturePayment` short-circuited whenever the session already
  carried a `capture_id` — which is always the case after a first partial
  capture — and reported success without calling PayPal. Medusa then showed
  the full amount as captured while PayPal had only taken the first slice
  (reproduced: 20 + 30 on a 50 EUR authorization → Medusa 50 captured, PayPal
  20). Capture decisions are now driven by what PayPal actually holds for the
  order, reconciled against Medusa's own capture ledger: the remaining amount
  is captured, a request above what remains is refused (not silently
  clamped), a fully-captured order is an idempotent success, and funds that
  are still PENDING are never booked as captured. Money PayPal holds that
  Medusa has not booked — a capture whose Medusa write failed and is being
  retried with a fresh capture row (fresh `PayPal-Request-Id`), or a
  storefront capture the session never recorded — is treated as the capture
  being booked instead of being taken from the buyer a second time. The
  capture that exhausts the session amount now closes the authorization
  (`final_capture`). The logic lives once in the shared base provider instead
  of two diverging copies.

  > **Upgrade note:** if you used partial captures on 1.2.4 or earlier, Medusa
  > may show more captured than PayPal actually took. Check PayPal for
  > authorizations still in `PARTIALLY_CAPTURED` state and capture the
  > remainder from the PayPal dashboard; the plugin cannot repair those
  > retroactively because Medusa already considers them fully captured.
- **Line items sent to PayPal no longer include a bogus "Line Item Amount
  Offset" or a fabricated `shipping_discount`.** Medusa v2's `cart.subtotal`
  includes shipping and `shipping_total` includes the tax on shipping (which
  `tax_total` also carries), so with **Send Item Details** on every cart with
  a shipping charge got an offset item worth the shipping fee plus a fake
  discount to make the totals balance. The breakdown now uses `item_subtotal`,
  the pre-tax `shipping_subtotal` and `discount_subtotal` — the figures
  Medusa's own total is built from — so `item_total + shipping + tax_total −
  discount` equals the order total exactly (verified with tax, promotion and
  shipping combined). The previous drift reconciliation is kept as a safety
  net.
- **`POST /store/paypal-complete` no longer claims "Payment was processed"
  for a payment that was never approved.** When the provider rejects the
  session (buyer never approved, capture declined) the route now answers
  `402` with the real reason and `payment_authorized: false`, so a storefront
  does not tell the buyer "do not pay again" for money that was never taken.
  Transient errors keep the retryable 5xx. The check uses
  `MedusaError.isMedusaError` rather than `instanceof`, which is false across
  the payment-module boundary.
- `POST /store/payment-collections/:id/payment-sessions` surfaces the core
  workflow's real error message instead of `[object Object]`.

### Changed
- Capture and refund calls send `Prefer: return=representation`, so the
  capture/refund entries stored on the session carry amount, status and
  `final_capture` instead of the id/status/links stub.

## 1.2.4 — 2026-09-11

### Changed
- **Code formatting & documentation only — no behavior changes.** The whole
  source tree is now formatted with Prettier (`npm run format` /
  `npm run format:check`; config in `.prettierrc`, plus `.editorconfig`), and
  every module, provider, model, API route, and admin component carries JSDoc.
  Two misplaced doc blocks were re-attached to the code they describe, empty
  `catch {}` blocks now state their intent, and a few naming/unused-parameter
  inconsistencies were tidied (`_input` → `input` in the card provider).

## 1.2.3 — 2026-09-08

### Changed
- **Docs & metadata only — no code changes.** Expanded npm keywords and
  sharpened the package description for discoverability; added `homepage` and
  `bugs` links to the npm page; README now shows the correct requirements
  (Node 20+, Medusa v2.12+), a monthly-downloads badge, and a Support section.

## 1.2.0 — 2026-09-02

### Fixed
- **A cart edit can no longer resurrect a stale-amount PayPal order.** The
  create-order `PayPal-Request-Id` now includes the cart total and currency.
  Previously it was derived from the cart id alone, so after the buyer changed
  the cart, the "create a fresh order" path was silently answered from PayPal's
  idempotency cache with the ORIGINAL order at the original total.
- **`capture-order` no longer reports success for a capture that never
  completed.** The existing-capture short-circuit now requires a demonstrably
  COMPLETED capture; a webhook-patched `capture_id` (which is also written for
  DENIED/PENDING captures) is verified against the live PayPal order before
  being returned as success, so a declined payment surfaces as retryable
  instead of "payment processed but order could not be finalized".
- **The capture-order idempotency key is now scoped per order.** A client
  reusing one `Idempotency-Key` header across two orders could previously
  receive order A's cached capture for order B. Also fixed the internal
  variable shadowing so the request-correlation UUID appears consistently in
  logs.
- **Canceling a captured payment no longer records success for a refund that
  failed.** Both providers' `cancelPayment` now reject FAILED/CANCELLED/DENIED
  refund statuses (PayPal returns 2xx for those too), matching the existing
  `refundPayment` gate.

### Added
- **Webhook-driven cart completion (safety net).** When a
  `PAYMENT.CAPTURE.COMPLETED` webhook arrives for a cart the buyer never
  finalized (closed tab or crash right after paying), the plugin now completes
  the cart server-side so a captured payment always produces an order. Races
  with the storefront's own completion are handled (whoever wins, the other
  treats it as success); genuine failures follow the webhook retry schedule and
  stay visible in the dead-letter queue. Disable with
  `PAYPAL_WEBHOOK_COMPLETE_CART=false`. New metric: `webhook_cart_completed`.

### Hardened
- **Metric writes are now atomic everywhere.** The module service's
  `recordMetric` uses the same single-statement `INSERT ... ON CONFLICT`
  upsert as the payment providers (shared `utils/metrics.ts`), so concurrent
  routes/webhooks can no longer lose increments to a read-modify-write race.
- **Settings singleton reads are deterministic.** All readers (module service
  and the providers' raw-SQL credential resolver) now order by `created_at
  ASC`, and a first-boot save race merges into the canonical row instead of
  leaving a divergent duplicate.
- **Webhook endpoints can be rate-limited** (opt-in, separate from the buyer
  routes): set `PAYPAL_WEBHOOK_RATE_LIMIT_MAX` /
  `PAYPAL_WEBHOOK_RATE_LIMIT_WINDOW_MS`. Deliberately NOT tied to
  `PAYPAL_RATE_LIMIT_MAX` — PayPal delivers from a handful of egress IPs, so a
  buyer-sized cap would throttle legitimate webhook bursts.
- **Card provider observability parity.** The advanced-card provider now
  records the same audit events and metrics as the wallet provider
  (`capture_success`/`capture_failed`, `refund_*`, `void_success`,
  `cancel_*`, `authorize_failed`), converts capture errors to `MedusaError`
  so real PayPal failure reasons are not masked in production, and preserves
  the `provider_id` passthrough in `updatePayment`.
- **Startup warning when `PAYPAL_SELLER_NONCE` is auto-generated** — in
  multi-instance deployments a per-process nonce can race the onboarding
  code_verifier; the warning tells operators to pin it.
- **Single source of truth for the BN code and API base URLs** (removed 4
  duplicated constants and 9 duplicated environment ternaries).
- `npm run typecheck` (full `tsc --noEmit`, admin UI included) now passes and
  runs in CI; added `@types/react`/`react` dev dependencies.

## 1.1.0

### Fixed (re-audit)
- **PayPal webhooks and the onboarding return bridge now work without a
  publishable API key.** Medusa applies its store publishable-key middleware to
  every `/store/*` route, and PayPal cannot send `x-publishable-api-key` — so
  webhook deliveries and onboarding return redirects to the old `/store/paypal/*`
  paths were rejected before the handlers ran. Both handlers are now also served
  from `/hooks/paypal/webhook` and `/hooks/paypal/onboard-return` (no key
  guard). New registrations use the `/hooks` URLs, and existing PayPal webhook
  registrations pointing at the legacy path are migrated in place (best-effort,
  on admin status view) via PayPal's webhook PATCH API. The legacy `/store`
  routes remain for deployments that inject the key at their edge.
- **Unapproved orders can no longer be booked as authorized.** `authorizePayment`
  treated a PayPal order in `CREATED`/`SAVED` status as authorized, letting a
  cart complete (order placed, inventory reserved) with no payment approved at
  PayPal at all. Only buyer-`APPROVED` orders now count.
- **PENDING / DECLINED captures are no longer booked as captured.** The
  idempotency branches of `authorizePayment` (both providers) keyed off the mere
  presence of a capture/authorization id and returned the settings-derived
  status. They now derive the status from the actual PayPal resource: a
  `COMPLETED` capture → captured; a `PENDING` (eCheck) capture → authorized
  (order placed, funds not claimed until the `CAPTURE.COMPLETED` webhook); a
  `DENIED`/`DECLINED`/`FAILED` capture → error.
- **Partial captures no longer charge the full amount.** Medusa never passes the
  requested capture amount to a provider — only the Medusa capture-row id (as
  `context.idempotency_key`). The providers now resolve the requested amount
  from that row, so an admin capturing €40 of a €100 authorization no longer
  sends a €100 capture to PayPal.
- **Stale PayPal orders are invalidated when the cart changes.** `updatePayment`
  now drops the stored `paypal.order_id` when the session amount/currency
  changes (before any capture/authorization), and `create-order` verifies a
  stored order's amount, currency, and state against the cart before reusing it
  — so a buyer who backs out of the PayPal popup and edits their cart is charged
  the new total, not the stale one.
- **Card provider no longer 422s on approved capture-intent orders.** Its
  `authorizePayment` called `/v2/checkout/orders/{id}/authorize` regardless of
  intent, which PayPal rejects (`UNSUPPORTED_INTENT`) for `CAPTURE`-intent
  orders — permanently stranding a checkout whose storefront capture call had
  failed after 3-D Secure approval. Approved orders now return `authorized` and
  the capture is routed by intent; `/authorize` is only called for
  `AUTHORIZE`-intent orders. The impossible create-order-then-authorize
  fallbacks (both providers) were replaced with clear errors.
- **Webhook processing no longer corrupts session data.** `PAYMENT.CAPTURE.REFUNDED`
  / `REVERSED` events carry a *refund* resource — its id was being stored as the
  session's `capture_id`. Identifiers are now extracted refund-aware (with the
  capture id taken from `related_ids` or the resource's "up" link), and patches
  no longer overwrite stored fields (e.g. `order_id`) with null when an event
  lacks them.
- **Partial refunds no longer cancel the payment session.** Refund events now
  compare the cumulative refunded total against the captured amount and keep the
  session status for partial refunds (recording the refund data); only full
  refunds transition the session to canceled.
- **Rate limiter can no longer be bypassed via `X-Forwarded-For`.** The client
  key now uses the right-most (proxy-appended) XFF entry instead of the
  left-most (client-forgeable) one.
- **`paypal-complete` no longer 500s on an empty/non-JSON body** (missing body
  guard before destructuring).
- **Client-supplied idempotency keys are now cart-scoped in `create-order`**, so
  a reused `Idempotency-Key` header across two carts can no longer return cart
  A's cached PayPal order (and amount) for cart B.
- **Circuit breaker is now keyed per host.** Failures of a non-PayPal host (e.g.
  the onboarding service) can no longer open the breaker and block live
  captures/refunds against a healthy PayPal API.
- **Raw-knex reads now respect soft deletes** (`paypal_connection` /
  `paypal_settings` lookups in the credential resolver filter `deleted_at`).
- **Removed the broken `./workflows` package export** (it pointed at a file that
  was never built; importing it always failed).

### Added (re-audit)
- `GET /store/paypal/config` now emits `disable_buttons` (from
  `paypal_settings.disableButtons` / `disable_buttons`), which the UI package
  has always read to build the SDK's `disable-funding` list.


### Fixed
- **Transient capture/refund failures now retry.** The `paypalFetchWithRetry`
  wrapper (jittered backoff on 5xx/429/timeout) was implemented but never wired
  in — capture and refund used the non-retrying path. They now retry safely: the
  `PayPal-Request-Id` idempotency key means a retry re-uses the same
  capture/refund instead of double-charging.
- **Provider `recordMetric` is now atomic.** The credential-resolver metric path
  used by the payment providers (capture/refund/authorize success & failure
  counters) now uses a single `INSERT … ON CONFLICT … count = count + 1` upsert
  instead of a read-modify-write, so concurrent captures/refunds no longer lose
  increments. (The module-service metric path remains best-effort and unchanged.)
- **Capture persisted-but-lost gap is now observable.** After a successful PayPal
  capture, session persistence is retried, and if it still fails the route logs
  CRITICAL and records a `capture_order_persist_failed` metric instead of
  silently swallowing the error (the webhook and `paypal-complete`'s live
  re-derivation remain the reconciliation backstop).
- **Card provider partial-capture idempotency key** now includes the amount, so
  two sequential partial captures of the same order are no longer deduplicated by
  PayPal into one (matching the wallet provider).

### Added
- **Asynchronous webhook processing.** The webhook route now verifies, persists,
  and acknowledges (200) immediately, then processes off the request path via a
  subscriber — so PayPal's ~15s delivery timeout is never tripped by slow
  downstream work. The retry cron additionally recovers events left in
  `processing` past a staleness threshold, so an event is never lost even if the
  event bus drops a message.
- **Opt-in rate limiting** on the public store payment routes (`create-order`,
  `capture-order`, `paypal-complete`). Disabled by default (a pass-through) so
  existing deployments are unaffected; enable by setting `PAYPAL_RATE_LIMIT_MAX`
  (and optionally `PAYPAL_RATE_LIMIT_WINDOW_MS`) once you've confirmed the real
  client IP reaches the app (e.g. behind a proxy that forwards `x-forwarded-for`).
- **Encryption-key guardrail.** When `PAYPAL_ENCRYPTION_KEY` is unset, secrets
  now emit a loud (production-flagged) warning; set `PAYPAL_ENCRYPTION_STRICT=true`
  to fail closed instead of persisting plaintext secrets.
- Additional unit tests: retry/circuit-breaker behavior, the rate limiter, the
  atomic metric upsert, and session-persistence retry.

### Changed
- **`client_token` is only issued when advanced card fields are enabled** — the
  PayPal-buttons flow uses just `client_id`, so an unauthenticated config fetch no
  longer hands out a card-fields token it won't use.
- **`postMessage` target origin** for the onboarding return popup is now
  configurable (`PAYPAL_ADMIN_ORIGIN`, falling back to the first `ADMIN_CORS`
  entry) instead of the `"*"` wildcard.
- **Shared provider base class.** The wallet and advanced-card providers now
  extend a common `PayPalProviderBase` for the identical credential/token
  handling, order lookups, idempotency keys, amount normalization, and status
  mapping — removing a large block of duplicated code. Behavior is unchanged.
- Centralized the PayPal partner-attribution (BN) code into a single constant and
  removed the unused `PayPalSellerClient` dead code.

## 1.0.2

### Fixed
- `toAmountNumber` no longer silently coerces a blank string (`""` / `"   "`) or a
  blank serialized `BigNumber` value to `0`. `Number("")` is `0`, so a blank amount
  could previously turn a partial refund/capture into a full one — it now throws
  instead, consistent with the function's existing guards against `NaN`/unparseable
  input.

### Added
- Unit test suite (vitest) covering the money-critical, IO-free logic:
  amount coercion & currency formatting, currency support checks, capture/refund
  status mapping, secret encryption/decryption (AES-256-GCM round-trip, tamper &
  missing-key handling), webhook signature request composition, the webhook state
  machine, retry scheduling, identifier extraction, and provider-id detection.
