@sakeetech/vendure-payment-viva 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +952 -0
  3. package/dist/api/admin-internal.controller.d.ts +59 -0
  4. package/dist/api/admin-internal.controller.d.ts.map +1 -0
  5. package/dist/api/admin-internal.controller.js +229 -0
  6. package/dist/api/admin-internal.controller.js.map +1 -0
  7. package/dist/api/admin-onboarding.controller.d.ts +72 -0
  8. package/dist/api/admin-onboarding.controller.d.ts.map +1 -0
  9. package/dist/api/admin-onboarding.controller.js +496 -0
  10. package/dist/api/admin-onboarding.controller.js.map +1 -0
  11. package/dist/api/admin-sources.controller.d.ts +50 -0
  12. package/dist/api/admin-sources.controller.d.ts.map +1 -0
  13. package/dist/api/admin-sources.controller.js +283 -0
  14. package/dist/api/admin-sources.controller.js.map +1 -0
  15. package/dist/api/shop-api.extension.d.ts +15 -0
  16. package/dist/api/shop-api.extension.d.ts.map +1 -0
  17. package/dist/api/shop-api.extension.js +35 -0
  18. package/dist/api/shop-api.extension.js.map +1 -0
  19. package/dist/api/shop-api.resolver.d.ts +42 -0
  20. package/dist/api/shop-api.resolver.d.ts.map +1 -0
  21. package/dist/api/shop-api.resolver.js +256 -0
  22. package/dist/api/shop-api.resolver.js.map +1 -0
  23. package/dist/api/webhook.controller.d.ts +58 -0
  24. package/dist/api/webhook.controller.d.ts.map +1 -0
  25. package/dist/api/webhook.controller.js +204 -0
  26. package/dist/api/webhook.controller.js.map +1 -0
  27. package/dist/cli/bin.d.ts +28 -0
  28. package/dist/cli/bin.d.ts.map +1 -0
  29. package/dist/cli/bin.js +104 -0
  30. package/dist/cli/bin.js.map +1 -0
  31. package/dist/cli/plan.d.ts +41 -0
  32. package/dist/cli/plan.d.ts.map +1 -0
  33. package/dist/cli/plan.js +115 -0
  34. package/dist/cli/plan.js.map +1 -0
  35. package/dist/cli/register-webhooks.d.ts +45 -0
  36. package/dist/cli/register-webhooks.d.ts.map +1 -0
  37. package/dist/cli/register-webhooks.js +400 -0
  38. package/dist/cli/register-webhooks.js.map +1 -0
  39. package/dist/cli/types.d.ts +75 -0
  40. package/dist/cli/types.d.ts.map +1 -0
  41. package/dist/cli/types.js +10 -0
  42. package/dist/cli/types.js.map +1 -0
  43. package/dist/constants.d.ts +35 -0
  44. package/dist/constants.d.ts.map +1 -0
  45. package/dist/constants.js +40 -0
  46. package/dist/constants.js.map +1 -0
  47. package/dist/entities/index.d.ts +4 -0
  48. package/dist/entities/index.d.ts.map +1 -0
  49. package/dist/entities/index.js +3 -0
  50. package/dist/entities/index.js.map +1 -0
  51. package/dist/entities/viva-transaction.entity.d.ts +70 -0
  52. package/dist/entities/viva-transaction.entity.d.ts.map +1 -0
  53. package/dist/entities/viva-transaction.entity.js +133 -0
  54. package/dist/entities/viva-transaction.entity.js.map +1 -0
  55. package/dist/entities/viva-webhook-event.entity.d.ts +71 -0
  56. package/dist/entities/viva-webhook-event.entity.d.ts.map +1 -0
  57. package/dist/entities/viva-webhook-event.entity.js +138 -0
  58. package/dist/entities/viva-webhook-event.entity.js.map +1 -0
  59. package/dist/index.d.ts +27 -0
  60. package/dist/index.d.ts.map +1 -0
  61. package/dist/index.js +23 -0
  62. package/dist/index.js.map +1 -0
  63. package/dist/jobs/process-viva-webhook.handler.d.ts +95 -0
  64. package/dist/jobs/process-viva-webhook.handler.d.ts.map +1 -0
  65. package/dist/jobs/process-viva-webhook.handler.js +530 -0
  66. package/dist/jobs/process-viva-webhook.handler.js.map +1 -0
  67. package/dist/jobs/queue-names.d.ts +18 -0
  68. package/dist/jobs/queue-names.d.ts.map +1 -0
  69. package/dist/jobs/queue-names.js +19 -0
  70. package/dist/jobs/queue-names.js.map +1 -0
  71. package/dist/jobs/retention-cleanup.handler.d.ts +31 -0
  72. package/dist/jobs/retention-cleanup.handler.d.ts.map +1 -0
  73. package/dist/jobs/retention-cleanup.handler.js +94 -0
  74. package/dist/jobs/retention-cleanup.handler.js.map +1 -0
  75. package/dist/loaders/bootstrap.d.ts +28 -0
  76. package/dist/loaders/bootstrap.d.ts.map +1 -0
  77. package/dist/loaders/bootstrap.js +90 -0
  78. package/dist/loaders/bootstrap.js.map +1 -0
  79. package/dist/migrations/1714000000000-create-viva-tables.d.ts +22 -0
  80. package/dist/migrations/1714000000000-create-viva-tables.d.ts.map +1 -0
  81. package/dist/migrations/1714000000000-create-viva-tables.js +105 -0
  82. package/dist/migrations/1714000000000-create-viva-tables.js.map +1 -0
  83. package/dist/observability/metrics-state.service.d.ts +43 -0
  84. package/dist/observability/metrics-state.service.d.ts.map +1 -0
  85. package/dist/observability/metrics-state.service.js +207 -0
  86. package/dist/observability/metrics-state.service.js.map +1 -0
  87. package/dist/payment-method-handler.d.ts +26 -0
  88. package/dist/payment-method-handler.d.ts.map +1 -0
  89. package/dist/payment-method-handler.js +693 -0
  90. package/dist/payment-method-handler.js.map +1 -0
  91. package/dist/plugin.d.ts +95 -0
  92. package/dist/plugin.d.ts.map +1 -0
  93. package/dist/plugin.js +241 -0
  94. package/dist/plugin.js.map +1 -0
  95. package/dist/providers/viva-oauth2-strategy.provider.d.ts +41 -0
  96. package/dist/providers/viva-oauth2-strategy.provider.d.ts.map +1 -0
  97. package/dist/providers/viva-oauth2-strategy.provider.js +60 -0
  98. package/dist/providers/viva-oauth2-strategy.provider.js.map +1 -0
  99. package/dist/services/connected-accounts.service.d.ts +53 -0
  100. package/dist/services/connected-accounts.service.d.ts.map +1 -0
  101. package/dist/services/connected-accounts.service.js +108 -0
  102. package/dist/services/connected-accounts.service.js.map +1 -0
  103. package/dist/services/per-merchant-semaphore.service.d.ts +49 -0
  104. package/dist/services/per-merchant-semaphore.service.d.ts.map +1 -0
  105. package/dist/services/per-merchant-semaphore.service.js +156 -0
  106. package/dist/services/per-merchant-semaphore.service.js.map +1 -0
  107. package/dist/services/state-machine.service.d.ts +100 -0
  108. package/dist/services/state-machine.service.d.ts.map +1 -0
  109. package/dist/services/state-machine.service.js +233 -0
  110. package/dist/services/state-machine.service.js.map +1 -0
  111. package/dist/types.d.ts +286 -0
  112. package/dist/types.d.ts.map +1 -0
  113. package/dist/types.js +23 -0
  114. package/dist/types.js.map +1 -0
  115. package/dist/util/currency.d.ts +32 -0
  116. package/dist/util/currency.d.ts.map +1 -0
  117. package/dist/util/currency.js +90 -0
  118. package/dist/util/currency.js.map +1 -0
  119. package/dist/util/error-envelope.d.ts +51 -0
  120. package/dist/util/error-envelope.d.ts.map +1 -0
  121. package/dist/util/error-envelope.js +157 -0
  122. package/dist/util/error-envelope.js.map +1 -0
  123. package/dist/util/ip-allowlist.d.ts +44 -0
  124. package/dist/util/ip-allowlist.d.ts.map +1 -0
  125. package/dist/util/ip-allowlist.js +139 -0
  126. package/dist/util/ip-allowlist.js.map +1 -0
  127. package/dist/util/normalize-options.d.ts +24 -0
  128. package/dist/util/normalize-options.d.ts.map +1 -0
  129. package/dist/util/normalize-options.js +189 -0
  130. package/dist/util/normalize-options.js.map +1 -0
  131. package/dist/util/url-template.d.ts +18 -0
  132. package/dist/util/url-template.d.ts.map +1 -0
  133. package/dist/util/url-template.js +22 -0
  134. package/dist/util/url-template.js.map +1 -0
  135. package/package.json +75 -0
@@ -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