@sakeetech/vendure-payment-viva 0.2.1

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 (135) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +952 -0
  3. package/dist/api/admin-internal.controller.d.ts +59 -0
  4. package/dist/api/admin-internal.controller.d.ts.map +1 -0
  5. package/dist/api/admin-internal.controller.js +229 -0
  6. package/dist/api/admin-internal.controller.js.map +1 -0
  7. package/dist/api/admin-onboarding.controller.d.ts +72 -0
  8. package/dist/api/admin-onboarding.controller.d.ts.map +1 -0
  9. package/dist/api/admin-onboarding.controller.js +496 -0
  10. package/dist/api/admin-onboarding.controller.js.map +1 -0
  11. package/dist/api/admin-sources.controller.d.ts +50 -0
  12. package/dist/api/admin-sources.controller.d.ts.map +1 -0
  13. package/dist/api/admin-sources.controller.js +283 -0
  14. package/dist/api/admin-sources.controller.js.map +1 -0
  15. package/dist/api/shop-api.extension.d.ts +15 -0
  16. package/dist/api/shop-api.extension.d.ts.map +1 -0
  17. package/dist/api/shop-api.extension.js +35 -0
  18. package/dist/api/shop-api.extension.js.map +1 -0
  19. package/dist/api/shop-api.resolver.d.ts +42 -0
  20. package/dist/api/shop-api.resolver.d.ts.map +1 -0
  21. package/dist/api/shop-api.resolver.js +256 -0
  22. package/dist/api/shop-api.resolver.js.map +1 -0
  23. package/dist/api/webhook.controller.d.ts +58 -0
  24. package/dist/api/webhook.controller.d.ts.map +1 -0
  25. package/dist/api/webhook.controller.js +204 -0
  26. package/dist/api/webhook.controller.js.map +1 -0
  27. package/dist/cli/bin.d.ts +28 -0
  28. package/dist/cli/bin.d.ts.map +1 -0
  29. package/dist/cli/bin.js +104 -0
  30. package/dist/cli/bin.js.map +1 -0
  31. package/dist/cli/plan.d.ts +41 -0
  32. package/dist/cli/plan.d.ts.map +1 -0
  33. package/dist/cli/plan.js +115 -0
  34. package/dist/cli/plan.js.map +1 -0
  35. package/dist/cli/register-webhooks.d.ts +45 -0
  36. package/dist/cli/register-webhooks.d.ts.map +1 -0
  37. package/dist/cli/register-webhooks.js +400 -0
  38. package/dist/cli/register-webhooks.js.map +1 -0
  39. package/dist/cli/types.d.ts +75 -0
  40. package/dist/cli/types.d.ts.map +1 -0
  41. package/dist/cli/types.js +10 -0
  42. package/dist/cli/types.js.map +1 -0
  43. package/dist/constants.d.ts +35 -0
  44. package/dist/constants.d.ts.map +1 -0
  45. package/dist/constants.js +40 -0
  46. package/dist/constants.js.map +1 -0
  47. package/dist/entities/index.d.ts +4 -0
  48. package/dist/entities/index.d.ts.map +1 -0
  49. package/dist/entities/index.js +3 -0
  50. package/dist/entities/index.js.map +1 -0
  51. package/dist/entities/viva-transaction.entity.d.ts +70 -0
  52. package/dist/entities/viva-transaction.entity.d.ts.map +1 -0
  53. package/dist/entities/viva-transaction.entity.js +133 -0
  54. package/dist/entities/viva-transaction.entity.js.map +1 -0
  55. package/dist/entities/viva-webhook-event.entity.d.ts +71 -0
  56. package/dist/entities/viva-webhook-event.entity.d.ts.map +1 -0
  57. package/dist/entities/viva-webhook-event.entity.js +138 -0
  58. package/dist/entities/viva-webhook-event.entity.js.map +1 -0
  59. package/dist/index.d.ts +27 -0
  60. package/dist/index.d.ts.map +1 -0
  61. package/dist/index.js +23 -0
  62. package/dist/index.js.map +1 -0
  63. package/dist/jobs/process-viva-webhook.handler.d.ts +95 -0
  64. package/dist/jobs/process-viva-webhook.handler.d.ts.map +1 -0
  65. package/dist/jobs/process-viva-webhook.handler.js +530 -0
  66. package/dist/jobs/process-viva-webhook.handler.js.map +1 -0
  67. package/dist/jobs/queue-names.d.ts +18 -0
  68. package/dist/jobs/queue-names.d.ts.map +1 -0
  69. package/dist/jobs/queue-names.js +19 -0
  70. package/dist/jobs/queue-names.js.map +1 -0
  71. package/dist/jobs/retention-cleanup.handler.d.ts +31 -0
  72. package/dist/jobs/retention-cleanup.handler.d.ts.map +1 -0
  73. package/dist/jobs/retention-cleanup.handler.js +94 -0
  74. package/dist/jobs/retention-cleanup.handler.js.map +1 -0
  75. package/dist/loaders/bootstrap.d.ts +28 -0
  76. package/dist/loaders/bootstrap.d.ts.map +1 -0
  77. package/dist/loaders/bootstrap.js +90 -0
  78. package/dist/loaders/bootstrap.js.map +1 -0
  79. package/dist/migrations/1714000000000-create-viva-tables.d.ts +22 -0
  80. package/dist/migrations/1714000000000-create-viva-tables.d.ts.map +1 -0
  81. package/dist/migrations/1714000000000-create-viva-tables.js +105 -0
  82. package/dist/migrations/1714000000000-create-viva-tables.js.map +1 -0
  83. package/dist/observability/metrics-state.service.d.ts +43 -0
  84. package/dist/observability/metrics-state.service.d.ts.map +1 -0
  85. package/dist/observability/metrics-state.service.js +207 -0
  86. package/dist/observability/metrics-state.service.js.map +1 -0
  87. package/dist/payment-method-handler.d.ts +26 -0
  88. package/dist/payment-method-handler.d.ts.map +1 -0
  89. package/dist/payment-method-handler.js +693 -0
  90. package/dist/payment-method-handler.js.map +1 -0
  91. package/dist/plugin.d.ts +95 -0
  92. package/dist/plugin.d.ts.map +1 -0
  93. package/dist/plugin.js +241 -0
  94. package/dist/plugin.js.map +1 -0
  95. package/dist/providers/viva-oauth2-strategy.provider.d.ts +41 -0
  96. package/dist/providers/viva-oauth2-strategy.provider.d.ts.map +1 -0
  97. package/dist/providers/viva-oauth2-strategy.provider.js +60 -0
  98. package/dist/providers/viva-oauth2-strategy.provider.js.map +1 -0
  99. package/dist/services/connected-accounts.service.d.ts +53 -0
  100. package/dist/services/connected-accounts.service.d.ts.map +1 -0
  101. package/dist/services/connected-accounts.service.js +108 -0
  102. package/dist/services/connected-accounts.service.js.map +1 -0
  103. package/dist/services/per-merchant-semaphore.service.d.ts +49 -0
  104. package/dist/services/per-merchant-semaphore.service.d.ts.map +1 -0
  105. package/dist/services/per-merchant-semaphore.service.js +156 -0
  106. package/dist/services/per-merchant-semaphore.service.js.map +1 -0
  107. package/dist/services/state-machine.service.d.ts +100 -0
  108. package/dist/services/state-machine.service.d.ts.map +1 -0
  109. package/dist/services/state-machine.service.js +233 -0
  110. package/dist/services/state-machine.service.js.map +1 -0
  111. package/dist/types.d.ts +286 -0
  112. package/dist/types.d.ts.map +1 -0
  113. package/dist/types.js +23 -0
  114. package/dist/types.js.map +1 -0
  115. package/dist/util/currency.d.ts +32 -0
  116. package/dist/util/currency.d.ts.map +1 -0
  117. package/dist/util/currency.js +90 -0
  118. package/dist/util/currency.js.map +1 -0
  119. package/dist/util/error-envelope.d.ts +51 -0
  120. package/dist/util/error-envelope.d.ts.map +1 -0
  121. package/dist/util/error-envelope.js +157 -0
  122. package/dist/util/error-envelope.js.map +1 -0
  123. package/dist/util/ip-allowlist.d.ts +44 -0
  124. package/dist/util/ip-allowlist.d.ts.map +1 -0
  125. package/dist/util/ip-allowlist.js +139 -0
  126. package/dist/util/ip-allowlist.js.map +1 -0
  127. package/dist/util/normalize-options.d.ts +24 -0
  128. package/dist/util/normalize-options.d.ts.map +1 -0
  129. package/dist/util/normalize-options.js +189 -0
  130. package/dist/util/normalize-options.js.map +1 -0
  131. package/dist/util/url-template.d.ts +18 -0
  132. package/dist/util/url-template.d.ts.map +1 -0
  133. package/dist/util/url-template.js +22 -0
  134. package/dist/util/url-template.js.map +1 -0
  135. package/package.json +75 -0
package/README.md ADDED
@@ -0,0 +1,952 @@
1
+ # @sakeetech/vendure-payment-viva
2
+
3
+ Vendure 3.x plugin for **Viva Wallet** payments — multi-mode (merchant + ISV).
4
+ Wraps [`@sakeetech/viva-payments-core`](../viva-payments-core) and provides a
5
+ complete Smart Checkout integration for Vendure storefronts: payment-method
6
+ handler, webhook receiver + BullMQ worker, admin REST endpoints, Shop API
7
+ `cancelPayment` mutation, and a `vendure-viva-register-webhooks` CLI.
8
+
9
+ ---
10
+
11
+ > **v0.2.0 — alpha. Live demo verification pending.**
12
+ >
13
+ > Plugin now supports two operational modes: `merchant` (default, single-tenant)
14
+ > and `isv` (opt-in, multi-tenant under an ISV partner). Marketplace mode is
15
+ > reserved for a future release.
16
+ >
17
+ > Canonical references live in the repo's `docs/` directory (published with the
18
+ > package):
19
+ > [`docs/AUTH.md`](https://github.com/sakee-tech/vivawallet-npm-public/blob/main/docs/AUTH.md),
20
+ > [`docs/ENDPOINTS.md`](https://github.com/sakee-tech/vivawallet-npm-public/blob/main/docs/ENDPOINTS.md),
21
+ > [`docs/WEBHOOKS.md`](https://github.com/sakee-tech/vivawallet-npm-public/blob/main/docs/WEBHOOKS.md),
22
+ > [`docs/STATE-MACHINE.md`](https://github.com/sakee-tech/vivawallet-npm-public/blob/main/docs/STATE-MACHINE.md),
23
+ > [`docs/ERRORS.md`](https://github.com/sakee-tech/vivawallet-npm-public/blob/main/docs/ERRORS.md),
24
+ > [`docs/SECURITY.md`](https://github.com/sakee-tech/vivawallet-npm-public/blob/main/docs/SECURITY.md),
25
+ > [`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).
26
+
27
+ ---
28
+
29
+ ## Prerequisites
30
+
31
+ | Requirement | Notes |
32
+ |---|---|
33
+ | **Vendure 3.x** | `@vendure/core ^3.6.x` |
34
+ | **Postgres** | TypeORM-compatible. SQLite acceptable for unit tests only. |
35
+ | **BullMQ-backed JobQueueService** | The webhook flow REQUIRES async job processing. Use `BullMQJobQueueStrategy` (from `@vendure/job-queue-plugin`) or equivalent. In-memory job queue is acceptable for dev only — webhooks will not be processed in production without it. |
36
+ | **A Viva account** | Merchant mode: direct merchant credentials (Self Care → Settings → API Access). ISV mode: ISV partner credentials. Demo + production use the same credential pair; `environment` flips the base URL. |
37
+ | **Node.js ≥ 20** | |
38
+
39
+ ---
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ pnpm add @sakeetech/vendure-payment-viva
45
+ ```
46
+
47
+ Peer dependencies (install alongside the plugin):
48
+
49
+ ```bash
50
+ pnpm add @vendure/core @vendure/job-queue-plugin @nestjs/common @nestjs/core rxjs typeorm graphql
51
+ ```
52
+
53
+ ---
54
+
55
+ ## Mode matrix
56
+
57
+ The plugin runs in one of two operational modes. **`merchant` is the default**
58
+ (90% case). Pass `mode: 'isv'` to `init()` to opt into ISV multi-tenant mode.
59
+
60
+ | Capability | `merchant` *(default)* | `isv` *(opt-in)* |
61
+ |---|---|---|
62
+ | **Who it's for** | Single direct Viva merchant — your storefront, your account | SaaS / platform onboarding many merchants under one ISV partner agreement |
63
+ | **Required credentials** | One OAuth2 pair (`clientId` / `clientSecret`) + one Basic pair (`legacyMerchantId` / `legacyApiKey`) | Platform-wide OAuth2 pair + legacy Basic pair + optional Reseller Basic pair (`reseller`) |
64
+ | **OAuth2 scope** | `urn:viva:payments:core:api:redirectcheckout` (+ `acquiring` for Fast Refund) | `urn:viva:payments:core:api:isv` (+ `acquiring`) |
65
+ | **Per-call merchant scoping** | None — token IS the merchant | `?merchantId={uuid}` query on every Smart Checkout / transaction call |
66
+ | **Onboarding flow** | None — merchant signs up with Viva directly | `POST /isv/v1/accounts` → hosted KYC → 8194 verification webhook |
67
+ | **Channel resolution** | `vivaSourceCode` only (no per-tenant merchantId) | `resolveMerchantId` callback or `ctx.channel.customFields.vivaMerchantId` |
68
+ | **Channel custom fields registered** | `vivaSourceCode`, `vivaApplePayDomainVerified` | …plus `vivaAccountId`, `vivaMerchantId`, `vivaPayoutsEnabled` |
69
+ | **Admin REST surface** | `GET /viva/internal/*`, `GET /viva/webhook/health` | …plus `POST/GET /viva/admin/connected-accounts/*`, `POST .../sources` |
70
+ | **Webhook registration** | Manual — Self Care UI; CLI prints the URLs + verification key | Automated — CLI calls `POST /isv/v1/webhooks` |
71
+ | **Webhook events handled** | 1796, 1797, 1798, 4865 | …plus 8193 (Account Connected), 8194 (Account Verification) |
72
+ | **Refund paths** | Fast Refund (Visa/MC) → Standard refund (legacy Basic auth) | Same, plus optional per-tenant approval state |
73
+ | **Storefront `cancelPayment` mutation** | mounted | mounted |
74
+
75
+ If `mode` is omitted, the plugin **defaults to `'merchant'`** and prints a
76
+ one-time startup warning. Set it explicitly to silence it. `mode` is also
77
+ auto-inferred as `'isv'` when only ISV-only fields (e.g. `resolveMerchantId`,
78
+ `onboardingReturnUrl`) are provided.
79
+
80
+ ---
81
+
82
+ ## Configuration
83
+
84
+ ### Quick start — merchant mode
85
+
86
+ The 90% case. You have a single Viva merchant account and want to take payments
87
+ on a single Vendure storefront.
88
+
89
+ ```ts
90
+ import { VivaPaymentPlugin } from '@sakeetech/vendure-payment-viva';
91
+
92
+ export const config: VendureConfig = {
93
+ plugins: [
94
+ VivaPaymentPlugin.init({
95
+ mode: 'merchant', // default; can be omitted
96
+ environment: 'demo', // 'demo' | 'production'
97
+ clientId: process.env.VIVA_CLIENT_ID!,
98
+ clientSecret: process.env.VIVA_CLIENT_SECRET!,
99
+ legacyMerchantId: process.env.VIVA_MERCHANT_ID!,
100
+ legacyApiKey: process.env.VIVA_API_KEY!,
101
+ webhookVerificationKey: process.env.VIVA_WEBHOOK_VERIFICATION_KEY!,
102
+ successUrl: 'https://your-store.com/order-confirmation/{orderCode}',
103
+ failureUrl: 'https://your-store.com/checkout?paymentCancelled=1',
104
+ sourceCode: 'Default', // optional Smart Checkout source code
105
+ }),
106
+ BullMQJobQueuePlugin.init({ connection: redisConnectionOptions }),
107
+ ],
108
+ };
109
+ ```
110
+
111
+ Required env vars (merchant mode):
112
+
113
+ | Variable | Description |
114
+ |---|---|
115
+ | `VIVA_CLIENT_ID` | OAuth2 client_id — Smart Checkout creds from Self Care → API Access |
116
+ | `VIVA_CLIENT_SECRET` | OAuth2 client_secret — paired with `VIVA_CLIENT_ID` |
117
+ | `VIVA_MERCHANT_ID` | Legacy Basic-auth Merchant ID (UUID) — required for refunds (probe F1, 2026-04-25) |
118
+ | `VIVA_API_KEY` | Legacy Basic-auth API Key — paired with `VIVA_MERCHANT_ID` |
119
+ | `VIVA_WEBHOOK_VERIFICATION_KEY` | URL-verify response key. Fetched by the CLI on `--apply`. |
120
+
121
+ ### Quick start — ISV mode (advanced / opt-in)
122
+
123
+ ISV mode is for SaaS platforms onboarding many merchants under one ISV partner
124
+ agreement with Viva. You hold platform-wide OAuth2 credentials; each merchant
125
+ goes through Viva's hosted KYC to attach to your platform.
126
+
127
+ ```ts
128
+ import { VivaPaymentPlugin } from '@sakeetech/vendure-payment-viva';
129
+
130
+ export const config: VendureConfig = {
131
+ plugins: [
132
+ VivaPaymentPlugin.init({
133
+ mode: 'isv',
134
+ environment: 'demo',
135
+
136
+ // Platform-wide ISV OAuth2 pair (same pair for demo + production).
137
+ clientId: process.env.VIVA_CLIENT_ID!,
138
+ clientSecret: process.env.VIVA_CLIENT_SECRET!,
139
+
140
+ // Legacy Basic-auth pair — required in BOTH modes for refunds (probe F1).
141
+ legacyMerchantId: process.env.VIVA_MERCHANT_ID!,
142
+ legacyApiKey: process.env.VIVA_API_KEY!,
143
+
144
+ webhookVerificationKey: process.env.VIVA_WEBHOOK_VERIFICATION_KEY!,
145
+ successUrl: 'https://your-saas.com/order-confirmation/{orderCode}',
146
+ failureUrl: 'https://your-saas.com/checkout?paymentCancelled=1',
147
+
148
+ // Required by POST /isv/v1/accounts.
149
+ onboardingReturnUrl: 'https://your-saas.com/admin/viva/onboarding-return',
150
+
151
+ // Optional ISV branding shown on Viva's onboarding pages.
152
+ // onboardingBranding: { partnerName: 'YourSaaS', logoUrl: 'https://...', primaryColor: '#1F2439' },
153
+
154
+ // Per-channel resolvers (all optional — defaults read from Channel custom fields).
155
+ // resolveMerchantId: (ctx) => ctx.channel.customFields.vivaMerchantId,
156
+ // resolveSourceCode: (ctx) => ctx.channel.customFields.vivaSourceCode ?? 'Default',
157
+ // resolveIsvAmount: (order, ctx) => 0,
158
+ // resolveCheckoutColor: (ctx) => undefined,
159
+
160
+ // Reseller Basic-auth — required only when calling
161
+ // POST /viva/admin/connected-accounts/:id/sources (IsvSources).
162
+ // All three fields are all-or-nothing.
163
+ // reseller: {
164
+ // resellerId: process.env.VIVA_RESELLER_ID!,
165
+ // merchantId: process.env.VIVA_RESELLER_MERCHANT_ID!,
166
+ // resellerApiKey: process.env.VIVA_RESELLER_API_KEY!,
167
+ // },
168
+ }),
169
+ BullMQJobQueuePlugin.init({ connection: redisConnectionOptions }),
170
+ ],
171
+ };
172
+ ```
173
+
174
+ ### Required vs. optional fields
175
+
176
+ Discriminated on `mode`. Fields marked **shared** apply to both modes.
177
+
178
+ | Field | Mode | Required | Default |
179
+ |---|---|---|---|
180
+ | `mode` | — | no | `'merchant'` (auto-inferred as `'isv'` when ISV-only fields are present) |
181
+ | `environment` | shared | yes | — |
182
+ | `clientId` | shared | yes | — (alias: `isvClientId` accepted with deprecation warning) |
183
+ | `clientSecret` | shared | yes | — (alias: `isvClientSecret` accepted with deprecation warning) |
184
+ | `legacyMerchantId` | shared | yes | — (required for refunds — probe F1) |
185
+ | `legacyApiKey` | shared | yes | — (required for refunds — probe F1) |
186
+ | `webhookVerificationKey` | shared | yes | — |
187
+ | `successUrl` | shared | yes | — |
188
+ | `failureUrl` | shared | yes | — |
189
+ | `refundStrategy` | shared | no | `'auto'` (Fast → Standard fallback) |
190
+ | `sourceCode` | merchant | no | `'Default'` |
191
+ | `checkoutColor` | merchant | no | unset |
192
+ | `onboardingReturnUrl` | ISV | yes | — (rejected by `POST /isv/v1/accounts` if missing) |
193
+ | `onboardingBranding` | ISV | no | unset |
194
+ | `resolveMerchantId` | ISV | no | reads `ctx.channel.customFields.vivaMerchantId` |
195
+ | `resolveSourceCode` | ISV | no | reads `vivaSourceCode ?? 'Default'` |
196
+ | `resolveIsvAmount` | ISV | no | `() => 0` |
197
+ | `resolveCheckoutColor` | ISV | no | `() => undefined` |
198
+ | `reseller` | ISV | no | required only for `POST .../sources` route |
199
+ | `redlock` | shared | no | in-process semaphore (5 permits per merchantId per worker) |
200
+ | `logger` | shared | no | Vendure built-in |
201
+ | `metricsHook` | shared | no | noop |
202
+ | `tracer` | shared | no | noop |
203
+ | `webhookIpAllowlist` | shared | no | Viva published demo + production CIDRs |
204
+ | `trustedProxyDepth` | shared | no | `0` (socket only). Number of trailing `X-Forwarded-For` hops set by infrastructure you control. Required to be set correctly when a proxy/CDN sits in front — see "Webhook firewall configuration" below. |
205
+
206
+ ### Deprecated aliases (one-minor back-compat)
207
+
208
+ Accepted in `0.2.x` with a startup deprecation warning. **Removed in `0.3.0`.**
209
+
210
+ | Deprecated | Use instead |
211
+ |---|---|
212
+ | `isvClientId` | `clientId` |
213
+ | `isvClientSecret` | `clientSecret` |
214
+
215
+ > 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
216
+ > the full upgrade path.
217
+
218
+ ---
219
+
220
+ ## Channel custom fields
221
+
222
+ The plugin auto-registers custom fields on the `Channel` entity. Vendure
223
+ applies them automatically when the plugin is loaded — no manual migration
224
+ needed.
225
+
226
+ | Field | Type | Default | Modes | Written by |
227
+ |---|---|---|---|---|
228
+ | `vivaSourceCode` | `string \| null` | `'Default'` | both | Operator sets manually in Vendure admin; identifies the Smart Checkout source |
229
+ | `vivaApplePayDomainVerified` | `boolean` | `false` | both | Operator sets manually after completing the Apple Pay domain registration step in Viva Self Care (ops tracking only; does not gate payments) |
230
+ | `vivaAccountId` | `string \| null` | `null` | ISV only | Plugin writes at onboarding (`POST /viva/admin/connected-accounts`) |
231
+ | `vivaMerchantId` | `string \| null` | `null` | ISV only | Plugin writes automatically on Account Verification webhook (8194). Operator can also trigger via the reconcile endpoint |
232
+ | `vivaPayoutsEnabled` | `boolean` | `false` | ISV only | Plugin flips to `true` as the **last step** of Account Verification webhook processing (the storefront gate). `public: true` — readable from Shop API |
233
+
234
+ > Switching an existing deployment from `isv` → `merchant` (or back) does NOT
235
+ > drop columns — Vendure preserves operator data. The three ISV-only columns
236
+ > stay in place but unused. To reclaim them, drop manually.
237
+
238
+ ---
239
+
240
+ ## Quick start (full flow)
241
+
242
+ ### 1. Add the plugin
243
+
244
+ ```ts
245
+ // vendure-config.ts
246
+ import { VivaPaymentPlugin } from '@sakeetech/vendure-payment-viva';
247
+
248
+ export const config: VendureConfig = {
249
+ plugins: [
250
+ VivaPaymentPlugin.init({ /* see Configuration above */ }),
251
+ // BullMQJobQueuePlugin is required for webhook processing
252
+ BullMQJobQueuePlugin.init({ connection: redisConnectionOptions }),
253
+ ],
254
+ };
255
+ ```
256
+
257
+ ### 2. Run migrations
258
+
259
+ ```bash
260
+ # Generates the initial migration (creates viva_transaction + viva_webhook_event tables)
261
+ npx vendure migrate
262
+ ```
263
+
264
+ Channel custom fields are auto-registered — no separate migration step.
265
+
266
+ ### 3. Set environment variables
267
+
268
+ ```bash
269
+ # Merchant mode
270
+ VIVA_CLIENT_ID=your-smart-checkout-client-id
271
+ VIVA_CLIENT_SECRET=your-smart-checkout-client-secret
272
+ VIVA_MERCHANT_ID=your-merchant-uuid
273
+ VIVA_API_KEY=your-api-key
274
+ VIVA_WEBHOOK_VERIFICATION_KEY= # leave empty until step 4
275
+ VIVA_WEBHOOK_URL=https://your-store.com/viva/webhook
276
+ ```
277
+
278
+ ### 4. Register webhooks
279
+
280
+ The plugin ships a CLI `vendure-viva-register-webhooks`. Mode-aware behaviour:
281
+
282
+ **Merchant mode** — Viva offers no programmatic webhook-registration API for
283
+ direct merchants. The CLI fetches the verification key via
284
+ `GET /api/messages/config/token` (Basic auth) and prints the URLs to paste
285
+ into Self Care.
286
+
287
+ ```bash
288
+ VIVA_MODE=merchant pnpm vendure-viva-register-webhooks --apply
289
+ # Prints:
290
+ # Manual webhook setup required (merchant mode).
291
+ # 1. Go to Viva Self Care → Sales → API Access → Webhooks.
292
+ # 2. For each event below, register the URL with your storefront base URL:
293
+ # Transaction Payment Created (1796): https://your-store.com/viva/webhook
294
+ # Transaction Reversal Created (1797): https://your-store.com/viva/webhook
295
+ # Transaction Payment Failed (1798): https://your-store.com/viva/webhook
296
+ # Order Updated (4865): https://your-store.com/viva/webhook
297
+ # 3. Paste this verification key into VIVA_WEBHOOK_VERIFICATION_KEY:
298
+ # <uuid-printed-here>
299
+ # 4. Restart your Vendure server.
300
+ ```
301
+
302
+ **ISV mode** — registers one webhook URL per V1 event type at the ISV partner
303
+ level (one URL covers all merchants). Idempotent — re-running emits
304
+ `SKIP_ALREADY_REGISTERED` and exits 0.
305
+
306
+ ```bash
307
+ # Preview (no changes applied)
308
+ VIVA_MODE=isv pnpm vendure-viva-register-webhooks --dry-run
309
+
310
+ # Apply — POSTs /isv/v1/webhooks per V1 event type (1796, 1797, 1798, 4865, 8193, 8194)
311
+ VIVA_MODE=isv pnpm vendure-viva-register-webhooks --apply
312
+
313
+ # Drift reconciliation (ISV — currently a warning no-op; the ISV API exposes
314
+ # no list/deactivate endpoints, probe-verified 2026-05-11)
315
+ VIVA_MODE=isv pnpm vendure-viva-register-webhooks --reconcile-drift
316
+ ```
317
+
318
+ Paste the verification key into `.env` and restart.
319
+
320
+ > See [`docs/WEBHOOKS.md`](https://github.com/sakee-tech/vivawallet-npm-public/blob/main/docs/WEBHOOKS.md) for the full envelope shape
321
+ > and per-event payload reference.
322
+
323
+ ### 5. (ISV mode only) Onboard a channel
324
+
325
+ ```bash
326
+ curl -X POST https://your-saas.com/viva/admin/connected-accounts \
327
+ -H 'Authorization: Bearer <superadmin-token>' \
328
+ -H 'Content-Type: application/json' \
329
+ -d '{"channelId": 1}'
330
+ # → { "accountId": "...", "onboardingUrl": "https://..." }
331
+ ```
332
+
333
+ Send the `onboardingUrl` to the shop principal. They complete Viva's hosted KYC.
334
+ When Viva verifies the account, webhook 8194 fires automatically and the plugin
335
+ writes `vivaMerchantId` + flips `vivaPayoutsEnabled=true`.
336
+
337
+ In **merchant mode**, no onboarding step is needed — your single merchant
338
+ account is implicit.
339
+
340
+ ### 6. Storefront integration
341
+
342
+ ```graphql
343
+ # Storefront: add payment to order (Viva handler returns redirectUrl in metadata)
344
+ mutation {
345
+ addPaymentToOrder(input: { method: "viva", metadata: {} }) {
346
+ ... on Order {
347
+ id
348
+ state
349
+ payments {
350
+ id
351
+ state
352
+ metadata # contains redirectUrl
353
+ }
354
+ }
355
+ }
356
+ }
357
+ ```
358
+
359
+ After receiving the payment, redirect the customer to `metadata.redirectUrl`.
360
+
361
+ On return from Smart Checkout, handle the cancel case:
362
+
363
+ ```graphql
364
+ # Storefront: ?paymentCancelled=1 → cancel the payment
365
+ mutation CancelPayment($paymentId: ID!) {
366
+ cancelPayment(paymentId: $paymentId) {
367
+ ... on Order { id state }
368
+ ... on CancelPaymentError { errorCode message }
369
+ }
370
+ }
371
+ ```
372
+
373
+ ---
374
+
375
+ ## REST contract
376
+
377
+ All admin endpoints require `Permission.SuperAdmin`. Mode availability:
378
+
379
+ | Path | merchant | isv |
380
+ |---|---|---|
381
+ | `GET` `/viva/internal/auth-status` | mounted | mounted |
382
+ | `GET` `/viva/webhook/health` | mounted | mounted |
383
+ | `GET` `/viva/metrics` | mounted | mounted |
384
+ | `GET` `/viva/webhook` | mounted | mounted |
385
+ | `POST` `/viva/webhook` | mounted | mounted |
386
+ | `POST` `/viva/admin/connected-accounts` | 404 | mounted |
387
+ | `GET` `/viva/admin/connected-accounts/:channelId` | 404 | mounted |
388
+ | `POST` `/viva/admin/connected-accounts/:channelId/reconcile` | 404 | mounted |
389
+ | `POST` `/viva/admin/connected-accounts/:id/sources` | 404 | mounted (requires `reseller` config or returns 412) |
390
+
391
+ ISV-only routes return `404 not_found` in merchant mode (per-handler
392
+ short-circuit). The `POST .../sources` route is **new in v0.2.0** — creates a
393
+ Smart Checkout source via `POST /api/sources` against the Reseller Basic-auth
394
+ legacy host. Returns `412 VIVA_RESELLER_CREDENTIALS_MISSING` if `reseller` is
395
+ not configured.
396
+
397
+ > Full path detail, request / response shapes, and curl examples in
398
+ > [`docs/ENDPOINTS.md`](https://github.com/sakee-tech/vivawallet-npm-public/blob/main/docs/ENDPOINTS.md) §3 (adapter endpoints).
399
+
400
+ ### `POST /viva/admin/connected-accounts` *(ISV only)*
401
+
402
+ ```bash
403
+ curl -X POST https://your-saas.com/viva/admin/connected-accounts \
404
+ -H 'Authorization: Bearer <token>' \
405
+ -H 'Content-Type: application/json' \
406
+ -d '{"channelId": 1, "sellerId": "optional-seller-id"}'
407
+ ```
408
+
409
+ Response `200`:
410
+
411
+ ```json
412
+ {
413
+ "accountId": "eeeeeeee-ffff-0000-1111-222222222222",
414
+ "onboardingUrl": "https://www.vivapayments.com/onboarding/..."
415
+ }
416
+ ```
417
+
418
+ ### `GET /viva/admin/connected-accounts/:channelId` *(ISV only)*
419
+
420
+ Response `200`:
421
+
422
+ ```json
423
+ {
424
+ "accountId": "eeeeeeee-ffff-0000-1111-222222222222",
425
+ "merchantId": "cccccccc-dddd-eeee-ffff-000000000001",
426
+ "payoutsEnabled": true,
427
+ "verificationStatus": "Approved",
428
+ "applePayDomainVerified": false
429
+ }
430
+ ```
431
+
432
+ ### `POST /viva/admin/connected-accounts/:channelId/reconcile` *(ISV only)*
433
+
434
+ Replays steps 6a–6c of the onboarding flow (retrieve account → write
435
+ `merchantId` → flip `payoutsEnabled`). Use this if webhook 8194 was missed.
436
+
437
+ ```json
438
+ {
439
+ "merchantId": "cccccccc-dddd-eeee-ffff-000000000001",
440
+ "payoutsEnabled": true
441
+ }
442
+ ```
443
+
444
+ ### `POST /viva/admin/connected-accounts/:id/sources` *(ISV only, new in v0.2.0)*
445
+
446
+ Creates an ecommerce or physical payment source on a connected merchant's
447
+ account via `POST /api/sources` (Reseller Basic auth). Returns `412
448
+ VIVA_RESELLER_CREDENTIALS_MISSING` when `options.reseller` is absent; `409
449
+ VIVA_ACCOUNT_NOT_VERIFIED` when the account hasn't completed KYC.
450
+
451
+ ### `GET /viva/internal/auth-status`
452
+
453
+ ```json
454
+ {
455
+ "token_present": true,
456
+ "token_expires_at": "2026-04-25T11:00:00.000Z",
457
+ "last_refresh_at": "2026-04-25T10:00:00.000Z"
458
+ }
459
+ ```
460
+
461
+ ### `GET /viva/webhook/health`
462
+
463
+ ```json
464
+ {
465
+ "events_received_24h": 42,
466
+ "events_pending": 0,
467
+ "oldest_pending_age_seconds": 0,
468
+ "last_processed_at": "2026-04-25T10:01:23.000Z"
469
+ }
470
+ ```
471
+
472
+ ### Webhook endpoints
473
+
474
+ | Verb | Path | Auth | Description |
475
+ |---|---|---|---|
476
+ | `GET` | `/viva/webhook` | IP allowlist + URL-verify handshake | Returns `{"Key": "<verification-key>"}` during Viva's registration / re-verification probe |
477
+ | `POST` | `/viva/webhook` | IP allowlist | Receive Viva event. INSERT-OR-IGNORE on `MessageId`. Enqueue BullMQ job if newly inserted. Always returns 200. |
478
+
479
+ > Receiver auth model + IP allowlist details in
480
+ > [`docs/SECURITY.md`](https://github.com/sakee-tech/vivawallet-npm-public/blob/main/docs/SECURITY.md) and
481
+ > [`docs/WEBHOOKS.md`](https://github.com/sakee-tech/vivawallet-npm-public/blob/main/docs/WEBHOOKS.md) §2.
482
+
483
+ ### Webhook firewall configuration
484
+
485
+ The in-app IP allowlist is one of **two layers** Viva's docs require:
486
+
487
+ > "whitelist the below IP addresses/ranges in **both your server and in your network firewall**" — Viva docs, `webhooks-for-payments.txt`
488
+
489
+ This package handles the application layer. The network layer is your
490
+ responsibility. Without the network-layer block, an attacker can hit your
491
+ deployment with a spoofed `X-Forwarded-For` and bypass the in-app check if
492
+ `trustedProxyDepth` is misconfigured.
493
+
494
+ **Viva published source IPs** (from
495
+ `references/viva-docs/md/webhooks-for-payments.txt`):
496
+
497
+ ```
498
+ # Production
499
+ 51.138.37.238 20.61.40.108 13.80.70.181 13.80.71.6 13.79.28.70 40.74.88.139
500
+ 2603:1020:201::/48 2603:1020:206::/48 2603:1020:204::/47
501
+ 2603:1020:300::/48 2603:1020:302::/48 2603:1020:301::/47
502
+
503
+ # Demo
504
+ 40.91.219.176 20.224.241.71 2603:1020:600::/48
505
+ ```
506
+
507
+ **Setting `trustedProxyDepth` correctly:**
508
+
509
+ | Deployment | Value | Why |
510
+ |---|---|---|
511
+ | Direct exposure (no proxy) | `0` (default) | Use socket; ignore `X-Forwarded-For` entirely. |
512
+ | Single reverse proxy (nginx, Caddy, Traefik) | `1` | Trust the rightmost X-F-F entry. |
513
+ | ALB / Fly.io / Railway / Render | `1` | Each appends one X-F-F hop. |
514
+ | Cloudflare → ALB | `2` | CDN + LB each append one. |
515
+
516
+ Setting this too high is unsafe (the helper picks an attacker-controllable
517
+ entry). Setting it too low rejects legitimate webhooks. Verify with a
518
+ manual `curl` against a non-prod deployment.
519
+
520
+ **nginx — strip incoming `X-Forwarded-For`:**
521
+
522
+ ```nginx
523
+ location /viva/webhook {
524
+ proxy_set_header X-Forwarded-For $remote_addr;
525
+ proxy_set_header X-Real-IP $remote_addr;
526
+ proxy_pass http://vendure-upstream;
527
+ }
528
+ ```
529
+
530
+ **Cloudflare WAF rule:**
531
+
532
+ ```
533
+ (http.request.uri.path eq "/viva/webhook"
534
+ and http.request.method eq "POST"
535
+ and not (ip.src in {51.138.37.238 20.61.40.108 13.80.70.181 13.80.71.6
536
+ 13.79.28.70 40.74.88.139}))
537
+ ```
538
+
539
+ **AWS ALB — security group:** allow inbound TCP 443 only from the Viva
540
+ CIDRs above. The in-app allowlist becomes defence-in-depth.
541
+
542
+ ### Shop API extension
543
+
544
+ ```graphql
545
+ extend type Mutation {
546
+ """
547
+ Cancel a Vendure payment by ID.
548
+ Voids the Viva-side authorization (DELETE /checkout/v2/orders/{orderCode})
549
+ AND transitions the order back to AddingItems.
550
+ Storefront calls this on ?paymentCancelled=1.
551
+ Permission: order owner (active customer or active anonymous order).
552
+ """
553
+ cancelPayment(paymentId: ID!): CancelPaymentResult!
554
+ }
555
+
556
+ union CancelPaymentResult = Order | CancelPaymentError
557
+
558
+ type CancelPaymentError implements ErrorResult {
559
+ errorCode: String!
560
+ message: String!
561
+ vivaErrorCode: Int
562
+ vivaErrorMessage: String
563
+ }
564
+ ```
565
+
566
+ ---
567
+
568
+ ## Refund strategy
569
+
570
+ Refunds default to `refundStrategy: 'auto'` — try Fast Refund first, fall back
571
+ to Standard refund on `403 not eligible`.
572
+
573
+ | Strategy | Behaviour | When to use |
574
+ |---|---|---|
575
+ | `'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. |
576
+ | `'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. |
577
+ | `'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. |
578
+
579
+ Eligibility (auto + fast): Viva approves Fast Refund per-merchant; card must
580
+ be Visa or Mastercard; transaction must be card-not-present (e-commerce).
581
+
582
+ > Path detail in [`docs/ENDPOINTS.md`](https://github.com/sakee-tech/vivawallet-npm-public/blob/main/docs/ENDPOINTS.md) §5 (Fast
583
+ > Refund + Standard refund).
584
+
585
+ ---
586
+
587
+ ## Error contract
588
+
589
+ All plugin errors — REST responses, job failures, and the `cancelPayment`
590
+ mutation — share one envelope:
591
+
592
+ ```ts
593
+ type VivaPluginError = {
594
+ code: VivaErrorCode;
595
+ message: string;
596
+ retryable: boolean;
597
+ vivaErrorCode?: number; // Viva's own error code, when available
598
+ vivaErrorMessage?: string; // Viva's own error message, when available
599
+ };
600
+ ```
601
+
602
+ REST responses: HTTP `5xx` for retryable errors (`VIVA_AUTH_DOWN`); HTTP `4xx`
603
+ for all others. The `cancelPayment` mutation returns the `CancelPaymentError`
604
+ union member with the envelope flattened into `errorCode` + `message`.
605
+
606
+ | Code | HTTP | Retryable | When it fires |
607
+ |---|---|---|---|
608
+ | `VIVA_AUTH_DOWN` | 503 | yes | OAuth2 token unavailable at bootstrap or runtime (Viva 5xx / timeout) |
609
+ | `VIVA_API_ERROR` | 502 | no | Viva 4xx not covered by a specific code below. Passes through Viva's `errorCode` + `message` |
610
+ | `VIVA_ACCOUNT_NOT_VERIFIED` | 400 | no | (ISV) `createPayment` called on a channel where `vivaPayoutsEnabled=false` or `vivaMerchantId` is missing |
611
+ | `VIVA_ISV_AMOUNT_TOO_HIGH` | 400 | no | (ISV) `resolveIsvAmount` returned a value ≥ the order total (pre-call guard) |
612
+ | `VIVA_CHANNEL_MISCONFIGURED` | 400 | no | (ISV) `resolveMerchantId` returned `undefined` |
613
+ | `VIVA_ORDER_NOT_FOUND` | 404 | no | Retrieve Transaction returned not-found, or Vendure payment not found |
614
+ | `VIVA_AMOUNT_MISMATCH` | 422 | no | Retrieve Transaction amount ≠ `viva_transaction.amount_minor` (webhook job path — `processed_at` stays NULL) |
615
+ | `VIVA_REFUND_REJECTED` | 422 | no | Viva 4xx on refund request |
616
+ | `VIVA_PAYMENT_ALREADY_SETTLED` | 409 | no | Settle attempted on already-settled payment |
617
+ | `VIVA_PAYMENT_NOT_CANCELLABLE` | 409 | no | Cancel attempted on already-settled or already-cancelled payment |
618
+ | `VIVA_ALREADY_ONBOARDED` | 409 | no | (ISV) `POST /viva/admin/connected-accounts` called on a channel that already has `vivaAccountId` |
619
+ | `VIVA_RESELLER_CREDENTIALS_MISSING` | 412 | no | **(new v0.2.0)** `POST .../sources` called without `options.reseller` |
620
+ | `VIVA_SOURCE_CREATION_FAILED` | 422 | no | **(new v0.2.0)** Viva 4xx on `POST /api/sources` |
621
+ | `VIVA_FAST_REFUND_INELIGIBLE` | 403 | no | **(new v0.2.0)** (`refundStrategy='fast'` only) Card scheme isn't Visa/MC, or merchant not approved. With `'auto'` this is caught internally and never surfaces. |
622
+ | `VIVA_MODE_MISMATCH` | 400 | no | **(new v0.2.0)** ISV-only entry point invoked while plugin is in merchant mode (surfaced from `@sakeetech/viva-payments-core/errors`) |
623
+ | `VIVA_INTERNAL_ERROR` | 500 | no | Catch-all for unexpected failures. Indicates a bug |
624
+
625
+ > Full catalogue + Viva→plugin mapping rules in
626
+ > [`docs/ERRORS.md`](https://github.com/sakee-tech/vivawallet-npm-public/blob/main/docs/ERRORS.md).
627
+
628
+ ---
629
+
630
+ ## Field-write order on Account Verification webhook *(ISV mode)*
631
+
632
+ When webhook 8194 (Account Verification Status Changed) fires, the plugin
633
+ performs two writes to the channel's custom fields. **The order is mandatory:**
634
+
635
+ 1. **`vivaMerchantId` is written first.** This gives the payment handler a
636
+ valid merchantId to use in Viva API calls.
637
+ 2. **`vivaPayoutsEnabled` is flipped `true` last.** This is the storefront
638
+ gate — `createPayment` guards on `vivaPayoutsEnabled=true`. Only after
639
+ `vivaMerchantId` is present is it safe to open the gate.
640
+
641
+ If the writes were reversed — payouts enabled before merchantId is set — a
642
+ storefront call could slip through the gate and land on `createPayment` with
643
+ `vivaMerchantId=null`, throwing `VIVA_CHANNEL_MISCONFIGURED`.
644
+
645
+ This write order is enforced in both the webhook job handler and the reconcile
646
+ endpoint.
647
+
648
+ ---
649
+
650
+ ## Webhook flow
651
+
652
+ ### Sequence diagram
653
+
654
+ ```mermaid
655
+ sequenceDiagram
656
+ participant SF as Storefront
657
+ participant V as Vendure API
658
+ participant VivaAPI as Viva API
659
+ participant WH as POST /viva/webhook
660
+ participant BQ as BullMQ worker
661
+ participant DB as DB (viva_*)
662
+
663
+ SF->>V: addPaymentToOrder({method:"viva"})
664
+ V->>VivaAPI: POST /checkout/v2/[isv/]orders[?merchantId={uuid}]
665
+ VivaAPI-->>V: {orderCode, redirectUrl}
666
+ V-->>SF: Payment{state:Created, metadata.redirectUrl}
667
+ SF->>SF: window.location = redirectUrl (Smart Checkout)
668
+ SF->>VivaAPI: customer completes payment
669
+ VivaAPI->>WH: POST /viva/webhook (EventTypeId=1796)
670
+ WH->>DB: INSERT INTO viva_webhook_event ON CONFLICT DO NOTHING
671
+ WH->>BQ: enqueue process-viva-webhook {messageId}
672
+ WH-->>VivaAPI: 200 OK (<100ms)
673
+ BQ->>VivaAPI: GET /checkout/v2/transactions/{id}
674
+ VivaAPI-->>BQ: {statusId:"F", amount:9999}
675
+ BQ->>DB: UPDATE viva_transaction SET status='captured'
676
+ BQ->>V: settlePayment → ArrangingPayment→PaymentAuthorized→PaymentSettled
677
+ BQ->>DB: UPDATE viva_webhook_event SET processed_at=now()
678
+ SF->>V: navigate to /order-confirmation/{orderCode}
679
+ ```
680
+
681
+ ### Narrative
682
+
683
+ 1. `addPaymentToOrder(method:"viva")` invokes
684
+ `PaymentMethodHandler.createPayment`. The handler chooses the merchant- or
685
+ ISV-mode Smart Checkout endpoint, receives an `orderCode`, constructs the
686
+ redirect URL, and returns Vendure state `Created` with `metadata.redirectUrl`.
687
+
688
+ 2. The storefront redirects the customer to Smart Checkout. The customer
689
+ completes (or cancels) payment on Viva's hosted page.
690
+
691
+ 3. Viva fires webhook `1796` (Payment Created). The plugin's `POST
692
+ /viva/webhook` controller:
693
+ - Validates source IP against the allowlist.
694
+ - Runs `INSERT INTO viva_webhook_event ... ON CONFLICT (message_id) DO
695
+ NOTHING` (idempotent dedupe by `MessageId`).
696
+ - If the row was newly inserted, enqueues a BullMQ job and returns 200
697
+ immediately (latency target: <100ms server-side, no Viva API call in the
698
+ receive path).
699
+
700
+ 4. The BullMQ worker processes the job:
701
+ - Calls `GET /checkout/v2/transactions/{transactionId}` to retrieve the
702
+ transaction (Retrieve-Transaction-before-settle pattern).
703
+ - Validates `statusId='F'` (Finished) and that the `amount` matches
704
+ `viva_transaction.amount_minor` (mismatch → `VIVA_AMOUNT_MISMATCH`,
705
+ `processed_at` stays NULL).
706
+ - Transitions the order: `ArrangingPayment → PaymentAuthorized →
707
+ PaymentSettled`.
708
+ - Marks the webhook event row `processed_at = now()`.
709
+
710
+ 5. If the order was already rolled back to `AddingItems` by a sweep job, the
711
+ worker performs a stale-order re-walk to bring it back to `PaymentSettled`.
712
+
713
+ > Full state machine, transition rules, and stale-order re-walk in
714
+ > [`docs/STATE-MACHINE.md`](https://github.com/sakee-tech/vivawallet-npm-public/blob/main/docs/STATE-MACHINE.md).
715
+
716
+ ---
717
+
718
+ ## Multi-channel setup *(ISV mode)*
719
+
720
+ The plugin is ISV multi-tenant by design when `mode: 'isv'`. Every deployed
721
+ channel is a separate Viva merchant. Steps for adding a new channel:
722
+
723
+ 1. Create the channel in Vendure (admin UI or API).
724
+ 2. Set `vivaSourceCode` on the channel's custom fields to the desired Smart
725
+ Checkout source code (e.g. `'Default'`).
726
+ 3. Call `POST /viva/admin/connected-accounts` with `{channelId}` to initiate
727
+ onboarding. Returns `{accountId, onboardingUrl}`.
728
+ 4. Send `onboardingUrl` to the channel's principal for KYC.
729
+ 5. Wait for webhook 8194. The plugin writes `vivaMerchantId` + flips
730
+ `vivaPayoutsEnabled=true` automatically.
731
+ 6. The storefront on that channel can now call
732
+ `addPaymentToOrder(method:"viva")`.
733
+
734
+ Webhook registration is ISV-level (one URL per event type covers all
735
+ merchants). No per-channel webhook configuration is needed.
736
+
737
+ In **merchant mode**, every channel shares the single configured merchant
738
+ account. Per-channel `vivaSourceCode` is still respected.
739
+
740
+ ---
741
+
742
+ ## Apple Pay — manual step required
743
+
744
+ > **WARNING:** Native Apple Pay domain registration is **not exposed by Viva
745
+ > to any integrator** (merchant or ISV). Domain registration must be performed
746
+ > manually through Viva Self Care for each storefront domain.
747
+
748
+ Steps:
749
+ 1. Navigate to Viva Self Care → Smart Checkout → Apple Pay Domain Verification.
750
+ 2. Complete the domain challenge for each storefront domain.
751
+ 3. Set `vivaApplePayDomainVerified=true` on the relevant channel custom field
752
+ (ops tracking only — the plugin does not use this field to gate Apple Pay).
753
+
754
+ There is no CLI or API call that can automate this step.
755
+
756
+ ---
757
+
758
+ ## Operator runbook
759
+
760
+ ### Onboarding a new shop *(ISV mode)*
761
+
762
+ 1. `POST /viva/admin/connected-accounts {channelId}` — initiate onboarding.
763
+ 2. Send the returned `onboardingUrl` to the shop principal.
764
+ 3. Principal completes Viva's KYC at the URL.
765
+ 4. Wait for Viva to fire webhook 8194 (can take minutes to hours after KYC
766
+ submission).
767
+ 5. Verify: `GET /viva/admin/connected-accounts/:channelId` should show
768
+ `"payoutsEnabled": true` and a non-null `merchantId`.
769
+ 6. If 8194 never arrived (check `/viva/webhook/health` and the
770
+ `viva_webhook_event` table), call
771
+ `POST /viva/admin/connected-accounts/:channelId/reconcile` to re-run the
772
+ account retrieval + write sequence manually.
773
+
774
+ ### Webhook registration and drift reconciliation
775
+
776
+ ```bash
777
+ # Merchant mode — re-fetch verification key and re-print manual setup
778
+ VIVA_MODE=merchant pnpm vendure-viva-register-webhooks --apply
779
+
780
+ # ISV mode — diff registered URLs vs. desired set (currently a warning no-op)
781
+ VIVA_MODE=isv pnpm vendure-viva-register-webhooks --reconcile-drift
782
+
783
+ # ISV mode — re-register (idempotent; emits SKIP_ALREADY_REGISTERED on dupes)
784
+ VIVA_MODE=isv pnpm vendure-viva-register-webhooks --apply
785
+ ```
786
+
787
+ The verification key (`VIVA_WEBHOOK_VERIFICATION_KEY`) is generated once per
788
+ deployment, stored in `.env`, and used to respond to Viva's GET URL-verify
789
+ probe. Never rotate it without also updating it in Viva Self Care (merchant
790
+ mode) or re-registering (ISV mode).
791
+
792
+ ### Troubleshooting: payment stuck in Created
793
+
794
+ **Symptom:** Customer completed Smart Checkout but the order is still in
795
+ `ArrangingPayment` and the payment shows state `Created`.
796
+
797
+ **Cause:** Webhook 1796 was not received or not processed.
798
+
799
+ **Steps:**
800
+ 1. Check `GET /viva/webhook/health` — look at `events_pending` and
801
+ `oldest_pending_age_seconds`.
802
+ 2. Query `SELECT * FROM viva_webhook_event WHERE processed_at IS NULL` — find
803
+ the stuck row.
804
+ 3. Check the `error` column for the failure reason (common: `retrieve-failed`,
805
+ `channel-not-found`, `VIVA_AMOUNT_MISMATCH`).
806
+ 4. If the row is missing entirely, the webhook was not received — check IP
807
+ allowlist and Viva dashboard for delivery errors.
808
+ 5. If the row exists with `error='channel-not-found'` (ISV), the channel's
809
+ `vivaMerchantId` was not set at the time of processing. Call
810
+ `POST /viva/admin/connected-accounts/:channelId/reconcile` then clear the
811
+ row's `error` and `processed_at=NULL` to allow re-processing.
812
+
813
+ ### Troubleshooting: payment in ArrangingPayment too long (stale orders)
814
+
815
+ **Symptom:** Order has been in `ArrangingPayment` for more than the
816
+ stale-order threshold (configurable via the retention job).
817
+
818
+ **Cause:** Either the customer abandoned the Smart Checkout, or the webhook
819
+ was received but the settle transition failed (check
820
+ `viva_webhook_event.error`).
821
+
822
+ **Recovery:** The retention cleanup job (runs daily via Vendure scheduler)
823
+ deletes `viva_webhook_event` rows with `processed_at < now() - 90 days`.
824
+ Stale orders require manual intervention (or a separate sweep job configured
825
+ by the SaaS).
826
+
827
+ ### Reading metrics and alerts
828
+
829
+ ```bash
830
+ curl https://your-vendure.com/viva/metrics -H 'Authorization: Bearer <token>'
831
+ ```
832
+
833
+ Key counters to alert on:
834
+ - `viva_webhook_events_pending_total > 10` — webhook processing is falling behind.
835
+ - `viva_auth_refresh_errors_total > 0` — OAuth2 token renewal is failing.
836
+ - `viva_amount_mismatch_total > 0` — payment amounts are diverging from Viva.
837
+
838
+ ### Manual paste of merchantId via reconcile endpoint *(ISV mode)*
839
+
840
+ If webhook 8194 was missed and `vivaMerchantId` is still null:
841
+
842
+ ```bash
843
+ # 1. Find the accountId on the channel
844
+ curl https://your-saas.com/viva/admin/connected-accounts/1 \
845
+ -H 'Authorization: Bearer <token>'
846
+
847
+ # 2. Trigger reconciliation (re-fetches from Viva, writes merchantId + payoutsEnabled)
848
+ curl -X POST https://your-saas.com/viva/admin/connected-accounts/1/reconcile \
849
+ -H 'Authorization: Bearer <token>'
850
+ ```
851
+
852
+ ---
853
+
854
+ ## Upgrading from 0.1.x
855
+
856
+ The `0.1.x` plugin was ISV-only. To upgrade in-place without behaviour change:
857
+
858
+ 1. Add `mode: 'isv'` to your `VivaPaymentPlugin.init({…})` call.
859
+ 2. Rename `isvClientId` → `clientId`, `isvClientSecret` → `clientSecret` (old
860
+ names still work for one minor with a deprecation warning; removed in
861
+ `0.3.0`).
862
+ 3. No DB migration needed — channel custom field shape is unchanged for ISV
863
+ deployments.
864
+
865
+ > Full upgrade path, env-var rename matrix, and rollback steps in
866
+ > [`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).
867
+
868
+ ---
869
+
870
+ ## Limitations / out of scope
871
+
872
+ | Limitation | Notes |
873
+ |---|---|
874
+ | **Native Apple Pay domain registration via API** | Viva does not expose this endpoint to any integrator. Manual step required (see §Apple Pay). |
875
+ | **Marketplace mode** | Reserved in the config union; not shipped in `v0.2.x`. |
876
+ | **Pre-auth-only flow** | Immediate-capture via Smart Checkout only — both modes. |
877
+ | **Subscription / recurring payments** | Not supported by this plugin. |
878
+ | **Admin UI extension** | SaaS owns the admin UI. Plugin ships the REST contract; SaaS integrates it. |
879
+ | **4865 Order Updated exact payload shape** | Viva's docs do not fully document the 4865 payload. Plugin defensively treats `StatusId ∈ {X, C, E}` as cancellation; will tighten after first live observation. |
880
+ | **Idempotency header server-side dedupe** | `Idempotency-Key` sent on all requests but not server-side deduped by Viva (probe F2, 2026-04-25). Local `viva_transaction` row is authoritative for dedup. |
881
+ | **Refund via legacy host** | Probe F1 (2026-04-25): `POST /checkout/v2/transactions/{id}` on v2/OAuth2 returns 405. Plugin uses `POST /api/transactions/{id}` on `demo.vivapayments.com`/`www.vivapayments.com` with Basic auth — requires `legacyMerchantId` + `legacyApiKey` in plugin options. |
882
+
883
+ ---
884
+
885
+ ## Sandbox testing notes
886
+
887
+ Fixture replay tests live in `test/sandbox/`:
888
+
889
+ ```
890
+ test/sandbox/
891
+ ├── fixtures/
892
+ │ ├── webhook-1796-payment-created.json
893
+ │ ├── webhook-1798-failed.json
894
+ │ ├── webhook-4865-order-updated.json
895
+ │ ├── webhook-8194-account-verification.json
896
+ │ ├── webhook-8194-account-verification-declined.json
897
+ │ ├── retrieve-transaction-finished.json
898
+ │ ├── retrieve-transaction-not-found.json
899
+ │ └── connected-account-verified.json
900
+ ├── replay-harness.ts
901
+ └── replay.test.ts
902
+ ```
903
+
904
+ All fixture `merchantId`, `transactionId`, and `accountId` values are stable
905
+ test UUIDs. No real Viva credentials appear in any fixture.
906
+
907
+ ### Refreshing fixtures from real Viva captures
908
+
909
+ 1. Run the plugin in `demo` environment against a Viva sandbox account.
910
+ 2. Capture the raw webhook body from your server logs (the `payload` column in
911
+ `viva_webhook_event`).
912
+ 3. Replace the corresponding fixture file's content.
913
+ 4. **Sanitise:** replace any real `MerchantId` with
914
+ `cccccccc-dddd-eeee-ffff-000000000001`, any real `ConnectedAccountId` with
915
+ `eeeeeeee-ffff-0000-1111-222222222222`, and any real `TransactionId` with
916
+ `aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb`.
917
+ 5. Keep `CardNumber` masked (`414746XXXXXX0133` format). Never commit real
918
+ card data.
919
+
920
+ ---
921
+
922
+ ## Versioning and roadmap
923
+
924
+ See [CHANGELOG.md](./CHANGELOG.md).
925
+
926
+ Open items after `0.2.0`:
927
+
928
+ - First live demo verification (shared with Medusa adapter — resolves
929
+ idempotency-header semantics, refund unit ambiguity, webhook amount unit).
930
+ - Marketplace mode (reserved seams — no timeline).
931
+ - Pre-auth-only flow (no timeline).
932
+
933
+ ---
934
+
935
+ ## License
936
+
937
+ MIT. See [LICENSE](./LICENSE).
938
+
939
+ ---
940
+
941
+ ## Contributing
942
+
943
+ Internal SaaS use. Contributions welcome once `0.2.0` stabilises after the
944
+ first live demo. Open issues or discussions in the project repository.
945
+
946
+ Local development:
947
+
948
+ ```bash
949
+ pnpm -F @sakeetech/vendure-payment-viva typecheck
950
+ pnpm -F @sakeetech/vendure-payment-viva test
951
+ pnpm -F @sakeetech/vendure-payment-viva build
952
+ ```