@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.
- package/LICENSE +21 -0
- package/README.md +952 -0
- package/dist/api/admin-internal.controller.d.ts +59 -0
- package/dist/api/admin-internal.controller.d.ts.map +1 -0
- package/dist/api/admin-internal.controller.js +229 -0
- package/dist/api/admin-internal.controller.js.map +1 -0
- package/dist/api/admin-onboarding.controller.d.ts +72 -0
- package/dist/api/admin-onboarding.controller.d.ts.map +1 -0
- package/dist/api/admin-onboarding.controller.js +496 -0
- package/dist/api/admin-onboarding.controller.js.map +1 -0
- package/dist/api/admin-sources.controller.d.ts +50 -0
- package/dist/api/admin-sources.controller.d.ts.map +1 -0
- package/dist/api/admin-sources.controller.js +283 -0
- package/dist/api/admin-sources.controller.js.map +1 -0
- package/dist/api/shop-api.extension.d.ts +15 -0
- package/dist/api/shop-api.extension.d.ts.map +1 -0
- package/dist/api/shop-api.extension.js +35 -0
- package/dist/api/shop-api.extension.js.map +1 -0
- package/dist/api/shop-api.resolver.d.ts +42 -0
- package/dist/api/shop-api.resolver.d.ts.map +1 -0
- package/dist/api/shop-api.resolver.js +256 -0
- package/dist/api/shop-api.resolver.js.map +1 -0
- package/dist/api/webhook.controller.d.ts +58 -0
- package/dist/api/webhook.controller.d.ts.map +1 -0
- package/dist/api/webhook.controller.js +204 -0
- package/dist/api/webhook.controller.js.map +1 -0
- package/dist/cli/bin.d.ts +28 -0
- package/dist/cli/bin.d.ts.map +1 -0
- package/dist/cli/bin.js +104 -0
- package/dist/cli/bin.js.map +1 -0
- package/dist/cli/plan.d.ts +41 -0
- package/dist/cli/plan.d.ts.map +1 -0
- package/dist/cli/plan.js +115 -0
- package/dist/cli/plan.js.map +1 -0
- package/dist/cli/register-webhooks.d.ts +45 -0
- package/dist/cli/register-webhooks.d.ts.map +1 -0
- package/dist/cli/register-webhooks.js +400 -0
- package/dist/cli/register-webhooks.js.map +1 -0
- package/dist/cli/types.d.ts +75 -0
- package/dist/cli/types.d.ts.map +1 -0
- package/dist/cli/types.js +10 -0
- package/dist/cli/types.js.map +1 -0
- package/dist/constants.d.ts +35 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +40 -0
- package/dist/constants.js.map +1 -0
- package/dist/entities/index.d.ts +4 -0
- package/dist/entities/index.d.ts.map +1 -0
- package/dist/entities/index.js +3 -0
- package/dist/entities/index.js.map +1 -0
- package/dist/entities/viva-transaction.entity.d.ts +70 -0
- package/dist/entities/viva-transaction.entity.d.ts.map +1 -0
- package/dist/entities/viva-transaction.entity.js +133 -0
- package/dist/entities/viva-transaction.entity.js.map +1 -0
- package/dist/entities/viva-webhook-event.entity.d.ts +71 -0
- package/dist/entities/viva-webhook-event.entity.d.ts.map +1 -0
- package/dist/entities/viva-webhook-event.entity.js +138 -0
- package/dist/entities/viva-webhook-event.entity.js.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/dist/jobs/process-viva-webhook.handler.d.ts +95 -0
- package/dist/jobs/process-viva-webhook.handler.d.ts.map +1 -0
- package/dist/jobs/process-viva-webhook.handler.js +530 -0
- package/dist/jobs/process-viva-webhook.handler.js.map +1 -0
- package/dist/jobs/queue-names.d.ts +18 -0
- package/dist/jobs/queue-names.d.ts.map +1 -0
- package/dist/jobs/queue-names.js +19 -0
- package/dist/jobs/queue-names.js.map +1 -0
- package/dist/jobs/retention-cleanup.handler.d.ts +31 -0
- package/dist/jobs/retention-cleanup.handler.d.ts.map +1 -0
- package/dist/jobs/retention-cleanup.handler.js +94 -0
- package/dist/jobs/retention-cleanup.handler.js.map +1 -0
- package/dist/loaders/bootstrap.d.ts +28 -0
- package/dist/loaders/bootstrap.d.ts.map +1 -0
- package/dist/loaders/bootstrap.js +90 -0
- package/dist/loaders/bootstrap.js.map +1 -0
- package/dist/migrations/1714000000000-create-viva-tables.d.ts +22 -0
- package/dist/migrations/1714000000000-create-viva-tables.d.ts.map +1 -0
- package/dist/migrations/1714000000000-create-viva-tables.js +105 -0
- package/dist/migrations/1714000000000-create-viva-tables.js.map +1 -0
- package/dist/observability/metrics-state.service.d.ts +43 -0
- package/dist/observability/metrics-state.service.d.ts.map +1 -0
- package/dist/observability/metrics-state.service.js +207 -0
- package/dist/observability/metrics-state.service.js.map +1 -0
- package/dist/payment-method-handler.d.ts +26 -0
- package/dist/payment-method-handler.d.ts.map +1 -0
- package/dist/payment-method-handler.js +693 -0
- package/dist/payment-method-handler.js.map +1 -0
- package/dist/plugin.d.ts +95 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +241 -0
- package/dist/plugin.js.map +1 -0
- package/dist/providers/viva-oauth2-strategy.provider.d.ts +41 -0
- package/dist/providers/viva-oauth2-strategy.provider.d.ts.map +1 -0
- package/dist/providers/viva-oauth2-strategy.provider.js +60 -0
- package/dist/providers/viva-oauth2-strategy.provider.js.map +1 -0
- package/dist/services/connected-accounts.service.d.ts +53 -0
- package/dist/services/connected-accounts.service.d.ts.map +1 -0
- package/dist/services/connected-accounts.service.js +108 -0
- package/dist/services/connected-accounts.service.js.map +1 -0
- package/dist/services/per-merchant-semaphore.service.d.ts +49 -0
- package/dist/services/per-merchant-semaphore.service.d.ts.map +1 -0
- package/dist/services/per-merchant-semaphore.service.js +156 -0
- package/dist/services/per-merchant-semaphore.service.js.map +1 -0
- package/dist/services/state-machine.service.d.ts +100 -0
- package/dist/services/state-machine.service.d.ts.map +1 -0
- package/dist/services/state-machine.service.js +233 -0
- package/dist/services/state-machine.service.js.map +1 -0
- package/dist/types.d.ts +286 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +23 -0
- package/dist/types.js.map +1 -0
- package/dist/util/currency.d.ts +32 -0
- package/dist/util/currency.d.ts.map +1 -0
- package/dist/util/currency.js +90 -0
- package/dist/util/currency.js.map +1 -0
- package/dist/util/error-envelope.d.ts +51 -0
- package/dist/util/error-envelope.d.ts.map +1 -0
- package/dist/util/error-envelope.js +157 -0
- package/dist/util/error-envelope.js.map +1 -0
- package/dist/util/ip-allowlist.d.ts +44 -0
- package/dist/util/ip-allowlist.d.ts.map +1 -0
- package/dist/util/ip-allowlist.js +139 -0
- package/dist/util/ip-allowlist.js.map +1 -0
- package/dist/util/normalize-options.d.ts +24 -0
- package/dist/util/normalize-options.d.ts.map +1 -0
- package/dist/util/normalize-options.js +189 -0
- package/dist/util/normalize-options.js.map +1 -0
- package/dist/util/url-template.d.ts +18 -0
- package/dist/util/url-template.d.ts.map +1 -0
- package/dist/util/url-template.js +22 -0
- package/dist/util/url-template.js.map +1 -0
- 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
|
+
```
|