@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
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* payment-method-handler.ts — Vendure PaymentMethodHandler for Viva Wallet ISV.
|
|
3
|
+
*
|
|
4
|
+
* Handler code: 'viva'
|
|
5
|
+
* Storefront calls: addPaymentToOrder({ method: 'viva' })
|
|
6
|
+
* Plan state contract: createPayment returns 'Created' (D3, §2)
|
|
7
|
+
*
|
|
8
|
+
* @see docs/plans/vendure-plugin-v0.md §"API Surface — PaymentMethodHandler operations"
|
|
9
|
+
* @see docs/plans/vendure-plugin-v0.md §"Architecture Decisions D3 + D5 + D11"
|
|
10
|
+
*/
|
|
11
|
+
import { PaymentMethodHandler, Logger, LanguageCode } from '@vendure/core';
|
|
12
|
+
import { IsvHttpClient } from '@sakeetech/viva-payments-core/isv';
|
|
13
|
+
import { Payments } from '@sakeetech/viva-payments-core/payments';
|
|
14
|
+
import { BasicAuthClient } from '@sakeetech/viva-payments-core/legacy';
|
|
15
|
+
import { FastRefundClient, resolveRefundStrategy, } from '@sakeetech/viva-payments-core/refunds';
|
|
16
|
+
import { VivaApiError, VivaAuthError, } from '@sakeetech/viva-payments-core/errors';
|
|
17
|
+
import { StateMachineService } from './services/state-machine.service.js';
|
|
18
|
+
import { VivaPluginError } from './util/error-envelope.js';
|
|
19
|
+
import { alphaToNumeric } from './util/currency.js';
|
|
20
|
+
import { substitute } from './util/url-template.js';
|
|
21
|
+
import { VIVA_PLUGIN_OPTIONS, VIVA_OAUTH2_STRATEGY_TOKEN, VIVA_LOG_CONTEXT, } from './constants.js';
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Constants
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
/** Latency log threshold in ms — log warning when Viva call exceeds this. */
|
|
26
|
+
const VIVA_LATENCY_WARN_MS = 1200;
|
|
27
|
+
/** Terminal statuses — cannot cancel a payment in these states. */
|
|
28
|
+
const TERMINAL_STATUSES = new Set(['captured', 'refunded', 'partially_refunded', 'failed', 'cancelled']);
|
|
29
|
+
/**
|
|
30
|
+
* Default Smart Checkout source code used when neither the channel custom
|
|
31
|
+
* field `vivaSourceCode` nor `config.sourceCode` (merchant mode) is set.
|
|
32
|
+
*
|
|
33
|
+
* @see references/viva-docs/md/payment-source-for-isv.txt:101
|
|
34
|
+
*/
|
|
35
|
+
const DEFAULT_SOURCE_CODE = 'Default';
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Module-level singletons (set in init)
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
let _options;
|
|
40
|
+
let _oauth2;
|
|
41
|
+
let _stateMachine;
|
|
42
|
+
/**
|
|
43
|
+
* Build the mode-aware `Payments` client.
|
|
44
|
+
*
|
|
45
|
+
* In merchant mode the URL paths emitted by `Payments` do NOT include the
|
|
46
|
+
* `/isv` segment and do NOT carry `merchantId={uuid}` as a query parameter.
|
|
47
|
+
* The class silently ignores `opts.merchantId` when constructed with
|
|
48
|
+
* `mode: 'merchant'` — adapter call sites may pass either undefined or the
|
|
49
|
+
* configured `legacyMerchantId` and behaviour is identical.
|
|
50
|
+
*
|
|
51
|
+
* @see docs/plans/multi-mode-v0.md §9
|
|
52
|
+
*/
|
|
53
|
+
function buildPaymentsClient(options, oauth2) {
|
|
54
|
+
const client = new IsvHttpClient({
|
|
55
|
+
environment: options.environment,
|
|
56
|
+
authStrategy: oauth2,
|
|
57
|
+
});
|
|
58
|
+
// Legacy Basic-auth client used by `Payments.refundPayment` (Standard refund).
|
|
59
|
+
// Probe-verified 2026-04-25 (F1): POST /checkout/v2/transactions/{id} → 405.
|
|
60
|
+
// Refund must use the legacy host with Basic auth (legacyMerchantId:legacyApiKey).
|
|
61
|
+
// @see references/viva-docs/md/tut-create-recurring-payment.txt:288
|
|
62
|
+
const legacyClient = buildLegacyClient(options);
|
|
63
|
+
return new Payments({
|
|
64
|
+
mode: options.mode,
|
|
65
|
+
client,
|
|
66
|
+
legacyClient,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Build the legacy Basic-auth client — same shape in both modes.
|
|
71
|
+
*
|
|
72
|
+
* `authVariant: 'merchant'` (the default) covers both modes' refund path; the
|
|
73
|
+
* `'reseller'` variant is only needed for IsvSources (POST /api/sources) and
|
|
74
|
+
* the payment handler does not call that endpoint.
|
|
75
|
+
*
|
|
76
|
+
* @see docs/AUTH.md §6.2
|
|
77
|
+
*/
|
|
78
|
+
function buildLegacyClient(options) {
|
|
79
|
+
return new BasicAuthClient({
|
|
80
|
+
environment: options.environment,
|
|
81
|
+
merchantId: options.legacyMerchantId,
|
|
82
|
+
apiKey: options.legacyApiKey,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Build a `FastRefundClient` bound to the OAuth2 acquiring scope.
|
|
87
|
+
*
|
|
88
|
+
* In merchant mode the refund handler uses this client when the resolved
|
|
89
|
+
* strategy is `'fast'`. ISV mode does not use Fast Refund in slice B — kept
|
|
90
|
+
* adjacent so future ISV adoption is one config change away.
|
|
91
|
+
*
|
|
92
|
+
* @see docs/ENDPOINTS.md §4
|
|
93
|
+
*/
|
|
94
|
+
function buildFastRefundClient(options, oauth2) {
|
|
95
|
+
const client = new IsvHttpClient({
|
|
96
|
+
environment: options.environment,
|
|
97
|
+
authStrategy: oauth2,
|
|
98
|
+
});
|
|
99
|
+
return new FastRefundClient({ client });
|
|
100
|
+
}
|
|
101
|
+
function getIsvPayments() {
|
|
102
|
+
if (_isvPaymentsOverride)
|
|
103
|
+
return _isvPaymentsOverride;
|
|
104
|
+
if (!_oauth2 || !_options) {
|
|
105
|
+
throw VivaPluginError.internalError('VivaPaymentMethodHandler not initialised. Did you call init()?');
|
|
106
|
+
}
|
|
107
|
+
return buildPaymentsClient(_options, _oauth2);
|
|
108
|
+
}
|
|
109
|
+
function getFastRefundClient() {
|
|
110
|
+
if (_fastRefundOverride)
|
|
111
|
+
return _fastRefundOverride;
|
|
112
|
+
if (!_oauth2 || !_options) {
|
|
113
|
+
throw VivaPluginError.internalError('VivaPaymentMethodHandler not initialised. Did you call init()?');
|
|
114
|
+
}
|
|
115
|
+
return buildFastRefundClient(_options, _oauth2);
|
|
116
|
+
}
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Helpers
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
/**
|
|
121
|
+
* Resolve the Viva merchantId for the current channel.
|
|
122
|
+
*
|
|
123
|
+
* - ISV mode: reads `resolveMerchantId(ctx)` or `channel.customFields.vivaMerchantId`.
|
|
124
|
+
* - Merchant mode: returns `undefined` — there is no per-channel tenant. The
|
|
125
|
+
* `Payments` client ignores `opts.merchantId` when constructed with
|
|
126
|
+
* `mode: 'merchant'`, so adapter-level callers may still pass through.
|
|
127
|
+
*/
|
|
128
|
+
function resolveMerchantId(options, ctx) {
|
|
129
|
+
if (options.mode !== 'isv')
|
|
130
|
+
return undefined;
|
|
131
|
+
const raw = options.resolveMerchantId
|
|
132
|
+
? options.resolveMerchantId(ctx)
|
|
133
|
+
: ctx.channel.customFields['vivaMerchantId'];
|
|
134
|
+
return raw;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Resolve the Smart Checkout sourceCode.
|
|
138
|
+
*
|
|
139
|
+
* Resolution order (both modes): channel custom field `vivaSourceCode` →
|
|
140
|
+
* mode-specific fallback.
|
|
141
|
+
*
|
|
142
|
+
* - ISV mode: optional `resolveSourceCode(ctx)` override; default `'Default'`.
|
|
143
|
+
* - Merchant mode: `config.sourceCode ?? 'Default'`.
|
|
144
|
+
*
|
|
145
|
+
* `vivaSourceCode` is registered as a channel custom field in both modes per
|
|
146
|
+
* the multi-mode plan — it is the ONE field that retains meaning in merchant
|
|
147
|
+
* mode (operator can override per-channel without code changes).
|
|
148
|
+
*/
|
|
149
|
+
function resolveSourceCode(options, ctx) {
|
|
150
|
+
// Per-channel override applies in both modes — drop in if operator set it.
|
|
151
|
+
const channelOverride = ctx.channel.customFields['vivaSourceCode'];
|
|
152
|
+
if (channelOverride)
|
|
153
|
+
return channelOverride;
|
|
154
|
+
if (options.mode === 'isv') {
|
|
155
|
+
if (options.resolveSourceCode)
|
|
156
|
+
return options.resolveSourceCode(ctx);
|
|
157
|
+
return DEFAULT_SOURCE_CODE;
|
|
158
|
+
}
|
|
159
|
+
// merchant mode
|
|
160
|
+
return options.sourceCode ?? DEFAULT_SOURCE_CODE;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Resolve the ISV platform fee (minor units) for an order.
|
|
164
|
+
*
|
|
165
|
+
* - ISV mode: defaults to 0, override via `resolveIsvAmount`.
|
|
166
|
+
* - Merchant mode: always 0 (no ISV concept — the wire body strips the field
|
|
167
|
+
* entirely in merchant mode, so the returned value is irrelevant beyond the
|
|
168
|
+
* pre-call guard).
|
|
169
|
+
*/
|
|
170
|
+
function resolveIsvAmount(options, order, ctx) {
|
|
171
|
+
if (options.mode !== 'isv')
|
|
172
|
+
return 0;
|
|
173
|
+
if (options.resolveIsvAmount)
|
|
174
|
+
return options.resolveIsvAmount(order, ctx);
|
|
175
|
+
return 0;
|
|
176
|
+
}
|
|
177
|
+
function resolveSuccessUrl(options, ctx) {
|
|
178
|
+
return typeof options.successUrl === 'function' ? options.successUrl(ctx) : options.successUrl;
|
|
179
|
+
}
|
|
180
|
+
function resolveFailureUrl(options, ctx) {
|
|
181
|
+
return typeof options.failureUrl === 'function' ? options.failureUrl(ctx) : options.failureUrl;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Resolve the optional Smart Checkout theme color.
|
|
185
|
+
*
|
|
186
|
+
* - ISV mode: optional `resolveCheckoutColor(ctx)` callback.
|
|
187
|
+
* - Merchant mode: optional `config.checkoutColor` string.
|
|
188
|
+
*/
|
|
189
|
+
function resolveCheckoutColor(options, ctx) {
|
|
190
|
+
if (options.mode === 'isv')
|
|
191
|
+
return options.resolveCheckoutColor?.(ctx);
|
|
192
|
+
return options.checkoutColor;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Stable idempotency key for (channelId, orderId, amountMinor, currencyCode).
|
|
196
|
+
* D11: used as both Idempotency-Key header value and pending-row lookup key.
|
|
197
|
+
* Vendure does NOT expose a real paymentId inside createPayment — orderId is
|
|
198
|
+
* the closest stable identifier available at that point.
|
|
199
|
+
*
|
|
200
|
+
* TODO(impl): If a future Vendure version passes the payment ID into createPayment,
|
|
201
|
+
* switch to (channelId, paymentId) here.
|
|
202
|
+
*/
|
|
203
|
+
function buildIdempotencyKey(channelId, orderId, amountMinor, currencyCode) {
|
|
204
|
+
return `viva:${channelId}:${orderId}:${amountMinor}:${currencyCode}`;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Build the Smart Checkout redirect URL.
|
|
208
|
+
* Demo: https://demo.vivapayments.com/web/checkout?ref={orderCode}
|
|
209
|
+
* Production: https://www.vivapayments.com/web/checkout?ref={orderCode}
|
|
210
|
+
*/
|
|
211
|
+
function buildCheckoutUrl(environment, orderCode, color) {
|
|
212
|
+
const host = environment === 'production' ? 'www.vivapayments.com' : 'demo.vivapayments.com';
|
|
213
|
+
let url = `https://${host}/web/checkout?ref=${orderCode}`;
|
|
214
|
+
if (color) {
|
|
215
|
+
url += `&color=${color.replace(/^#/, '')}`;
|
|
216
|
+
}
|
|
217
|
+
return url;
|
|
218
|
+
}
|
|
219
|
+
/** Map a VivaApiError to VivaPluginError.apiError with conditional fields. */
|
|
220
|
+
function mapApiError(err) {
|
|
221
|
+
const opts = { message: err.message };
|
|
222
|
+
if (err.vivaCode !== undefined)
|
|
223
|
+
opts.vivaErrorCode = Number(err.vivaCode);
|
|
224
|
+
if (err.message)
|
|
225
|
+
opts.vivaErrorMessage = err.message;
|
|
226
|
+
opts.cause = err;
|
|
227
|
+
throw VivaPluginError.apiError(opts);
|
|
228
|
+
}
|
|
229
|
+
/** Returns true when the error indicates a Viva-side 5xx / auth / network failure. */
|
|
230
|
+
function isRetryableVivaError(err) {
|
|
231
|
+
if (err instanceof VivaAuthError)
|
|
232
|
+
return true;
|
|
233
|
+
if (err instanceof VivaApiError && err.httpStatus !== undefined && err.httpStatus >= 500)
|
|
234
|
+
return true;
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// Test injection — allows unit tests to bypass NestJS DI
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
/** @internal Test-only: inject dependencies without NestJS DI. */
|
|
241
|
+
export function _testInjectDeps(opts) {
|
|
242
|
+
_options = opts.options;
|
|
243
|
+
_oauth2 = opts.oauth2;
|
|
244
|
+
_stateMachine = opts.stateMachine;
|
|
245
|
+
// Always reset overrides; only set if explicitly provided.
|
|
246
|
+
_isvPaymentsOverride = opts.isvPayments;
|
|
247
|
+
_fastRefundOverride = opts.fastRefundClient;
|
|
248
|
+
}
|
|
249
|
+
let _isvPaymentsOverride;
|
|
250
|
+
let _fastRefundOverride;
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// PaymentMethodHandler
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
export const vivaPaymentMethodHandler = new PaymentMethodHandler({
|
|
255
|
+
code: 'viva',
|
|
256
|
+
description: [
|
|
257
|
+
{
|
|
258
|
+
languageCode: LanguageCode.en,
|
|
259
|
+
value: 'Viva Wallet — Smart Checkout (ISV)',
|
|
260
|
+
},
|
|
261
|
+
],
|
|
262
|
+
args: {},
|
|
263
|
+
// -------------------------------------------------------------------------
|
|
264
|
+
// init — resolve injected services from NestJS DI
|
|
265
|
+
// -------------------------------------------------------------------------
|
|
266
|
+
init(injector) {
|
|
267
|
+
_options = injector.get(VIVA_PLUGIN_OPTIONS);
|
|
268
|
+
_oauth2 = injector.get(VIVA_OAUTH2_STRATEGY_TOKEN);
|
|
269
|
+
_stateMachine = injector.get(StateMachineService);
|
|
270
|
+
},
|
|
271
|
+
// -------------------------------------------------------------------------
|
|
272
|
+
// createPayment
|
|
273
|
+
// -------------------------------------------------------------------------
|
|
274
|
+
async createPayment(ctx, order, amount, _args, _metadata) {
|
|
275
|
+
const options = _options;
|
|
276
|
+
const stateMachine = _stateMachine;
|
|
277
|
+
// Step 1: resolve merchantId.
|
|
278
|
+
// - ISV mode: required; resolved per channel.
|
|
279
|
+
// - Merchant mode: undefined — `Payments` ignores opts.merchantId when
|
|
280
|
+
// constructed with mode='merchant'. We still record `legacyMerchantId`
|
|
281
|
+
// on the stored row for ops/audit consistency.
|
|
282
|
+
const merchantId = resolveMerchantId(options, ctx);
|
|
283
|
+
if (options.mode === 'isv' && !merchantId) {
|
|
284
|
+
throw VivaPluginError.channelMisconfigured();
|
|
285
|
+
}
|
|
286
|
+
// Step 2: check vivaPayoutsEnabled gate — ISV-only.
|
|
287
|
+
// In merchant mode there's no onboarding flip; this custom field is
|
|
288
|
+
// meaningless and must not gate the payment.
|
|
289
|
+
if (options.mode === 'isv') {
|
|
290
|
+
const payoutsEnabled = ctx.channel.customFields['vivaPayoutsEnabled'];
|
|
291
|
+
if (payoutsEnabled === false) {
|
|
292
|
+
throw VivaPluginError.accountNotVerified();
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
// Step 3: resolve sourceCode (mode-aware — channel override → fallback).
|
|
296
|
+
const sourceCode = resolveSourceCode(options, ctx);
|
|
297
|
+
// Step 4: resolve isvAmount (always 0 in merchant mode).
|
|
298
|
+
const isvAmount = resolveIsvAmount(options, order, ctx);
|
|
299
|
+
// Step 5: isvAmount guard — only meaningful in ISV mode (merchant mode
|
|
300
|
+
// returns 0 unconditionally, so this guard never fires there).
|
|
301
|
+
if (isvAmount >= amount) {
|
|
302
|
+
throw VivaPluginError.isvAmountTooHigh(isvAmount, amount);
|
|
303
|
+
}
|
|
304
|
+
// Step 6: resolve checkout color (optional, mode-aware).
|
|
305
|
+
const color = resolveCheckoutColor(options, ctx);
|
|
306
|
+
// Step 7: resolve redirect URL templates
|
|
307
|
+
const successUrlTemplate = resolveSuccessUrl(options, ctx);
|
|
308
|
+
const failureUrlTemplate = resolveFailureUrl(options, ctx);
|
|
309
|
+
// Step 8+9: idempotency key & INSERT-OR-NOTHING pending row.
|
|
310
|
+
// Vendure does NOT pass a paymentId into createPayment (assigned post-return).
|
|
311
|
+
// D11 fallback: key on (channelId, orderId, amountMinor, currencyCode).
|
|
312
|
+
// orderId is used as the paymentId column proxy value.
|
|
313
|
+
const idempotencyKey = buildIdempotencyKey(ctx.channelId, order.id, amount, order.currencyCode);
|
|
314
|
+
// Row-stored merchant id:
|
|
315
|
+
// ISV mode → resolved per-channel value.
|
|
316
|
+
// Merchant mode → configured `legacyMerchantId` (ops/audit only; the wire
|
|
317
|
+
// call does not include merchantId in merchant mode).
|
|
318
|
+
const storedMerchantId = options.mode === 'isv'
|
|
319
|
+
? merchantId
|
|
320
|
+
: options.legacyMerchantId;
|
|
321
|
+
const { row, wasInserted } = await stateMachine.upsertPendingTransaction(ctx, {
|
|
322
|
+
channelId: ctx.channelId,
|
|
323
|
+
paymentId: order.id, // proxy until real paymentId available post-return
|
|
324
|
+
idempotencyKey,
|
|
325
|
+
amountMinor: BigInt(amount),
|
|
326
|
+
currencyCode: order.currencyCode,
|
|
327
|
+
isvAmountMinor: BigInt(isvAmount),
|
|
328
|
+
});
|
|
329
|
+
// Idempotency: existing row with vivaOrderCode → return cached redirect URL
|
|
330
|
+
if (!wasInserted && row.vivaOrderCode) {
|
|
331
|
+
const existingRedirect = row.metadata['redirectUrl'];
|
|
332
|
+
if (existingRedirect) {
|
|
333
|
+
Logger.info(`[createPayment] Idempotency hit for order ${String(order.id)} — returning cached redirect URL.`, VIVA_LOG_CONTEXT);
|
|
334
|
+
return {
|
|
335
|
+
amount,
|
|
336
|
+
state: 'Created',
|
|
337
|
+
metadata: {
|
|
338
|
+
redirectUrl: existingRedirect,
|
|
339
|
+
vivaOrderCode: row.vivaOrderCode,
|
|
340
|
+
vivaMerchantId: storedMerchantId,
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
// Step 10: call Viva createOrder.
|
|
346
|
+
// - ISV mode → POST /checkout/v2/isv/orders?merchantId={uuid} with isvAmount.
|
|
347
|
+
// - Merchant mode → POST /checkout/v2/orders (no merchantId query, no isvAmount in body).
|
|
348
|
+
// The mode branch is entirely inside the `Payments` class — adapter passes
|
|
349
|
+
// `merchantId` either way; merchant-mode Payments silently drops it.
|
|
350
|
+
const isvPayments = getIsvPayments();
|
|
351
|
+
const currencyCode = alphaToNumeric(order.currencyCode);
|
|
352
|
+
const callStart = Date.now();
|
|
353
|
+
let orderCode;
|
|
354
|
+
try {
|
|
355
|
+
const createOpts = {
|
|
356
|
+
idempotencyKey,
|
|
357
|
+
...(merchantId !== undefined ? { merchantId } : {}),
|
|
358
|
+
};
|
|
359
|
+
const response = await isvPayments.createOrder({
|
|
360
|
+
amount: BigInt(amount),
|
|
361
|
+
currencyCode,
|
|
362
|
+
sourceCode,
|
|
363
|
+
// TODO(impl): confirm Viva ISV fee field name for isvAmount.
|
|
364
|
+
// The ISV platform fee (isvAmount) field name is unconfirmed from local docs.
|
|
365
|
+
// Current assumption: Viva does not accept it in the createOrder body;
|
|
366
|
+
// it is configured per-source in Viva Self Care instead.
|
|
367
|
+
// @see references/viva-docs/md/isv-partner-program.txt:104
|
|
368
|
+
}, createOpts);
|
|
369
|
+
orderCode = response.orderCode;
|
|
370
|
+
}
|
|
371
|
+
catch (err) {
|
|
372
|
+
if (isRetryableVivaError(err)) {
|
|
373
|
+
throw VivaPluginError.authDown(err instanceof Error ? err.message : String(err), err);
|
|
374
|
+
}
|
|
375
|
+
if (err instanceof VivaApiError) {
|
|
376
|
+
mapApiError(err);
|
|
377
|
+
}
|
|
378
|
+
throw err;
|
|
379
|
+
}
|
|
380
|
+
const elapsed = Date.now() - callStart;
|
|
381
|
+
if (elapsed > VIVA_LATENCY_WARN_MS) {
|
|
382
|
+
Logger.warn(`[createPayment] Viva createOrder took ${elapsed}ms (budget: ${VIVA_LATENCY_WARN_MS}ms) for order ${String(order.id)}.`, VIVA_LOG_CONTEXT);
|
|
383
|
+
}
|
|
384
|
+
// Step 12: construct redirect URL
|
|
385
|
+
const orderCodeStr = orderCode.toString();
|
|
386
|
+
const redirectUrl = buildCheckoutUrl(options.environment, orderCode, color);
|
|
387
|
+
// Step 13: substitute {orderCode} in success/failure URLs and store on row metadata
|
|
388
|
+
const successUrl = substitute(successUrlTemplate, { orderCode: orderCodeStr });
|
|
389
|
+
const failureUrl = substitute(failureUrlTemplate, { orderCode: orderCodeStr });
|
|
390
|
+
const rowMetadata = {
|
|
391
|
+
idempotencyKey,
|
|
392
|
+
redirectUrl,
|
|
393
|
+
successUrl,
|
|
394
|
+
failureUrl,
|
|
395
|
+
vivaMerchantId: storedMerchantId,
|
|
396
|
+
};
|
|
397
|
+
await stateMachine.setOrderCode(ctx, row.id, orderCodeStr, rowMetadata);
|
|
398
|
+
// Step 14: return Created state + redirectUrl in metadata
|
|
399
|
+
return {
|
|
400
|
+
amount,
|
|
401
|
+
state: 'Created',
|
|
402
|
+
metadata: {
|
|
403
|
+
redirectUrl,
|
|
404
|
+
vivaOrderCode: orderCodeStr,
|
|
405
|
+
vivaMerchantId: storedMerchantId,
|
|
406
|
+
public: {
|
|
407
|
+
redirectUrl,
|
|
408
|
+
},
|
|
409
|
+
},
|
|
410
|
+
};
|
|
411
|
+
},
|
|
412
|
+
// -------------------------------------------------------------------------
|
|
413
|
+
// settlePayment — invoked by webhook worker job (V7), NOT by storefront.
|
|
414
|
+
//
|
|
415
|
+
// Mode-agnostic: pure DB mutation (mark viva_transaction row 'captured').
|
|
416
|
+
// The webhook worker has already validated with Viva via retrieveTransaction
|
|
417
|
+
// before invoking the state machine, so there is no Viva API call here in
|
|
418
|
+
// either mode.
|
|
419
|
+
// -------------------------------------------------------------------------
|
|
420
|
+
async settlePayment(ctx, _order, payment, _args) {
|
|
421
|
+
const stateMachine = _stateMachine;
|
|
422
|
+
// Idempotent: already settled → no-op
|
|
423
|
+
if (payment.state === 'Settled') {
|
|
424
|
+
return { success: true };
|
|
425
|
+
}
|
|
426
|
+
const row = await stateMachine.getVivaTransaction(ctx, ctx.channelId, payment.id);
|
|
427
|
+
if (row) {
|
|
428
|
+
const existingMeta = row.metadata;
|
|
429
|
+
await stateMachine.setStatus(ctx, row.id, 'captured', {
|
|
430
|
+
...existingMeta,
|
|
431
|
+
settledAt: new Date().toISOString(),
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
return {
|
|
435
|
+
success: true,
|
|
436
|
+
metadata: { settledAt: new Date().toISOString() },
|
|
437
|
+
};
|
|
438
|
+
},
|
|
439
|
+
// -------------------------------------------------------------------------
|
|
440
|
+
// cancelPayment — invoked by Shop API mutation on ?paymentCancelled=1 (V8)
|
|
441
|
+
// -------------------------------------------------------------------------
|
|
442
|
+
async cancelPayment(ctx, _order, payment, _args) {
|
|
443
|
+
const options = _options;
|
|
444
|
+
const stateMachine = _stateMachine;
|
|
445
|
+
// Step 1: load transaction row
|
|
446
|
+
const row = await stateMachine.getVivaTransaction(ctx, ctx.channelId, payment.id);
|
|
447
|
+
// Step 2: missing row or no orderCode
|
|
448
|
+
if (!row || !row.vivaOrderCode) {
|
|
449
|
+
throw VivaPluginError.paymentNotCancellable('No Viva order code found — payment may not have been initiated.');
|
|
450
|
+
}
|
|
451
|
+
// Step 3: already terminal
|
|
452
|
+
if (TERMINAL_STATUSES.has(row.status)) {
|
|
453
|
+
throw VivaPluginError.paymentNotCancellable(`Payment is already in terminal state: ${row.status}.`);
|
|
454
|
+
}
|
|
455
|
+
// Step 4: resolve merchantId (ISV mode only — fallback to metadata-stored
|
|
456
|
+
// value for robustness when the channel custom field was unset after the
|
|
457
|
+
// row was created). Merchant mode passes undefined; `Payments` silently
|
|
458
|
+
// drops it from the DELETE URL.
|
|
459
|
+
let merchantId;
|
|
460
|
+
if (options.mode === 'isv') {
|
|
461
|
+
merchantId =
|
|
462
|
+
(resolveMerchantId(options, ctx) ??
|
|
463
|
+
row.metadata['vivaMerchantId']);
|
|
464
|
+
if (!merchantId) {
|
|
465
|
+
throw VivaPluginError.channelMisconfigured();
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
// Step 5: call Viva cancelOrder.
|
|
469
|
+
// - ISV mode: DELETE /checkout/v2/orders/{oc}?merchantId={uuid}.
|
|
470
|
+
// - Merchant mode: DELETE /checkout/v2/orders/{oc}.
|
|
471
|
+
const isvPayments = getIsvPayments();
|
|
472
|
+
const cancelOpts = merchantId !== undefined ? { merchantId } : {};
|
|
473
|
+
try {
|
|
474
|
+
await isvPayments.cancelOrder(BigInt(row.vivaOrderCode), cancelOpts);
|
|
475
|
+
}
|
|
476
|
+
catch (err) {
|
|
477
|
+
if (isRetryableVivaError(err)) {
|
|
478
|
+
throw VivaPluginError.authDown(err instanceof Error ? err.message : String(err), err);
|
|
479
|
+
}
|
|
480
|
+
if (err instanceof VivaApiError) {
|
|
481
|
+
mapApiError(err);
|
|
482
|
+
}
|
|
483
|
+
throw err;
|
|
484
|
+
}
|
|
485
|
+
// Step 6: update row status
|
|
486
|
+
const existingMeta = row.metadata;
|
|
487
|
+
await stateMachine.setStatus(ctx, row.id, 'cancelled', {
|
|
488
|
+
...existingMeta,
|
|
489
|
+
cancelledAt: new Date().toISOString(),
|
|
490
|
+
});
|
|
491
|
+
return { success: true };
|
|
492
|
+
},
|
|
493
|
+
// -------------------------------------------------------------------------
|
|
494
|
+
// createRefund — invoked by Vendure admin refund flow
|
|
495
|
+
// -------------------------------------------------------------------------
|
|
496
|
+
async createRefund(ctx, input, amount, _order, payment, _args) {
|
|
497
|
+
const options = _options;
|
|
498
|
+
const stateMachine = _stateMachine;
|
|
499
|
+
// Step 1: load transaction row
|
|
500
|
+
const row = await stateMachine.getVivaTransaction(ctx, ctx.channelId, payment.id);
|
|
501
|
+
if (!row) {
|
|
502
|
+
throw VivaPluginError.refundRejected('VivaTransaction row not found for this payment.');
|
|
503
|
+
}
|
|
504
|
+
// Step 2: status must be captured
|
|
505
|
+
if (row.status !== 'captured') {
|
|
506
|
+
throw VivaPluginError.refundRejected(`Payment not yet captured (status: ${row.status}). Refund requires captured status.`);
|
|
507
|
+
}
|
|
508
|
+
// Step 3: need vivaTransactionId (populated by webhook worker V7)
|
|
509
|
+
if (!row.vivaTransactionId) {
|
|
510
|
+
throw VivaPluginError.refundRejected('Viva transaction ID not yet known — webhook may be pending.');
|
|
511
|
+
}
|
|
512
|
+
// Step 4: determine amountMinor (omit for full refund per SDK contract)
|
|
513
|
+
const isFullRefund = input.amount === payment.amount;
|
|
514
|
+
const amountMinor = isFullRefund ? undefined : BigInt(amount);
|
|
515
|
+
// Step 5: resolve merchantId for Standard refund leg.
|
|
516
|
+
// - ISV mode: required (resolved from channel + row metadata fallback).
|
|
517
|
+
// - Merchant mode: pass the configured legacyMerchantId. The Payments
|
|
518
|
+
// method requires a value at the type level but does NOT encode it in
|
|
519
|
+
// the URL — the legacy refund endpoint authenticates via Basic auth on
|
|
520
|
+
// the host, not via a path/query param.
|
|
521
|
+
let merchantId;
|
|
522
|
+
if (options.mode === 'isv') {
|
|
523
|
+
const resolved = (resolveMerchantId(options, ctx) ??
|
|
524
|
+
row.metadata['vivaMerchantId']);
|
|
525
|
+
if (!resolved) {
|
|
526
|
+
throw VivaPluginError.channelMisconfigured();
|
|
527
|
+
}
|
|
528
|
+
merchantId = resolved;
|
|
529
|
+
}
|
|
530
|
+
else {
|
|
531
|
+
merchantId = options.legacyMerchantId;
|
|
532
|
+
}
|
|
533
|
+
const refundIdempotencyKey = `viva:refund:${String(row.id)}:${amount}`;
|
|
534
|
+
// Step 6: pre-check legacy creds. Refunds in both modes ultimately depend
|
|
535
|
+
// on the legacy host (Standard refund directly; Fast Refund falls back to
|
|
536
|
+
// Standard on 403 when strategy==='auto'). Without these creds the refund
|
|
537
|
+
// cannot proceed.
|
|
538
|
+
// @see references/viva-docs/md/tut-create-recurring-payment.txt:288
|
|
539
|
+
if (!options.legacyMerchantId || !options.legacyApiKey) {
|
|
540
|
+
throw VivaPluginError.refundRejected('Viva refund requires legacyMerchantId and legacyApiKey in plugin options. ' +
|
|
541
|
+
'Probe-verified 2026-04-25: POST /checkout/v2/transactions/{id} returns 405. ' +
|
|
542
|
+
'Only the legacy host with Basic auth works for Standard refund.');
|
|
543
|
+
}
|
|
544
|
+
const isvPayments = getIsvPayments();
|
|
545
|
+
// Step 7: branch refund path on mode.
|
|
546
|
+
// ISV mode → Standard refund only (Payments.refundPayment).
|
|
547
|
+
// Merchant mode → resolveRefundStrategy + FastRefundClient, with
|
|
548
|
+
// auto-fallback to Standard on HTTP 403 (auto only).
|
|
549
|
+
let refundResponse;
|
|
550
|
+
if (options.mode === 'isv') {
|
|
551
|
+
refundResponse = await callStandardRefund(isvPayments, row.vivaTransactionId, merchantId, amountMinor, refundIdempotencyKey);
|
|
552
|
+
}
|
|
553
|
+
else {
|
|
554
|
+
refundResponse = await refundMerchantMode({
|
|
555
|
+
options,
|
|
556
|
+
isvPayments,
|
|
557
|
+
fastRefundClient: getFastRefundClient(),
|
|
558
|
+
vivaTransactionId: row.vivaTransactionId,
|
|
559
|
+
amountMinor,
|
|
560
|
+
fullAmountMinor: BigInt(payment.amount),
|
|
561
|
+
isFullRefund,
|
|
562
|
+
refundIdempotencyKey,
|
|
563
|
+
rowMerchantId: merchantId,
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
// Step 8: update row status
|
|
567
|
+
const newStatus = isFullRefund ? 'refunded' : 'partially_refunded';
|
|
568
|
+
const existingMeta = row.metadata;
|
|
569
|
+
const refundedSoFar = existingMeta['refundedAmountMinor'] ?? 0;
|
|
570
|
+
await stateMachine.setStatus(ctx, row.id, newStatus, {
|
|
571
|
+
...existingMeta,
|
|
572
|
+
refundedAmountMinor: refundedSoFar + amount,
|
|
573
|
+
lastRefundTransactionId: refundResponse.transactionId,
|
|
574
|
+
lastRefundAt: new Date().toISOString(),
|
|
575
|
+
});
|
|
576
|
+
// Step 9: return per Vendure refund contract
|
|
577
|
+
return {
|
|
578
|
+
state: 'Settled',
|
|
579
|
+
metadata: {
|
|
580
|
+
vivaRefundResponse: { transactionId: refundResponse.transactionId },
|
|
581
|
+
},
|
|
582
|
+
};
|
|
583
|
+
},
|
|
584
|
+
});
|
|
585
|
+
// ---------------------------------------------------------------------------
|
|
586
|
+
// Refund helpers (file-level — exported only via the handler)
|
|
587
|
+
// ---------------------------------------------------------------------------
|
|
588
|
+
/**
|
|
589
|
+
* Call the Standard (legacy/Basic-auth) refund path via `Payments.refundPayment`.
|
|
590
|
+
*
|
|
591
|
+
* Wraps the legacy refund call with the adapter-level error envelope:
|
|
592
|
+
* - 5xx / auth → VIVA_AUTH_DOWN (retryable=true).
|
|
593
|
+
* - 4xx → VIVA_REFUND_REJECTED (retryable=false).
|
|
594
|
+
*
|
|
595
|
+
* @see references/viva-docs/md/tut-create-recurring-payment.txt:288
|
|
596
|
+
*/
|
|
597
|
+
async function callStandardRefund(isvPayments, vivaTransactionId, merchantId, amountMinor, refundIdempotencyKey) {
|
|
598
|
+
try {
|
|
599
|
+
const opts = {
|
|
600
|
+
merchantId,
|
|
601
|
+
idempotencyKey: refundIdempotencyKey,
|
|
602
|
+
};
|
|
603
|
+
if (amountMinor !== undefined)
|
|
604
|
+
opts.amountMinor = amountMinor;
|
|
605
|
+
const refundResponse = await isvPayments.refundPayment(vivaTransactionId, opts);
|
|
606
|
+
return { transactionId: refundResponse.transactionId };
|
|
607
|
+
}
|
|
608
|
+
catch (err) {
|
|
609
|
+
if (isRetryableVivaError(err)) {
|
|
610
|
+
throw VivaPluginError.authDown(err instanceof Error ? err.message : String(err), err);
|
|
611
|
+
}
|
|
612
|
+
if (err instanceof VivaApiError) {
|
|
613
|
+
throw VivaPluginError.refundRejected(err.message, err);
|
|
614
|
+
}
|
|
615
|
+
throw err;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Merchant-mode refund routing — Fast vs Standard with auto-fallback.
|
|
620
|
+
*
|
|
621
|
+
* Flow:
|
|
622
|
+
* 1. retrieveTransaction → cardType (drives strategy decision).
|
|
623
|
+
* 2. resolveRefundStrategy(config.refundStrategy ?? 'auto', { cardType, CNP: true }).
|
|
624
|
+
* Smart Checkout is always card-not-present (CNP).
|
|
625
|
+
* 3. decision.kind === 'fast' → FastRefundClient.refund:
|
|
626
|
+
* - 403 + strategy === 'fast' → VIVA_REFUND_REJECTED with the
|
|
627
|
+
* "fast does not fall back" message (mirrors medusa slice B).
|
|
628
|
+
* - 403 + strategy === 'auto' → fall through to Standard refund.
|
|
629
|
+
* - any other error → wrap via the standard error envelope.
|
|
630
|
+
* 4. decision.kind === 'standard' → callStandardRefund.
|
|
631
|
+
*
|
|
632
|
+
* @see docs/ENDPOINTS.md §4
|
|
633
|
+
* @see docs/plans/multi-mode-v0.md §8.5a
|
|
634
|
+
* @see references/payment-api.yaml:9255 (Fast Refund 403 semantics)
|
|
635
|
+
*/
|
|
636
|
+
async function refundMerchantMode(params) {
|
|
637
|
+
const { options, isvPayments, fastRefundClient, vivaTransactionId, amountMinor, fullAmountMinor, isFullRefund, refundIdempotencyKey, rowMerchantId, } = params;
|
|
638
|
+
const configuredStrategy = options.refundStrategy ?? 'auto';
|
|
639
|
+
// Step 1: look up cardType. Failure → log + proceed; strategy falls through
|
|
640
|
+
// to the `auto-no-card-info` branch (which routes to Standard).
|
|
641
|
+
let cardType;
|
|
642
|
+
try {
|
|
643
|
+
const tx = await isvPayments.retrieveTransaction(vivaTransactionId);
|
|
644
|
+
cardType = tx.cardType;
|
|
645
|
+
}
|
|
646
|
+
catch (err) {
|
|
647
|
+
Logger.warn(`[createRefund] retrieveTransaction failed for '${vivaTransactionId}': ` +
|
|
648
|
+
`${err instanceof Error ? err.message : String(err)}. ` +
|
|
649
|
+
`Refund strategy will route via 'auto-no-card-info' (Standard).`, VIVA_LOG_CONTEXT);
|
|
650
|
+
}
|
|
651
|
+
// Step 2: resolve strategy.
|
|
652
|
+
const decision = resolveRefundStrategy(configuredStrategy, {
|
|
653
|
+
...(cardType !== undefined ? { cardType } : {}),
|
|
654
|
+
isCardNotPresent: true, // Smart Checkout — always CNP
|
|
655
|
+
});
|
|
656
|
+
if (decision.kind === 'fast') {
|
|
657
|
+
// Fast Refund requires an explicit amount (no full-refund-by-omission).
|
|
658
|
+
const fastAmount = isFullRefund ? fullAmountMinor : amountMinor;
|
|
659
|
+
try {
|
|
660
|
+
const result = await fastRefundClient.refund({
|
|
661
|
+
transactionId: vivaTransactionId,
|
|
662
|
+
amount: fastAmount,
|
|
663
|
+
sourceCode: options.sourceCode ?? DEFAULT_SOURCE_CODE,
|
|
664
|
+
merchantTrns: refundIdempotencyKey,
|
|
665
|
+
idempotencyKey: refundIdempotencyKey,
|
|
666
|
+
});
|
|
667
|
+
return { transactionId: result.transactionId };
|
|
668
|
+
}
|
|
669
|
+
catch (err) {
|
|
670
|
+
const is403 = err instanceof VivaApiError && err.httpStatus === 403;
|
|
671
|
+
if (is403 && configuredStrategy === 'fast') {
|
|
672
|
+
// Explicit `fast` opt-in does not fall back — surface as a distinct
|
|
673
|
+
// code so callers can prompt operators to switch to 'auto' instead of
|
|
674
|
+
// treating it as a generic refund rejection.
|
|
675
|
+
throw VivaPluginError.fastRefundIneligible(undefined, err);
|
|
676
|
+
}
|
|
677
|
+
if (!is403) {
|
|
678
|
+
if (isRetryableVivaError(err)) {
|
|
679
|
+
throw VivaPluginError.authDown(err instanceof Error ? err.message : String(err), err);
|
|
680
|
+
}
|
|
681
|
+
if (err instanceof VivaApiError) {
|
|
682
|
+
throw VivaPluginError.refundRejected(err.message, err);
|
|
683
|
+
}
|
|
684
|
+
throw err;
|
|
685
|
+
}
|
|
686
|
+
// is403 + auto → fall through to Standard refund.
|
|
687
|
+
Logger.info(`[createRefund] Fast Refund 403 with strategy='auto' — falling back to Standard refund for ${vivaTransactionId}.`, VIVA_LOG_CONTEXT);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
// Standard refund — Payments.refundPayment routes via legacy/Basic auth.
|
|
691
|
+
return callStandardRefund(isvPayments, vivaTransactionId, rowMerchantId, amountMinor, refundIdempotencyKey);
|
|
692
|
+
}
|
|
693
|
+
//# sourceMappingURL=payment-method-handler.js.map
|