@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.
Files changed (171) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +816 -0
  3. package/dist/api/index.d.ts +15 -0
  4. package/dist/api/index.d.ts.map +1 -0
  5. package/dist/api/index.js +22 -0
  6. package/dist/api/index.js.map +1 -0
  7. package/dist/api/middlewares.d.ts +27 -0
  8. package/dist/api/middlewares.d.ts.map +1 -0
  9. package/dist/api/middlewares.js +62 -0
  10. package/dist/api/middlewares.js.map +1 -0
  11. package/dist/api/viva/admin/_admin-auth.d.ts +26 -0
  12. package/dist/api/viva/admin/_admin-auth.d.ts.map +1 -0
  13. package/dist/api/viva/admin/_admin-auth.js +49 -0
  14. package/dist/api/viva/admin/_admin-auth.js.map +1 -0
  15. package/dist/api/viva/admin/_mode-gate.d.ts +28 -0
  16. package/dist/api/viva/admin/_mode-gate.d.ts.map +1 -0
  17. package/dist/api/viva/admin/_mode-gate.js +45 -0
  18. package/dist/api/viva/admin/_mode-gate.js.map +1 -0
  19. package/dist/api/viva/admin/connected-accounts/[id]/reconcile/route.d.ts +21 -0
  20. package/dist/api/viva/admin/connected-accounts/[id]/reconcile/route.d.ts.map +1 -0
  21. package/dist/api/viva/admin/connected-accounts/[id]/reconcile/route.js +93 -0
  22. package/dist/api/viva/admin/connected-accounts/[id]/reconcile/route.js.map +1 -0
  23. package/dist/api/viva/admin/connected-accounts/[id]/route.d.ts +18 -0
  24. package/dist/api/viva/admin/connected-accounts/[id]/route.d.ts.map +1 -0
  25. package/dist/api/viva/admin/connected-accounts/[id]/route.js +59 -0
  26. package/dist/api/viva/admin/connected-accounts/[id]/route.js.map +1 -0
  27. package/dist/api/viva/admin/connected-accounts/[id]/sources/route.d.ts +34 -0
  28. package/dist/api/viva/admin/connected-accounts/[id]/sources/route.d.ts.map +1 -0
  29. package/dist/api/viva/admin/connected-accounts/[id]/sources/route.js +234 -0
  30. package/dist/api/viva/admin/connected-accounts/[id]/sources/route.js.map +1 -0
  31. package/dist/api/viva/admin/connected-accounts/route.d.ts +19 -0
  32. package/dist/api/viva/admin/connected-accounts/route.d.ts.map +1 -0
  33. package/dist/api/viva/admin/connected-accounts/route.js +78 -0
  34. package/dist/api/viva/admin/connected-accounts/route.js.map +1 -0
  35. package/dist/api/viva/internal/auth-status/route.d.ts +19 -0
  36. package/dist/api/viva/internal/auth-status/route.d.ts.map +1 -0
  37. package/dist/api/viva/internal/auth-status/route.js +91 -0
  38. package/dist/api/viva/internal/auth-status/route.js.map +1 -0
  39. package/dist/api/viva/internal/metrics/route.d.ts +13 -0
  40. package/dist/api/viva/internal/metrics/route.d.ts.map +1 -0
  41. package/dist/api/viva/internal/metrics/route.js +48 -0
  42. package/dist/api/viva/internal/metrics/route.js.map +1 -0
  43. package/dist/api/viva/webhook/health/route.d.ts +16 -0
  44. package/dist/api/viva/webhook/health/route.d.ts.map +1 -0
  45. package/dist/api/viva/webhook/health/route.js +27 -0
  46. package/dist/api/viva/webhook/health/route.js.map +1 -0
  47. package/dist/api/viva/webhook/route.d.ts +57 -0
  48. package/dist/api/viva/webhook/route.d.ts.map +1 -0
  49. package/dist/api/viva/webhook/route.js +269 -0
  50. package/dist/api/viva/webhook/route.js.map +1 -0
  51. package/dist/cli/bin.d.ts +12 -0
  52. package/dist/cli/bin.d.ts.map +1 -0
  53. package/dist/cli/bin.js +78 -0
  54. package/dist/cli/bin.js.map +1 -0
  55. package/dist/cli/index.d.ts +12 -0
  56. package/dist/cli/index.d.ts.map +1 -0
  57. package/dist/cli/index.js +14 -0
  58. package/dist/cli/index.js.map +1 -0
  59. package/dist/cli/plan.d.ts +51 -0
  60. package/dist/cli/plan.d.ts.map +1 -0
  61. package/dist/cli/plan.js +128 -0
  62. package/dist/cli/plan.js.map +1 -0
  63. package/dist/cli/register-webhooks.d.ts +54 -0
  64. package/dist/cli/register-webhooks.d.ts.map +1 -0
  65. package/dist/cli/register-webhooks.js +366 -0
  66. package/dist/cli/register-webhooks.js.map +1 -0
  67. package/dist/cli/types.d.ts +62 -0
  68. package/dist/cli/types.d.ts.map +1 -0
  69. package/dist/cli/types.js +12 -0
  70. package/dist/cli/types.js.map +1 -0
  71. package/dist/config.d.ts +158 -0
  72. package/dist/config.d.ts.map +1 -0
  73. package/dist/config.js +236 -0
  74. package/dist/config.js.map +1 -0
  75. package/dist/index.d.ts +21 -0
  76. package/dist/index.d.ts.map +1 -0
  77. package/dist/index.js +29 -0
  78. package/dist/index.js.map +1 -0
  79. package/dist/loaders/viva-oauth2-strategy.d.ts +26 -0
  80. package/dist/loaders/viva-oauth2-strategy.d.ts.map +1 -0
  81. package/dist/loaders/viva-oauth2-strategy.js +58 -0
  82. package/dist/loaders/viva-oauth2-strategy.js.map +1 -0
  83. package/dist/migrations/Migration_20260425000001_init_viva_payments.d.ts +19 -0
  84. package/dist/migrations/Migration_20260425000001_init_viva_payments.d.ts.map +1 -0
  85. package/dist/migrations/Migration_20260425000001_init_viva_payments.js +136 -0
  86. package/dist/migrations/Migration_20260425000001_init_viva_payments.js.map +1 -0
  87. package/dist/migrations/Migration_20260425000002_allow_null_order_code.d.ts +31 -0
  88. package/dist/migrations/Migration_20260425000002_allow_null_order_code.d.ts.map +1 -0
  89. package/dist/migrations/Migration_20260425000002_allow_null_order_code.js +71 -0
  90. package/dist/migrations/Migration_20260425000002_allow_null_order_code.js.map +1 -0
  91. package/dist/migrations/Migration_20260425000003_webhook_retry_count.d.ts +18 -0
  92. package/dist/migrations/Migration_20260425000003_webhook_retry_count.d.ts.map +1 -0
  93. package/dist/migrations/Migration_20260425000003_webhook_retry_count.js +42 -0
  94. package/dist/migrations/Migration_20260425000003_webhook_retry_count.js.map +1 -0
  95. package/dist/migrations/Migration_20260425000004_webhook_error_and_nullable_merchant.d.ts +29 -0
  96. package/dist/migrations/Migration_20260425000004_webhook_error_and_nullable_merchant.d.ts.map +1 -0
  97. package/dist/migrations/Migration_20260425000004_webhook_error_and_nullable_merchant.js +74 -0
  98. package/dist/migrations/Migration_20260425000004_webhook_error_and_nullable_merchant.js.map +1 -0
  99. package/dist/models/index.d.ts +7 -0
  100. package/dist/models/index.d.ts.map +1 -0
  101. package/dist/models/index.js +10 -0
  102. package/dist/models/index.js.map +1 -0
  103. package/dist/models/viva-tenant-merchant.d.ts +11 -0
  104. package/dist/models/viva-tenant-merchant.d.ts.map +1 -0
  105. package/dist/models/viva-tenant-merchant.js +54 -0
  106. package/dist/models/viva-tenant-merchant.js.map +1 -0
  107. package/dist/models/viva-transaction.d.ts +34 -0
  108. package/dist/models/viva-transaction.d.ts.map +1 -0
  109. package/dist/models/viva-transaction.js +104 -0
  110. package/dist/models/viva-transaction.js.map +1 -0
  111. package/dist/models/viva-webhook-event.d.ts +32 -0
  112. package/dist/models/viva-webhook-event.d.ts.map +1 -0
  113. package/dist/models/viva-webhook-event.js +88 -0
  114. package/dist/models/viva-webhook-event.js.map +1 -0
  115. package/dist/observability/config.d.ts +34 -0
  116. package/dist/observability/config.d.ts.map +1 -0
  117. package/dist/observability/config.js +57 -0
  118. package/dist/observability/config.js.map +1 -0
  119. package/dist/observability/index.d.ts +8 -0
  120. package/dist/observability/index.d.ts.map +1 -0
  121. package/dist/observability/index.js +15 -0
  122. package/dist/observability/index.js.map +1 -0
  123. package/dist/observability/prom-metrics.d.ts +41 -0
  124. package/dist/observability/prom-metrics.d.ts.map +1 -0
  125. package/dist/observability/prom-metrics.js +219 -0
  126. package/dist/observability/prom-metrics.js.map +1 -0
  127. package/dist/providers/payment-provider.d.ts +19 -0
  128. package/dist/providers/payment-provider.d.ts.map +1 -0
  129. package/dist/providers/payment-provider.js +24 -0
  130. package/dist/providers/payment-provider.js.map +1 -0
  131. package/dist/resolvers/auth-strategy-factory.d.ts +42 -0
  132. package/dist/resolvers/auth-strategy-factory.d.ts.map +1 -0
  133. package/dist/resolvers/auth-strategy-factory.js +60 -0
  134. package/dist/resolvers/auth-strategy-factory.js.map +1 -0
  135. package/dist/resolvers/tenant-resolver.d.ts +104 -0
  136. package/dist/resolvers/tenant-resolver.d.ts.map +1 -0
  137. package/dist/resolvers/tenant-resolver.js +118 -0
  138. package/dist/resolvers/tenant-resolver.js.map +1 -0
  139. package/dist/service.d.ts +200 -0
  140. package/dist/service.d.ts.map +1 -0
  141. package/dist/service.js +1003 -0
  142. package/dist/service.js.map +1 -0
  143. package/dist/subscribers/index.d.ts +5 -0
  144. package/dist/subscribers/index.d.ts.map +1 -0
  145. package/dist/subscribers/index.js +10 -0
  146. package/dist/subscribers/index.js.map +1 -0
  147. package/dist/subscribers/viva-webhook-event.d.ts +38 -0
  148. package/dist/subscribers/viva-webhook-event.d.ts.map +1 -0
  149. package/dist/subscribers/viva-webhook-event.js +133 -0
  150. package/dist/subscribers/viva-webhook-event.js.map +1 -0
  151. package/dist/workflows/cleanup-old-webhook-events.d.ts +39 -0
  152. package/dist/workflows/cleanup-old-webhook-events.d.ts.map +1 -0
  153. package/dist/workflows/cleanup-old-webhook-events.js +68 -0
  154. package/dist/workflows/cleanup-old-webhook-events.js.map +1 -0
  155. package/dist/workflows/index.d.ts +14 -0
  156. package/dist/workflows/index.d.ts.map +1 -0
  157. package/dist/workflows/index.js +19 -0
  158. package/dist/workflows/index.js.map +1 -0
  159. package/dist/workflows/per-tenant-semaphore.d.ts +47 -0
  160. package/dist/workflows/per-tenant-semaphore.d.ts.map +1 -0
  161. package/dist/workflows/per-tenant-semaphore.js +89 -0
  162. package/dist/workflows/per-tenant-semaphore.js.map +1 -0
  163. package/dist/workflows/process-webhook-event.d.ts +80 -0
  164. package/dist/workflows/process-webhook-event.d.ts.map +1 -0
  165. package/dist/workflows/process-webhook-event.js +280 -0
  166. package/dist/workflows/process-webhook-event.js.map +1 -0
  167. package/dist/workflows/reprocess-unresolved-tenants.d.ts +58 -0
  168. package/dist/workflows/reprocess-unresolved-tenants.d.ts.map +1 -0
  169. package/dist/workflows/reprocess-unresolved-tenants.js +121 -0
  170. package/dist/workflows/reprocess-unresolved-tenants.js.map +1 -0
  171. 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
+ ```