@sakeetech/medusa-payment-viva 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +816 -0
- package/dist/api/index.d.ts +15 -0
- package/dist/api/index.d.ts.map +1 -0
- package/dist/api/index.js +22 -0
- package/dist/api/index.js.map +1 -0
- package/dist/api/middlewares.d.ts +27 -0
- package/dist/api/middlewares.d.ts.map +1 -0
- package/dist/api/middlewares.js +62 -0
- package/dist/api/middlewares.js.map +1 -0
- package/dist/api/viva/admin/_admin-auth.d.ts +26 -0
- package/dist/api/viva/admin/_admin-auth.d.ts.map +1 -0
- package/dist/api/viva/admin/_admin-auth.js +49 -0
- package/dist/api/viva/admin/_admin-auth.js.map +1 -0
- package/dist/api/viva/admin/_mode-gate.d.ts +28 -0
- package/dist/api/viva/admin/_mode-gate.d.ts.map +1 -0
- package/dist/api/viva/admin/_mode-gate.js +45 -0
- package/dist/api/viva/admin/_mode-gate.js.map +1 -0
- package/dist/api/viva/admin/connected-accounts/[id]/reconcile/route.d.ts +21 -0
- package/dist/api/viva/admin/connected-accounts/[id]/reconcile/route.d.ts.map +1 -0
- package/dist/api/viva/admin/connected-accounts/[id]/reconcile/route.js +93 -0
- package/dist/api/viva/admin/connected-accounts/[id]/reconcile/route.js.map +1 -0
- package/dist/api/viva/admin/connected-accounts/[id]/route.d.ts +18 -0
- package/dist/api/viva/admin/connected-accounts/[id]/route.d.ts.map +1 -0
- package/dist/api/viva/admin/connected-accounts/[id]/route.js +59 -0
- package/dist/api/viva/admin/connected-accounts/[id]/route.js.map +1 -0
- package/dist/api/viva/admin/connected-accounts/[id]/sources/route.d.ts +34 -0
- package/dist/api/viva/admin/connected-accounts/[id]/sources/route.d.ts.map +1 -0
- package/dist/api/viva/admin/connected-accounts/[id]/sources/route.js +234 -0
- package/dist/api/viva/admin/connected-accounts/[id]/sources/route.js.map +1 -0
- package/dist/api/viva/admin/connected-accounts/route.d.ts +19 -0
- package/dist/api/viva/admin/connected-accounts/route.d.ts.map +1 -0
- package/dist/api/viva/admin/connected-accounts/route.js +78 -0
- package/dist/api/viva/admin/connected-accounts/route.js.map +1 -0
- package/dist/api/viva/internal/auth-status/route.d.ts +19 -0
- package/dist/api/viva/internal/auth-status/route.d.ts.map +1 -0
- package/dist/api/viva/internal/auth-status/route.js +91 -0
- package/dist/api/viva/internal/auth-status/route.js.map +1 -0
- package/dist/api/viva/internal/metrics/route.d.ts +13 -0
- package/dist/api/viva/internal/metrics/route.d.ts.map +1 -0
- package/dist/api/viva/internal/metrics/route.js +48 -0
- package/dist/api/viva/internal/metrics/route.js.map +1 -0
- package/dist/api/viva/webhook/health/route.d.ts +16 -0
- package/dist/api/viva/webhook/health/route.d.ts.map +1 -0
- package/dist/api/viva/webhook/health/route.js +27 -0
- package/dist/api/viva/webhook/health/route.js.map +1 -0
- package/dist/api/viva/webhook/route.d.ts +57 -0
- package/dist/api/viva/webhook/route.d.ts.map +1 -0
- package/dist/api/viva/webhook/route.js +269 -0
- package/dist/api/viva/webhook/route.js.map +1 -0
- package/dist/cli/bin.d.ts +12 -0
- package/dist/cli/bin.d.ts.map +1 -0
- package/dist/cli/bin.js +78 -0
- package/dist/cli/bin.js.map +1 -0
- package/dist/cli/index.d.ts +12 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +14 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/plan.d.ts +51 -0
- package/dist/cli/plan.d.ts.map +1 -0
- package/dist/cli/plan.js +128 -0
- package/dist/cli/plan.js.map +1 -0
- package/dist/cli/register-webhooks.d.ts +54 -0
- package/dist/cli/register-webhooks.d.ts.map +1 -0
- package/dist/cli/register-webhooks.js +366 -0
- package/dist/cli/register-webhooks.js.map +1 -0
- package/dist/cli/types.d.ts +62 -0
- package/dist/cli/types.d.ts.map +1 -0
- package/dist/cli/types.js +12 -0
- package/dist/cli/types.js.map +1 -0
- package/dist/config.d.ts +158 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +236 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/loaders/viva-oauth2-strategy.d.ts +26 -0
- package/dist/loaders/viva-oauth2-strategy.d.ts.map +1 -0
- package/dist/loaders/viva-oauth2-strategy.js +58 -0
- package/dist/loaders/viva-oauth2-strategy.js.map +1 -0
- package/dist/migrations/Migration_20260425000001_init_viva_payments.d.ts +19 -0
- package/dist/migrations/Migration_20260425000001_init_viva_payments.d.ts.map +1 -0
- package/dist/migrations/Migration_20260425000001_init_viva_payments.js +136 -0
- package/dist/migrations/Migration_20260425000001_init_viva_payments.js.map +1 -0
- package/dist/migrations/Migration_20260425000002_allow_null_order_code.d.ts +31 -0
- package/dist/migrations/Migration_20260425000002_allow_null_order_code.d.ts.map +1 -0
- package/dist/migrations/Migration_20260425000002_allow_null_order_code.js +71 -0
- package/dist/migrations/Migration_20260425000002_allow_null_order_code.js.map +1 -0
- package/dist/migrations/Migration_20260425000003_webhook_retry_count.d.ts +18 -0
- package/dist/migrations/Migration_20260425000003_webhook_retry_count.d.ts.map +1 -0
- package/dist/migrations/Migration_20260425000003_webhook_retry_count.js +42 -0
- package/dist/migrations/Migration_20260425000003_webhook_retry_count.js.map +1 -0
- package/dist/migrations/Migration_20260425000004_webhook_error_and_nullable_merchant.d.ts +29 -0
- package/dist/migrations/Migration_20260425000004_webhook_error_and_nullable_merchant.d.ts.map +1 -0
- package/dist/migrations/Migration_20260425000004_webhook_error_and_nullable_merchant.js +74 -0
- package/dist/migrations/Migration_20260425000004_webhook_error_and_nullable_merchant.js.map +1 -0
- package/dist/models/index.d.ts +7 -0
- package/dist/models/index.d.ts.map +1 -0
- package/dist/models/index.js +10 -0
- package/dist/models/index.js.map +1 -0
- package/dist/models/viva-tenant-merchant.d.ts +11 -0
- package/dist/models/viva-tenant-merchant.d.ts.map +1 -0
- package/dist/models/viva-tenant-merchant.js +54 -0
- package/dist/models/viva-tenant-merchant.js.map +1 -0
- package/dist/models/viva-transaction.d.ts +34 -0
- package/dist/models/viva-transaction.d.ts.map +1 -0
- package/dist/models/viva-transaction.js +104 -0
- package/dist/models/viva-transaction.js.map +1 -0
- package/dist/models/viva-webhook-event.d.ts +32 -0
- package/dist/models/viva-webhook-event.d.ts.map +1 -0
- package/dist/models/viva-webhook-event.js +88 -0
- package/dist/models/viva-webhook-event.js.map +1 -0
- package/dist/observability/config.d.ts +34 -0
- package/dist/observability/config.d.ts.map +1 -0
- package/dist/observability/config.js +57 -0
- package/dist/observability/config.js.map +1 -0
- package/dist/observability/index.d.ts +8 -0
- package/dist/observability/index.d.ts.map +1 -0
- package/dist/observability/index.js +15 -0
- package/dist/observability/index.js.map +1 -0
- package/dist/observability/prom-metrics.d.ts +41 -0
- package/dist/observability/prom-metrics.d.ts.map +1 -0
- package/dist/observability/prom-metrics.js +219 -0
- package/dist/observability/prom-metrics.js.map +1 -0
- package/dist/providers/payment-provider.d.ts +19 -0
- package/dist/providers/payment-provider.d.ts.map +1 -0
- package/dist/providers/payment-provider.js +24 -0
- package/dist/providers/payment-provider.js.map +1 -0
- package/dist/resolvers/auth-strategy-factory.d.ts +42 -0
- package/dist/resolvers/auth-strategy-factory.d.ts.map +1 -0
- package/dist/resolvers/auth-strategy-factory.js +60 -0
- package/dist/resolvers/auth-strategy-factory.js.map +1 -0
- package/dist/resolvers/tenant-resolver.d.ts +104 -0
- package/dist/resolvers/tenant-resolver.d.ts.map +1 -0
- package/dist/resolvers/tenant-resolver.js +118 -0
- package/dist/resolvers/tenant-resolver.js.map +1 -0
- package/dist/service.d.ts +200 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +1003 -0
- package/dist/service.js.map +1 -0
- package/dist/subscribers/index.d.ts +5 -0
- package/dist/subscribers/index.d.ts.map +1 -0
- package/dist/subscribers/index.js +10 -0
- package/dist/subscribers/index.js.map +1 -0
- package/dist/subscribers/viva-webhook-event.d.ts +38 -0
- package/dist/subscribers/viva-webhook-event.d.ts.map +1 -0
- package/dist/subscribers/viva-webhook-event.js +133 -0
- package/dist/subscribers/viva-webhook-event.js.map +1 -0
- package/dist/workflows/cleanup-old-webhook-events.d.ts +39 -0
- package/dist/workflows/cleanup-old-webhook-events.d.ts.map +1 -0
- package/dist/workflows/cleanup-old-webhook-events.js +68 -0
- package/dist/workflows/cleanup-old-webhook-events.js.map +1 -0
- package/dist/workflows/index.d.ts +14 -0
- package/dist/workflows/index.d.ts.map +1 -0
- package/dist/workflows/index.js +19 -0
- package/dist/workflows/index.js.map +1 -0
- package/dist/workflows/per-tenant-semaphore.d.ts +47 -0
- package/dist/workflows/per-tenant-semaphore.d.ts.map +1 -0
- package/dist/workflows/per-tenant-semaphore.js +89 -0
- package/dist/workflows/per-tenant-semaphore.js.map +1 -0
- package/dist/workflows/process-webhook-event.d.ts +80 -0
- package/dist/workflows/process-webhook-event.d.ts.map +1 -0
- package/dist/workflows/process-webhook-event.js +280 -0
- package/dist/workflows/process-webhook-event.js.map +1 -0
- package/dist/workflows/reprocess-unresolved-tenants.d.ts +58 -0
- package/dist/workflows/reprocess-unresolved-tenants.d.ts.map +1 -0
- package/dist/workflows/reprocess-unresolved-tenants.js +121 -0
- package/dist/workflows/reprocess-unresolved-tenants.js.map +1 -0
- package/package.json +63 -0
package/README.md
ADDED
|
@@ -0,0 +1,816 @@
|
|
|
1
|
+
# @sakeetech/medusa-payment-viva
|
|
2
|
+
|
|
3
|
+
Medusa v2 payment provider for **Viva Wallet** — multi-mode (merchant + ISV).
|
|
4
|
+
Wraps [`@sakeetech/viva-payments-core`](../viva-payments-core) and adapts it to
|
|
5
|
+
Medusa's `AbstractPaymentProvider` interface. Smart Checkout end-to-end:
|
|
6
|
+
`initiatePayment`, `authorizePayment`, `capturePayment`, `refundPayment`,
|
|
7
|
+
`cancelPayment`, plus webhook receiver, idempotent job processing, internal
|
|
8
|
+
metrics, and a `viva-register-webhooks` CLI.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
> **v0.2.0 — alpha. Live demo verification pending.**
|
|
13
|
+
>
|
|
14
|
+
> Upgrading from `0.1.x`? See [`docs/MIGRATION-0.1-to-0.2.md`](https://github.com/sakee-tech/vivawallet-npm-public/blob/main/docs/MIGRATION-0.1-to-0.2.md).
|
|
15
|
+
> All locked decisions (auth, endpoints, webhook contract, error envelope, state
|
|
16
|
+
> machine) live in `docs/` — this README links into them rather than duplicating.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Mode matrix
|
|
21
|
+
|
|
22
|
+
The plugin runs in one of two operational modes. **`merchant` is the default**
|
|
23
|
+
(90% case). Set `VIVA_MODE=isv` to opt into ISV multi-tenant mode.
|
|
24
|
+
|
|
25
|
+
| Capability | `merchant` *(default)* | `isv` *(opt-in)* |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| **Who it's for** | Single direct Viva merchant — your store, your account | SaaS / platform onboarding many merchants under one ISV partner agreement |
|
|
28
|
+
| **Required credentials** | One OAuth2 pair (`VIVA_CLIENT_ID`/`SECRET`) + one Basic pair (`VIVA_MERCHANT_ID`/`API_KEY`) | Platform-wide OAuth2 pair + legacy Basic pair + optional Reseller Basic pair (`VIVA_RESELLER_*`) |
|
|
29
|
+
| **OAuth2 scope** | `urn:viva:payments:core:api:redirectcheckout` (+ `acquiring` for Fast Refund) | `urn:viva:payments:core:api:isv` (+ `acquiring`) |
|
|
30
|
+
| **Per-call merchant scoping** | None — token IS the merchant | `?merchantId={uuid}` query on every Smart Checkout / transaction call |
|
|
31
|
+
| **Onboarding flow** | None — merchant signs up with Viva directly | `POST /isv/v1/accounts` → hosted KYC → 8194 verification webhook |
|
|
32
|
+
| **Tenant resolver** | Bypassed | `DefaultTenantResolver` reads `merchantId` from cart / store custom field |
|
|
33
|
+
| **Webhook registration** | Manual — Self Care UI; CLI prints the URLs + verification key | Automated — CLI calls `POST /isv/v1/webhooks` |
|
|
34
|
+
| **Webhook events handled** | 1796, 1797, 1798, 4865 | …plus 8193 (Account Connected), 8194 (Account Verification) |
|
|
35
|
+
| **Admin REST surface** | `GET /viva/internal/*`, `GET /viva/webhook/health` | …plus `POST/GET /viva/admin/connected-accounts/*`, `POST .../sources` |
|
|
36
|
+
| **Refund paths** | Fast Refund (Visa/MC) → Standard refund (legacy Basic auth) | Same, plus optional per-tenant approval state |
|
|
37
|
+
|
|
38
|
+
If `VIVA_MODE` is unset, the plugin **defaults to `'merchant'`** and prints a
|
|
39
|
+
one-time startup warning. Set the flag explicitly to silence it.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Prerequisites
|
|
44
|
+
|
|
45
|
+
| Requirement | Notes |
|
|
46
|
+
|---|---|
|
|
47
|
+
| **Medusa v2** | `@medusajs/framework ^2.0`, `@medusajs/types ^2.0` (peer deps) |
|
|
48
|
+
| **Postgres** | Medusa default. The plugin adds two tables: `viva_transaction`, `viva_webhook_event`. |
|
|
49
|
+
| **Node.js ≥ 20** | |
|
|
50
|
+
| **A Viva account** | Direct merchant (Self Care → API Access → Smart Checkout) — OR — ISV partner credentials. Demo + production use the same credential pair; `VIVA_ENVIRONMENT` switches the base URL. |
|
|
51
|
+
| **Worker process** | Medusa's built-in worker runs the webhook processing workflow. No separate Redis-backed queue required (BullMQ optional for multi-worker locks). |
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## Install
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pnpm add @sakeetech/medusa-payment-viva
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Peer dependencies (already in any Medusa v2 project):
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pnpm add @medusajs/framework @medusajs/types
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Quick start — merchant mode
|
|
70
|
+
|
|
71
|
+
The 90% case. You have a single Viva merchant account and want to take payments
|
|
72
|
+
on a single Medusa store.
|
|
73
|
+
|
|
74
|
+
### 1. Get credentials from Viva Self Care
|
|
75
|
+
|
|
76
|
+
In [Viva Self Care](https://demo.vivapayments.com) → **Settings → API Access**:
|
|
77
|
+
|
|
78
|
+
- **Smart Checkout credentials** → `clientId` + `clientSecret` (OAuth2 pair).
|
|
79
|
+
- **Merchant ID + API Key** → Basic-auth pair (used by refunds — probe-verified
|
|
80
|
+
2026-04-25, only the legacy host accepts the refund call).
|
|
81
|
+
|
|
82
|
+
### 2. Configure env vars
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
# .env
|
|
86
|
+
VIVA_MODE=merchant # optional (default)
|
|
87
|
+
VIVA_ENVIRONMENT=demo # 'demo' | 'production'
|
|
88
|
+
VIVA_CLIENT_ID=your-smart-checkout-client-id
|
|
89
|
+
VIVA_CLIENT_SECRET=your-smart-checkout-client-secret
|
|
90
|
+
VIVA_MERCHANT_ID=your-merchant-uuid # for refunds
|
|
91
|
+
VIVA_API_KEY=your-api-key # for refunds
|
|
92
|
+
VIVA_WEBHOOK_VERIFICATION_KEY= # leave empty until step 4
|
|
93
|
+
VIVA_SOURCE_CODE=Default # optional; Smart Checkout source code
|
|
94
|
+
VIVA_ADMIN_TOKEN=long-random-string # optional; gates /viva/internal/*
|
|
95
|
+
VIVA_WEBHOOK_BASE_URL=https://your-store.com
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### 3. Wire the provider into `medusa-config.ts`
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
import { defineConfig } from '@medusajs/framework/utils';
|
|
102
|
+
|
|
103
|
+
export default defineConfig({
|
|
104
|
+
modules: {
|
|
105
|
+
payment: {
|
|
106
|
+
resolve: '@medusajs/medusa/payment',
|
|
107
|
+
options: {
|
|
108
|
+
providers: [
|
|
109
|
+
{
|
|
110
|
+
resolve: '@sakeetech/medusa-payment-viva',
|
|
111
|
+
id: 'viva',
|
|
112
|
+
options: {
|
|
113
|
+
// The provider reads VIVA_* env vars at load time via
|
|
114
|
+
// loadConfigFromEnv(). No options need to be passed here for the
|
|
115
|
+
// default case — every knob is an env var. See §Configuration.
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### 4. Register webhooks (manual — merchant mode)
|
|
126
|
+
|
|
127
|
+
Viva does not expose a programmatic webhook-registration API for direct
|
|
128
|
+
merchants. The CLI fetches the verification key for you and prints the URLs
|
|
129
|
+
you paste into Self Care.
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
pnpm exec viva-register-webhooks --apply
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Output:
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
Manual webhook setup required (merchant mode).
|
|
139
|
+
|
|
140
|
+
1. Go to Viva Self Care → Sales → API Access → Webhooks.
|
|
141
|
+
2. For each event below, register the URL with your storefront base URL:
|
|
142
|
+
|
|
143
|
+
Transaction Payment Created (1796): https://your-store.com/viva/webhook
|
|
144
|
+
Transaction Reversal Created (1797): https://your-store.com/viva/webhook
|
|
145
|
+
Transaction Payment Failed (1798): https://your-store.com/viva/webhook
|
|
146
|
+
Order Updated (4865): https://your-store.com/viva/webhook
|
|
147
|
+
|
|
148
|
+
3. Paste this verification key into VIVA_WEBHOOK_VERIFICATION_KEY in .env:
|
|
149
|
+
<uuid-printed-here>
|
|
150
|
+
|
|
151
|
+
4. Restart your Medusa server.
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Paste the verification key into `.env` and restart Medusa.
|
|
155
|
+
|
|
156
|
+
> See [`docs/WEBHOOKS.md`](https://github.com/sakee-tech/vivawallet-npm-public/blob/main/docs/WEBHOOKS.md) for the full envelope shape
|
|
157
|
+
> and per-event payload reference.
|
|
158
|
+
|
|
159
|
+
### 5. Run migrations
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
pnpm medusa db:migrate
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Creates `viva_transaction` and `viva_webhook_event` tables.
|
|
166
|
+
|
|
167
|
+
### 6. Take a payment from the storefront
|
|
168
|
+
|
|
169
|
+
The provider exposes a `viva` payment session via Medusa's standard payment
|
|
170
|
+
flow. Your storefront calls:
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
// 1. Create a payment session
|
|
174
|
+
await medusa.store.payment.initiatePaymentSession({
|
|
175
|
+
cart_id: cartId,
|
|
176
|
+
provider_id: 'pp_viva_viva',
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// 2. Read the redirect URL from session.data
|
|
180
|
+
const { payment_session } = await medusa.store.payment.retrievePaymentSession(...);
|
|
181
|
+
const redirectUrl = payment_session.data.redirectUrl;
|
|
182
|
+
|
|
183
|
+
// 3. Send the customer to Smart Checkout
|
|
184
|
+
window.location.href = redirectUrl;
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
After the customer completes payment, Viva fires webhook `1796` to
|
|
188
|
+
`/viva/webhook`. The worker retrieves the transaction, validates the amount,
|
|
189
|
+
and transitions the payment session to `authorized` (Medusa's terminal success
|
|
190
|
+
state for capture — see [`docs/STATE-MACHINE.md`](https://github.com/sakee-tech/vivawallet-npm-public/blob/main/docs/STATE-MACHINE.md) §4.2).
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## Quick start — ISV mode
|
|
195
|
+
|
|
196
|
+
<details>
|
|
197
|
+
<summary>Click to expand — ISV multi-tenant onboarding flow</summary>
|
|
198
|
+
|
|
199
|
+
ISV mode is for SaaS platforms onboarding many merchants under one ISV partner
|
|
200
|
+
agreement with Viva. You hold platform-wide OAuth2 credentials; each merchant
|
|
201
|
+
goes through Viva's hosted KYC to attach to your platform.
|
|
202
|
+
|
|
203
|
+
### 1. Get ISV credentials
|
|
204
|
+
|
|
205
|
+
Under your ISV partner contract, Viva issues a **single platform-wide** OAuth2
|
|
206
|
+
pair (`clientId` / `clientSecret`). Same pair for demo + production;
|
|
207
|
+
`VIVA_ENVIRONMENT` flips the host.
|
|
208
|
+
|
|
209
|
+
If you'll call `POST /api/sources` to create payment sources for connected
|
|
210
|
+
merchants, you'll also need the Reseller Basic pair (`resellerId`,
|
|
211
|
+
`merchantId`, `resellerApiKey`).
|
|
212
|
+
|
|
213
|
+
### 2. Configure env vars
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
VIVA_MODE=isv
|
|
217
|
+
VIVA_ENVIRONMENT=demo
|
|
218
|
+
VIVA_CLIENT_ID=isv-client-id
|
|
219
|
+
VIVA_CLIENT_SECRET=isv-client-secret
|
|
220
|
+
VIVA_MERCHANT_ID=isv-legacy-merchant-id # for refund Basic auth
|
|
221
|
+
VIVA_API_KEY=isv-legacy-api-key # for refund Basic auth
|
|
222
|
+
VIVA_WEBHOOK_VERIFICATION_KEY= # set after step 4
|
|
223
|
+
VIVA_ADMIN_TOKEN=long-random-string
|
|
224
|
+
VIVA_WEBHOOK_BASE_URL=https://your-saas.com
|
|
225
|
+
|
|
226
|
+
# Optional — only required if you call POST /viva/admin/.../sources
|
|
227
|
+
VIVA_RESELLER_ID=reseller-uuid
|
|
228
|
+
VIVA_RESELLER_MERCHANT_ID=reseller-merchant-uuid
|
|
229
|
+
VIVA_RESELLER_API_KEY=reseller-api-key
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
> All three `VIVA_RESELLER_*` vars are **all-or-nothing**. Setting only some
|
|
233
|
+
> raises a validation error at boot.
|
|
234
|
+
|
|
235
|
+
### 3. Register webhooks (automated — ISV mode)
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
pnpm exec viva-register-webhooks --dry-run # preview
|
|
239
|
+
pnpm exec viva-register-webhooks --apply # call POST /isv/v1/webhooks
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
The CLI posts one webhook URL per V1 event type to Viva. Idempotent — re-running
|
|
243
|
+
emits `SKIP_ALREADY_REGISTERED` actions and exits 0.
|
|
244
|
+
|
|
245
|
+
### 4. Onboard a merchant
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
curl -X POST https://your-saas.com/viva/admin/connected-accounts \
|
|
249
|
+
-H "Authorization: Bearer $VIVA_ADMIN_TOKEN" \
|
|
250
|
+
-H 'Content-Type: application/json' \
|
|
251
|
+
-d '{
|
|
252
|
+
"email": "shop@example.com",
|
|
253
|
+
"returnUrl": "https://your-saas.com/onboarding/done",
|
|
254
|
+
"branding": { "partnerName": "YourSaaS", "logoUrl": "https://..." }
|
|
255
|
+
}'
|
|
256
|
+
# → { "accountId": "...", "onboardingUrl": "https://..." }
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Send `onboardingUrl` to the merchant. They complete Viva's hosted KYC. When
|
|
260
|
+
Viva verifies the account, webhook 8194 fires and the plugin records the
|
|
261
|
+
verification status. Tenant resolution against `merchantId` becomes possible.
|
|
262
|
+
|
|
263
|
+
### 5. Per-cart tenant resolution
|
|
264
|
+
|
|
265
|
+
The plugin's `DefaultTenantResolver` reads `merchantId` from the cart's metadata
|
|
266
|
+
or the store's custom field. Override with a custom resolver in your provider
|
|
267
|
+
options — see `src/resolvers/tenant-resolver.ts`.
|
|
268
|
+
|
|
269
|
+
</details>
|
|
270
|
+
|
|
271
|
+
---
|
|
272
|
+
|
|
273
|
+
## Configuration
|
|
274
|
+
|
|
275
|
+
### Plugin provider options (`medusa-config.ts`)
|
|
276
|
+
|
|
277
|
+
```ts
|
|
278
|
+
{
|
|
279
|
+
resolve: '@sakeetech/medusa-payment-viva',
|
|
280
|
+
id: 'viva',
|
|
281
|
+
options: {
|
|
282
|
+
// All configuration is read from VIVA_* env vars at load time via
|
|
283
|
+
// loadConfigFromEnv(). The options object is reserved for future
|
|
284
|
+
// injection hooks (custom tenant resolver, logger, etc.).
|
|
285
|
+
},
|
|
286
|
+
}
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
The provider calls `loadConfigFromEnv(process.env)` internally and constructs
|
|
290
|
+
the discriminated `VivaPluginConfig` union (`VivaMerchantConfig | VivaIsvConfig`)
|
|
291
|
+
— see `src/config.ts`.
|
|
292
|
+
|
|
293
|
+
### Environment variables — required (both modes)
|
|
294
|
+
|
|
295
|
+
| Variable | Purpose |
|
|
296
|
+
|---|---|
|
|
297
|
+
| `VIVA_CLIENT_ID` | OAuth2 client_id. Merchant mode: your Smart Checkout creds. ISV mode: platform-wide ISV pair. |
|
|
298
|
+
| `VIVA_CLIENT_SECRET` | OAuth2 client_secret. |
|
|
299
|
+
| `VIVA_MERCHANT_ID` | Legacy Basic-auth Merchant ID. Required in **both modes** for refunds — probe-verified 2026-04-25 (F1): `POST /checkout/v2/transactions/{id}` returns 405, only `POST /api/transactions/{id}` on the legacy host works. |
|
|
300
|
+
| `VIVA_API_KEY` | Legacy Basic-auth API Key. Pairs with `VIVA_MERCHANT_ID`. |
|
|
301
|
+
| `VIVA_WEBHOOK_VERIFICATION_KEY` | URL-verify response key. Merchant: from `GET /api/messages/config/token`. ISV: from `GET /isv/v1/webhooks/token`. The CLI fetches this for you. |
|
|
302
|
+
|
|
303
|
+
### Environment variables — optional (both modes)
|
|
304
|
+
|
|
305
|
+
| Variable | Default | Purpose |
|
|
306
|
+
|---|---|---|
|
|
307
|
+
| `VIVA_MODE` | `'merchant'` (auto-detected as `'isv'` if any `VIVA_RESELLER_*` is set) | `'merchant'` or `'isv'`. |
|
|
308
|
+
| `VIVA_ENVIRONMENT` | `'demo'` | `'demo'` or `'production'`. |
|
|
309
|
+
| `VIVA_REFUND_STRATEGY` | `'auto'` | `'auto'` (Fast → Standard fallback) / `'fast'` (Fast only) / `'standard'` (skip Fast). |
|
|
310
|
+
| `VIVA_SOURCE_CODE` | `'Default'` | Smart Checkout source code. Merchant mode only. |
|
|
311
|
+
| `VIVA_ADMIN_TOKEN` | unset | Bearer token gating `/viva/internal/*` and `/viva/admin/*`. When unset in production, internal endpoints return `401 admin-token-not-configured`. |
|
|
312
|
+
| `VIVA_WEBHOOK_BASE_URL` | unset | Your storefront origin. Used by the CLI to construct the webhook URL. Required by the CLI. |
|
|
313
|
+
| `VIVA_WEBHOOK_IP_ALLOWLIST` | unset | Comma-separated extra CIDRs added on top of Viva's documented source IPs. Read directly by the `/viva/webhook` route handler (Medusa file-based routes can't access plugin options). |
|
|
314
|
+
| `VIVA_WEBHOOK_IP_ALLOWLIST_BYPASS` | unset | Set to `'true'` to skip the IP allowlist gate in dev/test only. Never set in production. |
|
|
315
|
+
| `VIVA_TRUSTED_PROXY_DEPTH` | `0` | How many trailing `X-Forwarded-For` hops the deployment terminates. `0` = direct exposure (default, uses socket address). `1` = one reverse proxy (nginx/Caddy/ALB). `2` = CDN + LB (e.g. Cloudflare → ALB). Setting this correctly is **required** to prevent IP-allowlist bypass via header spoofing — see "Webhook firewall configuration" below. |
|
|
316
|
+
|
|
317
|
+
### Environment variables — ISV-only
|
|
318
|
+
|
|
319
|
+
All three are **all-or-nothing**. Required only if you call
|
|
320
|
+
`POST /viva/admin/connected-accounts/:id/sources` (which uses the
|
|
321
|
+
Reseller Basic auth variant — see [`docs/AUTH.md`](https://github.com/sakee-tech/vivawallet-npm-public/blob/main/docs/AUTH.md)).
|
|
322
|
+
|
|
323
|
+
| Variable | Purpose |
|
|
324
|
+
|---|---|
|
|
325
|
+
| `VIVA_RESELLER_ID` | Reseller UUID. |
|
|
326
|
+
| `VIVA_RESELLER_MERCHANT_ID` | Reseller's own merchant UUID (paired with the reseller credential, not the connected merchant). |
|
|
327
|
+
| `VIVA_RESELLER_API_KEY` | Reseller API key. |
|
|
328
|
+
|
|
329
|
+
### Deprecated aliases (one-minor back-compat)
|
|
330
|
+
|
|
331
|
+
Accepted in `0.2.x` with a startup deprecation warning. **Removed in `0.3.0`.**
|
|
332
|
+
|
|
333
|
+
| Deprecated | Use instead |
|
|
334
|
+
|---|---|
|
|
335
|
+
| `VIVA_ISV_CLIENT_ID` | `VIVA_CLIENT_ID` |
|
|
336
|
+
| `VIVA_ISV_CLIENT_SECRET` | `VIVA_CLIENT_SECRET` |
|
|
337
|
+
|
|
338
|
+
> See [`docs/MIGRATION-0.1-to-0.2.md`](https://github.com/sakee-tech/vivawallet-npm-public/blob/main/docs/MIGRATION-0.1-to-0.2.md) for
|
|
339
|
+
> the full upgrade path.
|
|
340
|
+
|
|
341
|
+
---
|
|
342
|
+
|
|
343
|
+
## Admin REST contract
|
|
344
|
+
|
|
345
|
+
All admin endpoints require `Authorization: Bearer $VIVA_ADMIN_TOKEN`.
|
|
346
|
+
|
|
347
|
+
### Mode availability
|
|
348
|
+
|
|
349
|
+
| Path | merchant | isv |
|
|
350
|
+
|---|---|---|
|
|
351
|
+
| `POST /viva/admin/connected-accounts` | 404 | mounted |
|
|
352
|
+
| `GET /viva/admin/connected-accounts/:id` | 404 | mounted |
|
|
353
|
+
| `POST /viva/admin/connected-accounts/:id/reconcile` | 404 | mounted |
|
|
354
|
+
| `POST /viva/admin/connected-accounts/:id/sources` | 404 | mounted (requires `VIVA_RESELLER_*` or returns 412) |
|
|
355
|
+
| `GET /viva/internal/auth-status` | mounted | mounted |
|
|
356
|
+
| `GET /viva/webhook/health` | mounted | mounted |
|
|
357
|
+
| `GET /viva/internal/metrics` | mounted | mounted |
|
|
358
|
+
| `GET /viva/webhook` | mounted | mounted |
|
|
359
|
+
| `POST /viva/webhook` | mounted | mounted |
|
|
360
|
+
|
|
361
|
+
ISV-only routes return `404 not_found` in merchant mode (per-handler short-circuit
|
|
362
|
+
via `_mode-gate.ts` — Medusa v2 has no clean way to skip a `route.ts` file at
|
|
363
|
+
boot based on plugin config).
|
|
364
|
+
|
|
365
|
+
> Full path detail, request / response shapes, and curl examples in
|
|
366
|
+
> [`docs/ENDPOINTS.md`](https://github.com/sakee-tech/vivawallet-npm-public/blob/main/docs/ENDPOINTS.md) §3 (adapter endpoints).
|
|
367
|
+
|
|
368
|
+
### `GET /viva/internal/auth-status`
|
|
369
|
+
|
|
370
|
+
```bash
|
|
371
|
+
curl https://your-store.com/viva/internal/auth-status \
|
|
372
|
+
-H "Authorization: Bearer $VIVA_ADMIN_TOKEN"
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
Response `200`:
|
|
376
|
+
|
|
377
|
+
```json
|
|
378
|
+
{
|
|
379
|
+
"token_present": true,
|
|
380
|
+
"token_expires_at": "2026-05-12T11:00:00.000Z",
|
|
381
|
+
"last_refresh_at": "2026-05-12T10:00:00.000Z"
|
|
382
|
+
}
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
### `GET /viva/webhook/health`
|
|
386
|
+
|
|
387
|
+
```bash
|
|
388
|
+
curl https://your-store.com/viva/webhook/health \
|
|
389
|
+
-H "Authorization: Bearer $VIVA_ADMIN_TOKEN"
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
Response `200`:
|
|
393
|
+
|
|
394
|
+
```json
|
|
395
|
+
{
|
|
396
|
+
"events_received_24h": 42,
|
|
397
|
+
"events_pending": 0,
|
|
398
|
+
"oldest_pending_age_seconds": 0,
|
|
399
|
+
"last_processed_at": "2026-05-12T10:01:23.000Z"
|
|
400
|
+
}
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
### `POST /viva/admin/connected-accounts` *(ISV only)*
|
|
404
|
+
|
|
405
|
+
```bash
|
|
406
|
+
curl -X POST https://your-saas.com/viva/admin/connected-accounts \
|
|
407
|
+
-H "Authorization: Bearer $VIVA_ADMIN_TOKEN" \
|
|
408
|
+
-H 'Content-Type: application/json' \
|
|
409
|
+
-d '{
|
|
410
|
+
"email": "shop@example.com",
|
|
411
|
+
"returnUrl": "https://your-saas.com/onboarding/done"
|
|
412
|
+
}'
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
Response `200`:
|
|
416
|
+
|
|
417
|
+
```json
|
|
418
|
+
{
|
|
419
|
+
"accountId": "eeeeeeee-ffff-0000-1111-222222222222",
|
|
420
|
+
"onboardingUrl": "https://www.vivapayments.com/onboarding/..."
|
|
421
|
+
}
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
### `POST /viva/admin/connected-accounts/:id/reconcile` *(ISV only)*
|
|
425
|
+
|
|
426
|
+
Manually re-fetches a connected account's verification state and writes it
|
|
427
|
+
locally. Use this if webhook 8194 was missed.
|
|
428
|
+
|
|
429
|
+
### `POST /viva/admin/connected-accounts/:id/sources` *(ISV only)*
|
|
430
|
+
|
|
431
|
+
Creates a Smart Checkout source via `POST /api/sources` against the **Reseller
|
|
432
|
+
Basic-auth** legacy host. Returns `412 viva_reseller_credentials_missing` if
|
|
433
|
+
`VIVA_RESELLER_*` are not configured.
|
|
434
|
+
|
|
435
|
+
### Webhook endpoints
|
|
436
|
+
|
|
437
|
+
| Verb | Path | Auth | Description |
|
|
438
|
+
|---|---|---|---|
|
|
439
|
+
| `GET` | `/viva/webhook` | IP allowlist + URL-verify | Returns `{"Key": "<verification-key>"}` during Viva's registration / re-verification probe. |
|
|
440
|
+
| `POST` | `/viva/webhook` | IP allowlist | Receives a Viva event. INSERT-OR-IGNORE on `MessageId`. Enqueues the `process-webhook-event` workflow if newly inserted. Always returns 200. |
|
|
441
|
+
|
|
442
|
+
> Receiver auth model + IP allowlist details in
|
|
443
|
+
> [`docs/SECURITY.md`](https://github.com/sakee-tech/vivawallet-npm-public/blob/main/docs/SECURITY.md) and
|
|
444
|
+
> [`docs/WEBHOOKS.md`](https://github.com/sakee-tech/vivawallet-npm-public/blob/main/docs/WEBHOOKS.md) §2.
|
|
445
|
+
|
|
446
|
+
---
|
|
447
|
+
|
|
448
|
+
## Webhook firewall configuration
|
|
449
|
+
|
|
450
|
+
The in-app IP allowlist is one of **two layers** Viva's docs require:
|
|
451
|
+
|
|
452
|
+
> "whitelist the below IP addresses/ranges in **both your server and in your network firewall**" — Viva docs, `webhooks-for-payments.txt`
|
|
453
|
+
|
|
454
|
+
This package handles the application layer. You are responsible for the
|
|
455
|
+
network layer. Without the network-layer block, an attacker can hit your
|
|
456
|
+
deployment with a spoofed `X-Forwarded-For` and rely on `VIVA_TRUSTED_PROXY_DEPTH`
|
|
457
|
+
being misconfigured to bypass the in-app check.
|
|
458
|
+
|
|
459
|
+
### Viva's published source IPs
|
|
460
|
+
|
|
461
|
+
Production (from `references/viva-docs/md/webhooks-for-payments.txt`):
|
|
462
|
+
|
|
463
|
+
```
|
|
464
|
+
51.138.37.238
|
|
465
|
+
20.61.40.108
|
|
466
|
+
13.80.70.181
|
|
467
|
+
13.80.71.6
|
|
468
|
+
13.79.28.70
|
|
469
|
+
40.74.88.139
|
|
470
|
+
2603:1020:201::/48
|
|
471
|
+
2603:1020:206::/48
|
|
472
|
+
2603:1020:204::/47
|
|
473
|
+
2603:1020:300::/48
|
|
474
|
+
2603:1020:302::/48
|
|
475
|
+
2603:1020:301::/47
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
Demo:
|
|
479
|
+
|
|
480
|
+
```
|
|
481
|
+
40.91.219.176
|
|
482
|
+
20.224.241.71
|
|
483
|
+
2603:1020:600::/48
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
### Setting `VIVA_TRUSTED_PROXY_DEPTH` correctly
|
|
487
|
+
|
|
488
|
+
| Deployment | Value | Why |
|
|
489
|
+
|---|---|---|
|
|
490
|
+
| Direct exposure (Medusa on a public IP, no proxy) | `0` (default) | Use `req.socket.remoteAddress`; ignore `X-Forwarded-For` entirely. |
|
|
491
|
+
| Single reverse proxy (nginx, Caddy, Traefik) | `1` | The proxy appends one entry; trust the rightmost. |
|
|
492
|
+
| ALB / Fly.io / Railway / Render | `1` | Each terminates TLS and appends one `X-Forwarded-For` hop. |
|
|
493
|
+
| Cloudflare → ALB | `2` | CDN appends one, LB appends another. |
|
|
494
|
+
|
|
495
|
+
Setting this too high is unsafe: the helper picks an attacker-controllable
|
|
496
|
+
entry. Setting it too low rejects legitimate webhooks. Verify with a
|
|
497
|
+
manual `curl` against a non-prod deployment.
|
|
498
|
+
|
|
499
|
+
### nginx — strip incoming `X-Forwarded-For`
|
|
500
|
+
|
|
501
|
+
```nginx
|
|
502
|
+
location /viva/webhook {
|
|
503
|
+
# CRITICAL: ignore any X-F-F the client sent.
|
|
504
|
+
proxy_set_header X-Forwarded-For $remote_addr;
|
|
505
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
506
|
+
proxy_pass http://medusa-upstream;
|
|
507
|
+
}
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
### Cloudflare WAF rule
|
|
511
|
+
|
|
512
|
+
Block POSTs to `/viva/webhook` whose source IP is not in Viva's list:
|
|
513
|
+
|
|
514
|
+
```
|
|
515
|
+
(http.request.uri.path eq "/viva/webhook"
|
|
516
|
+
and http.request.method eq "POST"
|
|
517
|
+
and not (ip.src in {51.138.37.238 20.61.40.108 13.80.70.181 13.80.71.6
|
|
518
|
+
13.79.28.70 40.74.88.139}))
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
### AWS ALB — security group
|
|
522
|
+
|
|
523
|
+
Attach a security group to the ALB that allows inbound TCP 443 only from
|
|
524
|
+
the Viva CIDRs above. The in-app allowlist becomes a defence-in-depth
|
|
525
|
+
second layer rather than the sole control.
|
|
526
|
+
|
|
527
|
+
---
|
|
528
|
+
|
|
529
|
+
## Webhook flow
|
|
530
|
+
|
|
531
|
+
```mermaid
|
|
532
|
+
sequenceDiagram
|
|
533
|
+
participant SF as Storefront
|
|
534
|
+
participant M as Medusa API
|
|
535
|
+
participant V as Viva API
|
|
536
|
+
participant WH as POST /viva/webhook
|
|
537
|
+
participant W as Medusa Worker
|
|
538
|
+
participant DB as Postgres (viva_*)
|
|
539
|
+
|
|
540
|
+
SF->>M: initiatePaymentSession({provider:'viva'})
|
|
541
|
+
M->>V: POST /checkout/v2/[isv/]orders
|
|
542
|
+
V-->>M: {orderCode, redirectUrl}
|
|
543
|
+
M-->>SF: PaymentSession{data.redirectUrl}
|
|
544
|
+
SF->>SF: window.location = redirectUrl
|
|
545
|
+
SF->>V: customer completes payment
|
|
546
|
+
V->>WH: POST /viva/webhook (EventTypeId=1796)
|
|
547
|
+
WH->>DB: INSERT viva_webhook_event ON CONFLICT DO NOTHING
|
|
548
|
+
WH->>W: enqueue process-webhook-event {messageId}
|
|
549
|
+
WH-->>V: 200 OK (<100ms)
|
|
550
|
+
W->>V: GET /checkout/v2/transactions/{id}
|
|
551
|
+
V-->>W: {statusId:'F', amount:9999}
|
|
552
|
+
W->>DB: UPDATE viva_transaction SET status='captured'
|
|
553
|
+
W->>M: PaymentSession.status = 'authorized'
|
|
554
|
+
W->>DB: UPDATE viva_webhook_event SET processed_at=now()
|
|
555
|
+
SF->>M: navigate to order-confirmation
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
### Narrative
|
|
559
|
+
|
|
560
|
+
1. **Receive (sync).** `POST /viva/webhook` validates the source IP against
|
|
561
|
+
the allowlist, runs `INSERT INTO viva_webhook_event ... ON CONFLICT
|
|
562
|
+
(message_id) DO NOTHING`, enqueues a worker job if the row was newly
|
|
563
|
+
inserted, and returns 200. Target latency <100ms — no Viva API call in the
|
|
564
|
+
receive path.
|
|
565
|
+
|
|
566
|
+
2. **Process (async).** The `process-webhook-event` workflow:
|
|
567
|
+
- Calls `GET /checkout/v2/transactions/{id}` to retrieve the transaction.
|
|
568
|
+
- Validates `statusId` letter against the local status lattice
|
|
569
|
+
(`mapStatusLetter` → `validateStatusTransition`).
|
|
570
|
+
- Validates `amount === viva_transaction.amount_minor`. Mismatch →
|
|
571
|
+
`VIVA_AMOUNT_MISMATCH`, `processed_at` stays NULL.
|
|
572
|
+
- Updates `viva_transaction.status` and the Medusa `PaymentSession.status`.
|
|
573
|
+
- Marks `viva_webhook_event.processed_at = now()`.
|
|
574
|
+
|
|
575
|
+
3. **Idempotency.** `MessageId` is the only dedup key. Viva's at-least-once
|
|
576
|
+
delivery is handled by the `ON CONFLICT DO NOTHING` insert. The state
|
|
577
|
+
lattice's idempotent self-transition rule means redelivered events on
|
|
578
|
+
already-processed transactions are no-ops.
|
|
579
|
+
|
|
580
|
+
### Webhook auth
|
|
581
|
+
|
|
582
|
+
**No HMAC. No body signing.** Two layers instead:
|
|
583
|
+
|
|
584
|
+
- **IP allowlist** — receiver checks source IP against Viva's published CIDRs.
|
|
585
|
+
- **URL-verify handshake** — on registration and periodic re-verification, Viva
|
|
586
|
+
sends `GET /viva/webhook?key=...`; receiver responds with
|
|
587
|
+
`{"Key": "<VIVA_WEBHOOK_VERIFICATION_KEY>"}`.
|
|
588
|
+
|
|
589
|
+
> Full details in [`docs/SECURITY.md`](https://github.com/sakee-tech/vivawallet-npm-public/blob/main/docs/SECURITY.md) and
|
|
590
|
+
> [`docs/WEBHOOKS.md`](https://github.com/sakee-tech/vivawallet-npm-public/blob/main/docs/WEBHOOKS.md) §2.
|
|
591
|
+
|
|
592
|
+
---
|
|
593
|
+
|
|
594
|
+
## Error contract
|
|
595
|
+
|
|
596
|
+
All adapter errors share one envelope:
|
|
597
|
+
|
|
598
|
+
```ts
|
|
599
|
+
type VivaPluginError = {
|
|
600
|
+
code: VivaErrorCode; // 'VIVA_AUTH_DOWN' | 'VIVA_API_ERROR' | …
|
|
601
|
+
message: string;
|
|
602
|
+
retryable: boolean;
|
|
603
|
+
vivaErrorCode?: number; // Viva's own code, when available
|
|
604
|
+
vivaErrorMessage?: string;
|
|
605
|
+
};
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
REST responses flatten the envelope into the response body. `retryable: true`
|
|
609
|
+
→ HTTP `5xx`; `retryable: false` → HTTP `4xx` (one exception:
|
|
610
|
+
`VIVA_INTERNAL_ERROR` is `500` and non-retryable — it's a bug, not a transient).
|
|
611
|
+
|
|
612
|
+
### Codes you'll commonly encounter
|
|
613
|
+
|
|
614
|
+
| Code | HTTP | Retryable | When |
|
|
615
|
+
|---|---|---|---|
|
|
616
|
+
| `VIVA_AUTH_DOWN` | 503 | yes | OAuth2 token endpoint unavailable. |
|
|
617
|
+
| `VIVA_API_ERROR` | 502 | no | Viva 4xx not covered by a specific code. |
|
|
618
|
+
| `VIVA_ORDER_NOT_FOUND` | 404 | no | Transaction missing on Viva, or payment row missing locally. |
|
|
619
|
+
| `VIVA_AMOUNT_MISMATCH` | 422 | no | Retrieve Transaction returned amount ≠ local `viva_transaction.amount_minor`. Webhook job leaves `processed_at NULL`. |
|
|
620
|
+
| `VIVA_REFUND_REJECTED` | 422 | no | Viva 4xx on refund call. |
|
|
621
|
+
| `VIVA_PAYMENT_ALREADY_SETTLED` | 409 | no | Settle attempted on already-settled payment. |
|
|
622
|
+
| `VIVA_PAYMENT_NOT_CANCELLABLE` | 409 | no | Cancel attempted on non-cancellable state. |
|
|
623
|
+
| `VIVA_MODE_MISMATCH` | 400 | no | ISV-only endpoint called when plugin is in merchant mode. |
|
|
624
|
+
| `VIVA_RESELLER_CREDENTIALS_MISSING` | 412 | no | `.../sources` called without `VIVA_RESELLER_*`. |
|
|
625
|
+
| `VIVA_FAST_REFUND_INELIGIBLE` | 403 | no | (`refundStrategy='fast'` only) Card scheme isn't Visa/MC, or merchant not approved. With `'auto'` this is caught internally and never surfaces. |
|
|
626
|
+
| `VIVA_ACCOUNT_NOT_VERIFIED` | 400 | no | (ISV) `createPayment` on a tenant whose `vivaPayoutsEnabled` is false. |
|
|
627
|
+
| `VIVA_CHANNEL_MISCONFIGURED` | 400 | no | (ISV) `resolveMerchantId(ctx)` returned `undefined`. |
|
|
628
|
+
| `VIVA_INTERNAL_ERROR` | 500 | no | Catch-all bug indicator. |
|
|
629
|
+
|
|
630
|
+
> Full catalogue + Viva→plugin mapping rules in [`docs/ERRORS.md`](https://github.com/sakee-tech/vivawallet-npm-public/blob/main/docs/ERRORS.md).
|
|
631
|
+
|
|
632
|
+
---
|
|
633
|
+
|
|
634
|
+
## CLI — `viva-register-webhooks`
|
|
635
|
+
|
|
636
|
+
Installed as a `bin` entry. Idempotent — re-running after `--apply` is safe.
|
|
637
|
+
|
|
638
|
+
```bash
|
|
639
|
+
pnpm exec viva-register-webhooks <flags>
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
### Flag matrix
|
|
643
|
+
|
|
644
|
+
| Flag | merchant | isv |
|
|
645
|
+
|---|---|---|
|
|
646
|
+
| `--dry-run` | Prints the URLs + a `<dry-run>` placeholder for the key. No network call. | Prints the action plan (REGISTER / SKIP). No mutation. |
|
|
647
|
+
| `--apply` | Fetches verification key via `GET /api/messages/config/token` (Basic auth). Prints manual setup instructions. | Calls `POST /isv/v1/webhooks` per V1 event type. |
|
|
648
|
+
| `--reconcile-drift` | **Not supported.** Prints a notice and exits 0. (Self Care UI is the only management surface for merchants.) | Currently a no-op with a warning — the ISV API exposes no list/deactivate endpoints (probe-verified 2026-05-11). |
|
|
649
|
+
| `--webhook-base-url <url>` | Override the storefront origin. Also reads `VIVA_WEBHOOK_BASE_URL`. | Same. |
|
|
650
|
+
| `--output human\|json` | Human-readable or JSON output. | Same. |
|
|
651
|
+
|
|
652
|
+
### Exit codes
|
|
653
|
+
|
|
654
|
+
| Code | Meaning |
|
|
655
|
+
|---|---|
|
|
656
|
+
| `0` | Success (manual setup printed / all applied / nothing to do). |
|
|
657
|
+
| `1` | Plan has actions but `--apply` was not passed (ISV mode CI gate). |
|
|
658
|
+
| `2` | Apply failed — HTTP error from Viva. |
|
|
659
|
+
| `3` | Fatal precondition (config invalid, missing `VIVA_WEBHOOK_BASE_URL`, `ABORT_LIMIT_HIT`). |
|
|
660
|
+
|
|
661
|
+
---
|
|
662
|
+
|
|
663
|
+
## State mapping
|
|
664
|
+
|
|
665
|
+
Three coupled state machines: **Viva `StatusId`** → **plugin `viva_transaction.status`**
|
|
666
|
+
→ **Medusa `PaymentSessionStatus`**. Pure mapping function in
|
|
667
|
+
`viva-payments-core/webhooks/status-lattice.ts`.
|
|
668
|
+
|
|
669
|
+
| Viva letter | Plugin status | Medusa `PaymentSessionStatus` |
|
|
670
|
+
|---|---|---|
|
|
671
|
+
| `F`, `C` | `captured` | `authorized` *(Medusa has no `captured` state — `viva_transaction.status` is the plugin-internal truth)* |
|
|
672
|
+
| `A` | `authorized` | `authorized` |
|
|
673
|
+
| `R` | `refunded` | `refunded` |
|
|
674
|
+
| `E` | `failed` | `error` |
|
|
675
|
+
| `X` | `cancelled` | `canceled` |
|
|
676
|
+
| `M`, `MA`, `MI`, `ML`, `MS`, `MW` | `disputed` | `requires_more` *(claim substate stored in `payment.metadata`)* |
|
|
677
|
+
|
|
678
|
+
The plugin status lattice enforces **monotonic forward** transitions; redelivered
|
|
679
|
+
events on terminal states are no-ops. Backward transitions are logged WARN;
|
|
680
|
+
illegal ones are logged ERROR and leave `viva_webhook_event.processed_at NULL`.
|
|
681
|
+
|
|
682
|
+
> Full state machine, transition rules, and stale-order re-walk in
|
|
683
|
+
> [`docs/STATE-MACHINE.md`](https://github.com/sakee-tech/vivawallet-npm-public/blob/main/docs/STATE-MACHINE.md).
|
|
684
|
+
|
|
685
|
+
---
|
|
686
|
+
|
|
687
|
+
## Refund strategy
|
|
688
|
+
|
|
689
|
+
Refunds default to `refundStrategy: 'auto'` — try Fast Refund first, fall back
|
|
690
|
+
to Standard refund on `403 not eligible`.
|
|
691
|
+
|
|
692
|
+
| Strategy | Behaviour | When to use |
|
|
693
|
+
|---|---|---|
|
|
694
|
+
| `'auto'` *(default)* | Fast Refund first (Visa/MC e-commerce only). Falls back to Standard refund on `403 VIVA_FAST_REFUND_INELIGIBLE`. The fallback is transparent — caller never sees the 403. | Most users. Transparent upgrade. |
|
|
695
|
+
| `'fast'` | Forces Fast Refund. Surfaces `VIVA_FAST_REFUND_INELIGIBLE` to the caller on ineligibility. | Merchants approved for Fast Refund who want explicit failures rather than silent fallback. |
|
|
696
|
+
| `'standard'` | Skips Fast Refund entirely. Always uses `POST /api/transactions/{id}` (legacy Basic auth). | Merchants not approved for Fast Refund by Viva sales, or those who want one consistent refund path. |
|
|
697
|
+
|
|
698
|
+
Eligibility (auto + fast): Viva approves Fast Refund per-merchant; card must
|
|
699
|
+
be Visa or Mastercard; transaction must be card-not-present (e-commerce).
|
|
700
|
+
|
|
701
|
+
> Path detail in [`docs/ENDPOINTS.md`](https://github.com/sakee-tech/vivawallet-npm-public/blob/main/docs/ENDPOINTS.md) §5
|
|
702
|
+
> (Fast Refund + Standard refund).
|
|
703
|
+
|
|
704
|
+
---
|
|
705
|
+
|
|
706
|
+
## Operator runbook
|
|
707
|
+
|
|
708
|
+
### Onboarding a new merchant (ISV mode)
|
|
709
|
+
|
|
710
|
+
1. `POST /viva/admin/connected-accounts` with `{email, returnUrl}`. Send the
|
|
711
|
+
returned `onboardingUrl` to the merchant.
|
|
712
|
+
2. Merchant completes Viva's hosted KYC.
|
|
713
|
+
3. Wait for webhook 8194 (can take minutes to hours after KYC submission).
|
|
714
|
+
4. Verify: `GET /viva/admin/connected-accounts/:id` shows
|
|
715
|
+
`"payoutsEnabled": true` and a non-null `merchantId`.
|
|
716
|
+
5. If 8194 was missed: call `POST /viva/admin/connected-accounts/:id/reconcile`
|
|
717
|
+
to re-fetch the account state from Viva.
|
|
718
|
+
|
|
719
|
+
### Payment stuck in `pending`
|
|
720
|
+
|
|
721
|
+
**Symptom:** Customer completed Smart Checkout but Medusa's `PaymentSession.status`
|
|
722
|
+
is still `pending`.
|
|
723
|
+
|
|
724
|
+
**Cause:** Webhook 1796 was not received or not processed.
|
|
725
|
+
|
|
726
|
+
1. Check `GET /viva/webhook/health` — look at `events_pending` and
|
|
727
|
+
`oldest_pending_age_seconds`.
|
|
728
|
+
2. Query `SELECT * FROM viva_webhook_event WHERE processed_at IS NULL` — find
|
|
729
|
+
the stuck row.
|
|
730
|
+
3. Check the `error` column for the failure reason (`retrieve-failed`,
|
|
731
|
+
`tenant-not-resolved`, `VIVA_AMOUNT_MISMATCH`, etc.).
|
|
732
|
+
4. If no row exists: the webhook was not received. Check Viva dashboard for
|
|
733
|
+
delivery errors and verify IP allowlist + URL-verify handshake.
|
|
734
|
+
|
|
735
|
+
### Webhook drift (ISV mode)
|
|
736
|
+
|
|
737
|
+
The ISV API does not expose a list/deactivate endpoint. If you suspect drift:
|
|
738
|
+
|
|
739
|
+
1. Run `pnpm exec viva-register-webhooks --apply` — re-registering is idempotent
|
|
740
|
+
(Viva's `POST /isv/v1/webhooks` returns the existing registration).
|
|
741
|
+
2. To remove unwanted registrations, use the Viva Self Care UI.
|
|
742
|
+
|
|
743
|
+
### Refund stuck
|
|
744
|
+
|
|
745
|
+
**Symptom:** Refund call returned 200 but `viva_transaction.status` never moves
|
|
746
|
+
to `refunded`.
|
|
747
|
+
|
|
748
|
+
**Cause:** Refund status changes are confirmed via webhook 1797 (Reversal
|
|
749
|
+
Created). If 1797 isn't received, the transaction stays `captured` locally.
|
|
750
|
+
|
|
751
|
+
1. Check `viva_webhook_event` for a row with `EventTypeId=1797` and
|
|
752
|
+
matching transaction.
|
|
753
|
+
2. If absent, contact Viva support with the `CorrelationId` from the refund
|
|
754
|
+
response.
|
|
755
|
+
|
|
756
|
+
### Reading metrics
|
|
757
|
+
|
|
758
|
+
```bash
|
|
759
|
+
curl https://your-store.com/viva/internal/metrics \
|
|
760
|
+
-H "Authorization: Bearer $VIVA_ADMIN_TOKEN"
|
|
761
|
+
```
|
|
762
|
+
|
|
763
|
+
Hand-rolled Prometheus exposition format (no `prom-client` dep). Alert on:
|
|
764
|
+
|
|
765
|
+
- `viva_webhook_events_pending_total > 10` — webhook processing falling behind.
|
|
766
|
+
- `viva_auth_refresh_errors_total > 0` — OAuth2 token renewal failing.
|
|
767
|
+
- `viva_amount_mismatch_total > 0` — payment amounts diverging.
|
|
768
|
+
|
|
769
|
+
---
|
|
770
|
+
|
|
771
|
+
## Limitations / out of scope
|
|
772
|
+
|
|
773
|
+
| Limitation | Notes |
|
|
774
|
+
|---|---|
|
|
775
|
+
| **Apple Pay domain registration** | Not exposed by Viva to any integrator (merchant or ISV). Manual step via Self Care. |
|
|
776
|
+
| **Marketplace mode** | Reserved seams in the codebase; no public API in `0.2.x`. |
|
|
777
|
+
| **Pre-auth-only flow** | Immediate-capture via Smart Checkout only — both modes. |
|
|
778
|
+
| **Subscriptions / recurring** | Not supported. |
|
|
779
|
+
| **Admin UI extension** | Operator owns the admin UI. Plugin ships the REST contract; the UI integrates it. |
|
|
780
|
+
| **4865 cancel-status letter** | Viva docs don't fully document 4865 `StatusId` for user-cancel. Plugin defensively treats `{X, C, E}` as cancel signals — will tighten after first live observation. |
|
|
781
|
+
| **Idempotency-Key server-side dedupe** | `Idempotency-Key` is sent on all requests but not deduped by Viva (probe F2, 2026-04-25). Local `viva_transaction` row is authoritative for dedup. |
|
|
782
|
+
| **Refund via legacy host** | Probe F1: `POST /checkout/v2/transactions/{id}` on v2/OAuth2 returns 405. Plugin uses `POST /api/transactions/{id}` on the legacy host with Basic auth. |
|
|
783
|
+
|
|
784
|
+
---
|
|
785
|
+
|
|
786
|
+
## Versioning and roadmap
|
|
787
|
+
|
|
788
|
+
See [CHANGELOG.md](./CHANGELOG.md).
|
|
789
|
+
|
|
790
|
+
Open items after `0.2.0`:
|
|
791
|
+
|
|
792
|
+
- First live demo verification (shared with Vendure adapter — resolves
|
|
793
|
+
idempotency-header semantics, refund unit ambiguity, webhook amount unit).
|
|
794
|
+
- Marketplace mode (P6 — no timeline; seams reserved).
|
|
795
|
+
- Pre-auth-only flow (no timeline).
|
|
796
|
+
|
|
797
|
+
---
|
|
798
|
+
|
|
799
|
+
## License
|
|
800
|
+
|
|
801
|
+
MIT. See [LICENSE](./LICENSE).
|
|
802
|
+
|
|
803
|
+
---
|
|
804
|
+
|
|
805
|
+
## Contributing
|
|
806
|
+
|
|
807
|
+
Internal SaaS use. Contributions welcome once `0.2.0` stabilises after the
|
|
808
|
+
first live demo. Open issues or discussions in the project repository.
|
|
809
|
+
|
|
810
|
+
Local development:
|
|
811
|
+
|
|
812
|
+
```bash
|
|
813
|
+
pnpm -F @sakeetech/medusa-payment-viva typecheck
|
|
814
|
+
pnpm -F @sakeetech/medusa-payment-viva test
|
|
815
|
+
pnpm -F @sakeetech/medusa-payment-viva build
|
|
816
|
+
```
|