@sakeetech/medusa-payment-viva 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +816 -0
- package/dist/api/index.d.ts +15 -0
- package/dist/api/index.d.ts.map +1 -0
- package/dist/api/index.js +22 -0
- package/dist/api/index.js.map +1 -0
- package/dist/api/middlewares.d.ts +27 -0
- package/dist/api/middlewares.d.ts.map +1 -0
- package/dist/api/middlewares.js +62 -0
- package/dist/api/middlewares.js.map +1 -0
- package/dist/api/viva/admin/_admin-auth.d.ts +26 -0
- package/dist/api/viva/admin/_admin-auth.d.ts.map +1 -0
- package/dist/api/viva/admin/_admin-auth.js +49 -0
- package/dist/api/viva/admin/_admin-auth.js.map +1 -0
- package/dist/api/viva/admin/_mode-gate.d.ts +28 -0
- package/dist/api/viva/admin/_mode-gate.d.ts.map +1 -0
- package/dist/api/viva/admin/_mode-gate.js +45 -0
- package/dist/api/viva/admin/_mode-gate.js.map +1 -0
- package/dist/api/viva/admin/connected-accounts/[id]/reconcile/route.d.ts +21 -0
- package/dist/api/viva/admin/connected-accounts/[id]/reconcile/route.d.ts.map +1 -0
- package/dist/api/viva/admin/connected-accounts/[id]/reconcile/route.js +93 -0
- package/dist/api/viva/admin/connected-accounts/[id]/reconcile/route.js.map +1 -0
- package/dist/api/viva/admin/connected-accounts/[id]/route.d.ts +18 -0
- package/dist/api/viva/admin/connected-accounts/[id]/route.d.ts.map +1 -0
- package/dist/api/viva/admin/connected-accounts/[id]/route.js +59 -0
- package/dist/api/viva/admin/connected-accounts/[id]/route.js.map +1 -0
- package/dist/api/viva/admin/connected-accounts/[id]/sources/route.d.ts +34 -0
- package/dist/api/viva/admin/connected-accounts/[id]/sources/route.d.ts.map +1 -0
- package/dist/api/viva/admin/connected-accounts/[id]/sources/route.js +234 -0
- package/dist/api/viva/admin/connected-accounts/[id]/sources/route.js.map +1 -0
- package/dist/api/viva/admin/connected-accounts/route.d.ts +19 -0
- package/dist/api/viva/admin/connected-accounts/route.d.ts.map +1 -0
- package/dist/api/viva/admin/connected-accounts/route.js +78 -0
- package/dist/api/viva/admin/connected-accounts/route.js.map +1 -0
- package/dist/api/viva/internal/auth-status/route.d.ts +19 -0
- package/dist/api/viva/internal/auth-status/route.d.ts.map +1 -0
- package/dist/api/viva/internal/auth-status/route.js +91 -0
- package/dist/api/viva/internal/auth-status/route.js.map +1 -0
- package/dist/api/viva/internal/metrics/route.d.ts +13 -0
- package/dist/api/viva/internal/metrics/route.d.ts.map +1 -0
- package/dist/api/viva/internal/metrics/route.js +48 -0
- package/dist/api/viva/internal/metrics/route.js.map +1 -0
- package/dist/api/viva/webhook/health/route.d.ts +16 -0
- package/dist/api/viva/webhook/health/route.d.ts.map +1 -0
- package/dist/api/viva/webhook/health/route.js +27 -0
- package/dist/api/viva/webhook/health/route.js.map +1 -0
- package/dist/api/viva/webhook/route.d.ts +57 -0
- package/dist/api/viva/webhook/route.d.ts.map +1 -0
- package/dist/api/viva/webhook/route.js +269 -0
- package/dist/api/viva/webhook/route.js.map +1 -0
- package/dist/cli/bin.d.ts +12 -0
- package/dist/cli/bin.d.ts.map +1 -0
- package/dist/cli/bin.js +78 -0
- package/dist/cli/bin.js.map +1 -0
- package/dist/cli/index.d.ts +12 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +14 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/plan.d.ts +51 -0
- package/dist/cli/plan.d.ts.map +1 -0
- package/dist/cli/plan.js +128 -0
- package/dist/cli/plan.js.map +1 -0
- package/dist/cli/register-webhooks.d.ts +54 -0
- package/dist/cli/register-webhooks.d.ts.map +1 -0
- package/dist/cli/register-webhooks.js +366 -0
- package/dist/cli/register-webhooks.js.map +1 -0
- package/dist/cli/types.d.ts +62 -0
- package/dist/cli/types.d.ts.map +1 -0
- package/dist/cli/types.js +12 -0
- package/dist/cli/types.js.map +1 -0
- package/dist/config.d.ts +158 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +236 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/loaders/viva-oauth2-strategy.d.ts +26 -0
- package/dist/loaders/viva-oauth2-strategy.d.ts.map +1 -0
- package/dist/loaders/viva-oauth2-strategy.js +58 -0
- package/dist/loaders/viva-oauth2-strategy.js.map +1 -0
- package/dist/migrations/Migration_20260425000001_init_viva_payments.d.ts +19 -0
- package/dist/migrations/Migration_20260425000001_init_viva_payments.d.ts.map +1 -0
- package/dist/migrations/Migration_20260425000001_init_viva_payments.js +136 -0
- package/dist/migrations/Migration_20260425000001_init_viva_payments.js.map +1 -0
- package/dist/migrations/Migration_20260425000002_allow_null_order_code.d.ts +31 -0
- package/dist/migrations/Migration_20260425000002_allow_null_order_code.d.ts.map +1 -0
- package/dist/migrations/Migration_20260425000002_allow_null_order_code.js +71 -0
- package/dist/migrations/Migration_20260425000002_allow_null_order_code.js.map +1 -0
- package/dist/migrations/Migration_20260425000003_webhook_retry_count.d.ts +18 -0
- package/dist/migrations/Migration_20260425000003_webhook_retry_count.d.ts.map +1 -0
- package/dist/migrations/Migration_20260425000003_webhook_retry_count.js +42 -0
- package/dist/migrations/Migration_20260425000003_webhook_retry_count.js.map +1 -0
- package/dist/migrations/Migration_20260425000004_webhook_error_and_nullable_merchant.d.ts +29 -0
- package/dist/migrations/Migration_20260425000004_webhook_error_and_nullable_merchant.d.ts.map +1 -0
- package/dist/migrations/Migration_20260425000004_webhook_error_and_nullable_merchant.js +74 -0
- package/dist/migrations/Migration_20260425000004_webhook_error_and_nullable_merchant.js.map +1 -0
- package/dist/models/index.d.ts +7 -0
- package/dist/models/index.d.ts.map +1 -0
- package/dist/models/index.js +10 -0
- package/dist/models/index.js.map +1 -0
- package/dist/models/viva-tenant-merchant.d.ts +11 -0
- package/dist/models/viva-tenant-merchant.d.ts.map +1 -0
- package/dist/models/viva-tenant-merchant.js +54 -0
- package/dist/models/viva-tenant-merchant.js.map +1 -0
- package/dist/models/viva-transaction.d.ts +34 -0
- package/dist/models/viva-transaction.d.ts.map +1 -0
- package/dist/models/viva-transaction.js +104 -0
- package/dist/models/viva-transaction.js.map +1 -0
- package/dist/models/viva-webhook-event.d.ts +32 -0
- package/dist/models/viva-webhook-event.d.ts.map +1 -0
- package/dist/models/viva-webhook-event.js +88 -0
- package/dist/models/viva-webhook-event.js.map +1 -0
- package/dist/observability/config.d.ts +34 -0
- package/dist/observability/config.d.ts.map +1 -0
- package/dist/observability/config.js +57 -0
- package/dist/observability/config.js.map +1 -0
- package/dist/observability/index.d.ts +8 -0
- package/dist/observability/index.d.ts.map +1 -0
- package/dist/observability/index.js +15 -0
- package/dist/observability/index.js.map +1 -0
- package/dist/observability/prom-metrics.d.ts +41 -0
- package/dist/observability/prom-metrics.d.ts.map +1 -0
- package/dist/observability/prom-metrics.js +219 -0
- package/dist/observability/prom-metrics.js.map +1 -0
- package/dist/providers/payment-provider.d.ts +19 -0
- package/dist/providers/payment-provider.d.ts.map +1 -0
- package/dist/providers/payment-provider.js +24 -0
- package/dist/providers/payment-provider.js.map +1 -0
- package/dist/resolvers/auth-strategy-factory.d.ts +42 -0
- package/dist/resolvers/auth-strategy-factory.d.ts.map +1 -0
- package/dist/resolvers/auth-strategy-factory.js +60 -0
- package/dist/resolvers/auth-strategy-factory.js.map +1 -0
- package/dist/resolvers/tenant-resolver.d.ts +104 -0
- package/dist/resolvers/tenant-resolver.d.ts.map +1 -0
- package/dist/resolvers/tenant-resolver.js +118 -0
- package/dist/resolvers/tenant-resolver.js.map +1 -0
- package/dist/service.d.ts +200 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +1003 -0
- package/dist/service.js.map +1 -0
- package/dist/subscribers/index.d.ts +5 -0
- package/dist/subscribers/index.d.ts.map +1 -0
- package/dist/subscribers/index.js +10 -0
- package/dist/subscribers/index.js.map +1 -0
- package/dist/subscribers/viva-webhook-event.d.ts +38 -0
- package/dist/subscribers/viva-webhook-event.d.ts.map +1 -0
- package/dist/subscribers/viva-webhook-event.js +133 -0
- package/dist/subscribers/viva-webhook-event.js.map +1 -0
- package/dist/workflows/cleanup-old-webhook-events.d.ts +39 -0
- package/dist/workflows/cleanup-old-webhook-events.d.ts.map +1 -0
- package/dist/workflows/cleanup-old-webhook-events.js +68 -0
- package/dist/workflows/cleanup-old-webhook-events.js.map +1 -0
- package/dist/workflows/index.d.ts +14 -0
- package/dist/workflows/index.d.ts.map +1 -0
- package/dist/workflows/index.js +19 -0
- package/dist/workflows/index.js.map +1 -0
- package/dist/workflows/per-tenant-semaphore.d.ts +47 -0
- package/dist/workflows/per-tenant-semaphore.d.ts.map +1 -0
- package/dist/workflows/per-tenant-semaphore.js +89 -0
- package/dist/workflows/per-tenant-semaphore.js.map +1 -0
- package/dist/workflows/process-webhook-event.d.ts +80 -0
- package/dist/workflows/process-webhook-event.d.ts.map +1 -0
- package/dist/workflows/process-webhook-event.js +280 -0
- package/dist/workflows/process-webhook-event.js.map +1 -0
- package/dist/workflows/reprocess-unresolved-tenants.d.ts +58 -0
- package/dist/workflows/reprocess-unresolved-tenants.d.ts.map +1 -0
- package/dist/workflows/reprocess-unresolved-tenants.js +121 -0
- package/dist/workflows/reprocess-unresolved-tenants.js.map +1 -0
- package/package.json +63 -0
package/dist/service.js
ADDED
|
@@ -0,0 +1,1003 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* service.ts — VivaPaymentProvider (Medusa v2 AbstractPaymentProvider)
|
|
4
|
+
*
|
|
5
|
+
* Implements all required Medusa v2 payment provider methods, bridging the
|
|
6
|
+
* Medusa payment lifecycle to the Viva Wallet ISV Smart Checkout flow.
|
|
7
|
+
*
|
|
8
|
+
* Key design choices:
|
|
9
|
+
* - A4 (write-pending-first): INSERT into viva_transaction with status='initiated'
|
|
10
|
+
* and viva_order_code=NULL BEFORE calling Viva's createOrder API.
|
|
11
|
+
* - P14 (idempotency): deduplicate by idempotency_key = medusa_payment_id.
|
|
12
|
+
* - P18 (refund validation): validate amount <= captured - refunded before Viva API.
|
|
13
|
+
* - P19 (single-tenant invariant): assertSingleTenantCart before any DB write.
|
|
14
|
+
* - Error model (plan lines 339–344): wrap all Viva errors into MedusaError types.
|
|
15
|
+
*
|
|
16
|
+
* AbstractPaymentProvider required methods (read from @medusajs/utils dist):
|
|
17
|
+
* capturePayment, authorizePayment, cancelPayment, initiatePayment,
|
|
18
|
+
* deletePayment, getPaymentStatus, refundPayment, retrievePayment,
|
|
19
|
+
* updatePayment, getWebhookActionAndData
|
|
20
|
+
*
|
|
21
|
+
* Smart Checkout redirect URL:
|
|
22
|
+
* - Demo: https://demo.vivapayments.com/web/checkout?ref={OrderCode}
|
|
23
|
+
* - Production: https://www.vivapayments.com/web/checkout?ref={OrderCode}
|
|
24
|
+
*
|
|
25
|
+
* @see references/viva-docs/md/payment-isv-api.txt:1 (ISV API overview)
|
|
26
|
+
* @see references/viva-docs/md/smart-checkout-save-payment.txt:1 (redirect URL)
|
|
27
|
+
* @see references/viva-docs/md/tut-create-recurring-payment.txt:1 (checkout URL format)
|
|
28
|
+
* @see references/viva-docs/md/isv-partner-program.txt:61 (P14, P15, P18, P19, A4)
|
|
29
|
+
* @see references/viva-docs/md/isv-credentials.txt:107 (auth credential types)
|
|
30
|
+
* @see references/viva-docs/md/oauth2-authentication.txt:128 (token endpoint)
|
|
31
|
+
*/
|
|
32
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
33
|
+
exports.VivaPaymentProvider = void 0;
|
|
34
|
+
const utils_1 = require("@medusajs/framework/utils");
|
|
35
|
+
const uuid_1 = require("uuid");
|
|
36
|
+
const isv_1 = require("@sakeetech/viva-payments-core/isv");
|
|
37
|
+
const payments_1 = require("@sakeetech/viva-payments-core/payments");
|
|
38
|
+
const legacy_1 = require("@sakeetech/viva-payments-core/legacy");
|
|
39
|
+
const refunds_1 = require("@sakeetech/viva-payments-core/refunds");
|
|
40
|
+
const errors_1 = require("@sakeetech/viva-payments-core/errors");
|
|
41
|
+
const webhooks_1 = require("@sakeetech/viva-payments-core/webhooks");
|
|
42
|
+
const tenant_resolver_js_1 = require("./resolvers/tenant-resolver.js");
|
|
43
|
+
const auth_strategy_factory_js_1 = require("./resolvers/auth-strategy-factory.js");
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Smart Checkout redirect URL builder
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
/**
|
|
48
|
+
* Builds the Smart Checkout redirect URL for a given order code.
|
|
49
|
+
*
|
|
50
|
+
* Pattern confirmed from Viva docs:
|
|
51
|
+
* Demo: https://demo.vivapayments.com/web/checkout?ref={OrderCode}
|
|
52
|
+
* Production: https://www.vivapayments.com/web/checkout?ref={OrderCode}
|
|
53
|
+
*
|
|
54
|
+
* @see references/viva-docs/md/tut-create-recurring-payment.txt:1 (URL format)
|
|
55
|
+
* @see references/viva-docs/md/smart-checkout-save-payment.txt:1 (Smart Checkout)
|
|
56
|
+
*/
|
|
57
|
+
function buildCheckoutUrl(environment, orderCode) {
|
|
58
|
+
const baseUrl = environment === 'demo'
|
|
59
|
+
? 'https://demo.vivapayments.com'
|
|
60
|
+
: 'https://www.vivapayments.com';
|
|
61
|
+
// @see references/viva-docs/md/tut-create-recurring-payment.txt:1
|
|
62
|
+
return `${baseUrl}/web/checkout?ref=${orderCode.toString()}`;
|
|
63
|
+
}
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// ISO 4217 numeric currency code lookup
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
/**
|
|
68
|
+
* Maps ISO 3-char alphabetic currency codes to ISO 4217 numeric codes.
|
|
69
|
+
* Viva expects numeric codes in the createOrder request body.
|
|
70
|
+
*
|
|
71
|
+
* TODO(impl): extend this map for all currencies Viva supports.
|
|
72
|
+
* Currently covers the primary markets listed in Viva docs.
|
|
73
|
+
*
|
|
74
|
+
* @see references/viva-docs/md/isv-partner-program.txt:83 (P15 currency encoding)
|
|
75
|
+
*/
|
|
76
|
+
const CURRENCY_ALPHA_TO_NUMERIC = {
|
|
77
|
+
eur: '978',
|
|
78
|
+
gbp: '826',
|
|
79
|
+
usd: '840',
|
|
80
|
+
pln: '985',
|
|
81
|
+
ron: '946',
|
|
82
|
+
czk: '203',
|
|
83
|
+
huf: '348',
|
|
84
|
+
bgn: '975',
|
|
85
|
+
dkk: '208',
|
|
86
|
+
sek: '752',
|
|
87
|
+
nok: '578',
|
|
88
|
+
};
|
|
89
|
+
function toCurrencyCode(isoAlpha) {
|
|
90
|
+
const numeric = CURRENCY_ALPHA_TO_NUMERIC[isoAlpha.toLowerCase()];
|
|
91
|
+
if (!numeric) {
|
|
92
|
+
// TODO(impl): add full ISO 4217 numeric lookup or accept numeric codes directly
|
|
93
|
+
// Default to EUR (978) if unknown — operator should configure the currency correctly.
|
|
94
|
+
console.warn(`[viva] Unknown currency code '${isoAlpha}', defaulting to EUR (978). ` +
|
|
95
|
+
`Add the mapping in CURRENCY_ALPHA_TO_NUMERIC if needed.`);
|
|
96
|
+
return '978';
|
|
97
|
+
}
|
|
98
|
+
return numeric;
|
|
99
|
+
}
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Medusa status mapping (plan P17)
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
/**
|
|
104
|
+
* Maps internal VivaTransactionStatus to Medusa PaymentSessionStatus.
|
|
105
|
+
*
|
|
106
|
+
* Medusa status values: 'authorized' | 'captured' | 'pending' | 'requires_more'
|
|
107
|
+
* | 'error' | 'canceled'
|
|
108
|
+
*
|
|
109
|
+
* @see references/viva-docs/md/isv-partner-program.txt:61 (P17 status lattice)
|
|
110
|
+
*/
|
|
111
|
+
function toMedusaStatus(vivaStatus) {
|
|
112
|
+
switch (vivaStatus) {
|
|
113
|
+
case 'authorized':
|
|
114
|
+
return 'authorized';
|
|
115
|
+
case 'captured':
|
|
116
|
+
return 'authorized'; // Medusa treats captured as authorized from session perspective
|
|
117
|
+
case 'initiated':
|
|
118
|
+
return 'pending';
|
|
119
|
+
case 'failed':
|
|
120
|
+
return 'error';
|
|
121
|
+
case 'cancelled':
|
|
122
|
+
return 'canceled';
|
|
123
|
+
case 'refunded':
|
|
124
|
+
return 'authorized'; // session is complete; refunds tracked separately
|
|
125
|
+
case 'disputed':
|
|
126
|
+
return 'requires_more';
|
|
127
|
+
default:
|
|
128
|
+
return 'pending';
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Error mapping (plan lines 339–344)
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
/**
|
|
135
|
+
* Maps Viva error types to the appropriate MedusaError.
|
|
136
|
+
*
|
|
137
|
+
* Mapping (plan lines 339–344):
|
|
138
|
+
* VivaAuthError → MedusaError.Types.UNAUTHORIZED
|
|
139
|
+
* VivaApiError → MedusaError.Types.PAYMENT_AUTHORIZATION_ERROR
|
|
140
|
+
* VivaValidationError → MedusaError.Types.INVALID_DATA
|
|
141
|
+
* VivaRateLimitError → MedusaError.Types.PAYMENT_AUTHORIZATION_ERROR (retriable)
|
|
142
|
+
* VivaModeMismatchError → MedusaError.Types.NOT_ALLOWED
|
|
143
|
+
* MedusaError → pass through unchanged
|
|
144
|
+
* Other → MedusaError.Types.UNEXPECTED_STATE
|
|
145
|
+
*
|
|
146
|
+
* @see references/viva-docs/md/isv-partner-program.txt:104 (error model, plan 339)
|
|
147
|
+
*/
|
|
148
|
+
function toMedusaError(err) {
|
|
149
|
+
if (err instanceof utils_1.MedusaError) {
|
|
150
|
+
return err;
|
|
151
|
+
}
|
|
152
|
+
if (err instanceof errors_1.VivaModeMismatchError) {
|
|
153
|
+
return new utils_1.MedusaError(utils_1.MedusaError.Types.NOT_ALLOWED, err.message);
|
|
154
|
+
}
|
|
155
|
+
if (err instanceof errors_1.VivaAuthError) {
|
|
156
|
+
return new utils_1.MedusaError(utils_1.MedusaError.Types.UNAUTHORIZED, err.message);
|
|
157
|
+
}
|
|
158
|
+
if (err instanceof errors_1.VivaRateLimitError) {
|
|
159
|
+
return new utils_1.MedusaError(utils_1.MedusaError.Types.PAYMENT_AUTHORIZATION_ERROR, err.message);
|
|
160
|
+
}
|
|
161
|
+
if (err instanceof errors_1.VivaApiError) {
|
|
162
|
+
return new utils_1.MedusaError(utils_1.MedusaError.Types.PAYMENT_AUTHORIZATION_ERROR, err.message);
|
|
163
|
+
}
|
|
164
|
+
if (err instanceof errors_1.VivaValidationError) {
|
|
165
|
+
return new utils_1.MedusaError(utils_1.MedusaError.Types.INVALID_DATA, err.message);
|
|
166
|
+
}
|
|
167
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
168
|
+
return new utils_1.MedusaError(utils_1.MedusaError.Types.UNEXPECTED_STATE, message);
|
|
169
|
+
}
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// Mode-aware client construction (slice B)
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
/**
|
|
174
|
+
* Default Smart Checkout source code for merchant mode when `config.sourceCode`
|
|
175
|
+
* is unset. Viva accepts this on any merchant account out of the box.
|
|
176
|
+
*
|
|
177
|
+
* @see references/viva-docs/md/payment-source-for-isv.txt:101
|
|
178
|
+
*/
|
|
179
|
+
const DEFAULT_SOURCE_CODE = 'Default';
|
|
180
|
+
/**
|
|
181
|
+
* Build the right {@link BasicAuthClient} (legacy host + Basic auth) per mode.
|
|
182
|
+
*
|
|
183
|
+
* Both modes use the merchant variant in slice B — the reseller variant is only
|
|
184
|
+
* needed for IsvSources (`POST /api/sources`), which the payment provider does
|
|
185
|
+
* not call. In ISV mode, the reseller-flavoured client is still built by
|
|
186
|
+
* {@link buildAuthStrategies} for OAuth2 401-fallback.
|
|
187
|
+
*
|
|
188
|
+
* @see docs/AUTH.md §6.2
|
|
189
|
+
*/
|
|
190
|
+
function buildLegacyClient(config) {
|
|
191
|
+
return new legacy_1.BasicAuthClient({
|
|
192
|
+
authVariant: 'merchant',
|
|
193
|
+
environment: config.environment,
|
|
194
|
+
merchantId: config.legacyMerchantId,
|
|
195
|
+
apiKey: config.legacyApiKey,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Build a mode-aware {@link Payments} instance.
|
|
200
|
+
*
|
|
201
|
+
* In merchant mode the constructed client emits URL paths without `/isv` and
|
|
202
|
+
* without the `merchantId={uuid}` query parameter. The `Payments` class
|
|
203
|
+
* silently ignores `opts.merchantId` when constructed with `mode='merchant'`.
|
|
204
|
+
*
|
|
205
|
+
* @see docs/plans/multi-mode-v0.md §9
|
|
206
|
+
*/
|
|
207
|
+
function buildPaymentsClient(config, httpClient, legacyClient) {
|
|
208
|
+
return new payments_1.Payments({
|
|
209
|
+
mode: config.mode,
|
|
210
|
+
client: httpClient,
|
|
211
|
+
legacyClient,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Build a {@link FastRefundClient} bound to the OAuth2 acquiring scope.
|
|
216
|
+
*
|
|
217
|
+
* Caller is expected to fall back to standard refund on HTTP 403 when the
|
|
218
|
+
* refund strategy is `'auto'`; when strategy is `'fast'` a 403 surfaces as
|
|
219
|
+
* `VIVA_FAST_REFUND_INELIGIBLE`.
|
|
220
|
+
*
|
|
221
|
+
* @see docs/ENDPOINTS.md §4
|
|
222
|
+
*/
|
|
223
|
+
function buildFastRefundClient(httpClient) {
|
|
224
|
+
return new refunds_1.FastRefundClient({ client: httpClient });
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Resolve the Viva merchantId to pass through to `Payments` calls per mode.
|
|
228
|
+
*
|
|
229
|
+
* In ISV mode this is the per-tenant merchant resolved from the cart. In
|
|
230
|
+
* merchant mode there is no per-cart tenant — the `Payments` client ignores
|
|
231
|
+
* `opts.merchantId` entirely so the value is irrelevant, but we still pass
|
|
232
|
+
* the configured legacy merchant id for consistency in stored DB rows.
|
|
233
|
+
*/
|
|
234
|
+
function effectiveMerchantId(config, tenantMerchantId) {
|
|
235
|
+
// In ISV mode the tenant resolver always returns a value before we get here.
|
|
236
|
+
if (config.mode === 'isv' && tenantMerchantId)
|
|
237
|
+
return tenantMerchantId;
|
|
238
|
+
return config.legacyMerchantId;
|
|
239
|
+
}
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// VivaPaymentProvider
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
/**
|
|
244
|
+
* Medusa v2 payment provider for Viva Wallet Smart Checkout (ISV multi-tenant).
|
|
245
|
+
*
|
|
246
|
+
* Identifier: 'viva'. Payment provider ID format: pp_viva_<id>.
|
|
247
|
+
* The <id> comes from the `id` field in medusa-config.ts providers array.
|
|
248
|
+
*
|
|
249
|
+
* @see references/viva-docs/md/payment-isv-api.txt:1 (ISV payment creation)
|
|
250
|
+
* @see references/viva-docs/md/smart-checkout-save-payment.txt:1 (redirect URL)
|
|
251
|
+
* @see references/viva-docs/md/isv-partner-program.txt:61 (ISV overview)
|
|
252
|
+
*/
|
|
253
|
+
class VivaPaymentProvider extends utils_1.AbstractPaymentProvider {
|
|
254
|
+
static identifier = 'viva';
|
|
255
|
+
vivaConfig;
|
|
256
|
+
isvPayments;
|
|
257
|
+
legacyClient;
|
|
258
|
+
fastRefundClient;
|
|
259
|
+
tenantResolver;
|
|
260
|
+
em;
|
|
261
|
+
constructor(container, options) {
|
|
262
|
+
super(container, options);
|
|
263
|
+
this.vivaConfig = options.config;
|
|
264
|
+
// Build auth strategies from config
|
|
265
|
+
const authStrategies = (0, auth_strategy_factory_js_1.buildAuthStrategies)(options.config);
|
|
266
|
+
// Build ISV HTTP client with OAuth2 primary strategy
|
|
267
|
+
const httpClient = new isv_1.IsvHttpClient({
|
|
268
|
+
environment: options.config.environment,
|
|
269
|
+
authStrategy: authStrategies.primary,
|
|
270
|
+
});
|
|
271
|
+
// Build legacy Basic-auth client for refundPayment + Standard refund path.
|
|
272
|
+
// Probe-verified 2026-04-25 (F1): POST /checkout/v2/transactions/{id} returns 405.
|
|
273
|
+
// Refund must use legacy host with Basic auth (merchantId:apiKey).
|
|
274
|
+
// @see references/viva-docs/md/tut-create-recurring-payment.txt:288
|
|
275
|
+
this.legacyClient = buildLegacyClient(options.config);
|
|
276
|
+
// Slice B: build the `Payments` client mode-aware. In merchant mode the
|
|
277
|
+
// URL paths do NOT include the `/isv` segment and do NOT carry
|
|
278
|
+
// `merchantId={uuid}` — see Payments.client.ts buildOrderCreateUrl etc.
|
|
279
|
+
// @see docs/plans/multi-mode-v0.md §9
|
|
280
|
+
this.isvPayments = buildPaymentsClient(options.config, httpClient, this.legacyClient);
|
|
281
|
+
// Fast Refund client — used by the merchant-mode refundPayment flow when
|
|
282
|
+
// strategy resolves to 'fast' (auto or explicit).
|
|
283
|
+
// @see docs/ENDPOINTS.md §4
|
|
284
|
+
this.fastRefundClient = buildFastRefundClient(httpClient);
|
|
285
|
+
// EntityManager from container for DB access.
|
|
286
|
+
// TODO(impl): verify the exact container key for Mikro-ORM EM in Medusa v2.
|
|
287
|
+
// Standard pattern in Medusa v2: container['manager'] for the base EM.
|
|
288
|
+
this.em = (container['manager'] ?? container['entityManager']);
|
|
289
|
+
if (options.tenantResolver) {
|
|
290
|
+
this.tenantResolver = options.tenantResolver;
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
this.tenantResolver = new tenant_resolver_js_1.DefaultTenantResolver(this.em);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// --------------------------------------------------------------------------
|
|
297
|
+
// initiatePayment
|
|
298
|
+
// --------------------------------------------------------------------------
|
|
299
|
+
/**
|
|
300
|
+
* Initiates a payment session (Medusa calls this when customer selects Viva):
|
|
301
|
+
* 1. P19: assert single-tenant cart (from input.context)
|
|
302
|
+
* 2. Resolve tenant → Viva merchant
|
|
303
|
+
* 3. P14 dedup: check for existing transaction by idempotency_key
|
|
304
|
+
* 4. A4 write-pending-first: INSERT transaction row BEFORE Viva API call
|
|
305
|
+
* 5. Call Viva createOrder
|
|
306
|
+
* 6. Update row with viva_order_code
|
|
307
|
+
* 7. Return redirect URL in data (storefront uses this to redirect customer)
|
|
308
|
+
*
|
|
309
|
+
* The medusa_payment_id is derived from input.context.idempotency_key (set by
|
|
310
|
+
* Medusa Payment Module) or generated per call.
|
|
311
|
+
*
|
|
312
|
+
* @see references/viva-docs/md/payment-isv-api.txt:1 (createOrder)
|
|
313
|
+
* @see references/viva-docs/md/smart-checkout-save-payment.txt:1 (redirect URL)
|
|
314
|
+
* @see references/viva-docs/md/isv-partner-program.txt:61 (P14, P19, A4)
|
|
315
|
+
*/
|
|
316
|
+
async initiatePayment(input) {
|
|
317
|
+
try {
|
|
318
|
+
// Extract cart-like context for tenant resolution and P19 validation.
|
|
319
|
+
// Medusa passes cart metadata via input.context. The actual cart is
|
|
320
|
+
// not available here — only the customer context.
|
|
321
|
+
// P19: validate that we have a single tenant (from context metadata if available)
|
|
322
|
+
// TODO(impl): when Medusa passes cart items in context, pass them here.
|
|
323
|
+
// For now assertSingleTenantCart is a no-op when items is empty.
|
|
324
|
+
const contextMetadata = (input.data?.['cart_metadata'] ??
|
|
325
|
+
input.data?.['metadata']);
|
|
326
|
+
const cartLike = {
|
|
327
|
+
id: input.data?.['cart_id'] ?? '',
|
|
328
|
+
metadata: contextMetadata ?? null,
|
|
329
|
+
items: input.data?.['items'] ?? [],
|
|
330
|
+
};
|
|
331
|
+
// P19: validate single-tenant cart before any DB write (ISV mode only —
|
|
332
|
+
// merchant mode has no per-cart tenant concept).
|
|
333
|
+
if (this.vivaConfig.mode === 'isv') {
|
|
334
|
+
(0, tenant_resolver_js_1.assertSingleTenantCart)(cartLike);
|
|
335
|
+
}
|
|
336
|
+
// Resolve tenant → Viva merchant per mode:
|
|
337
|
+
// ISV mode: resolveTenant from cart → vivaMerchantId, sourceCode.
|
|
338
|
+
// Merchant mode: no per-cart tenant — vivaMerchantId is undefined and
|
|
339
|
+
// sourceCode falls back to config.sourceCode ?? 'Default'.
|
|
340
|
+
// @see docs/plans/multi-mode-v0.md §9
|
|
341
|
+
let vivaMerchantId;
|
|
342
|
+
let sourceCode;
|
|
343
|
+
if (this.vivaConfig.mode === 'isv') {
|
|
344
|
+
const { tenantId } = await this.tenantResolver.resolveTenantFromCart(cartLike);
|
|
345
|
+
const account = await this.tenantResolver.resolveVivaAccount(tenantId);
|
|
346
|
+
vivaMerchantId = account.vivaMerchantId;
|
|
347
|
+
// ISV mode currently has no per-tenant sourceCode — use 'Default'.
|
|
348
|
+
// TODO(slice-e): allow per-tenant sourceCode via tenant resolver.
|
|
349
|
+
sourceCode = DEFAULT_SOURCE_CODE;
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
vivaMerchantId = undefined;
|
|
353
|
+
sourceCode = this.vivaConfig.sourceCode ?? DEFAULT_SOURCE_CODE;
|
|
354
|
+
}
|
|
355
|
+
// Use Medusa's idempotency_key if provided, else use existing data's session id
|
|
356
|
+
const medusaPaymentId = input.context?.idempotency_key ??
|
|
357
|
+
input.data?.['session_id'] ??
|
|
358
|
+
(0, uuid_1.v4)();
|
|
359
|
+
const idempotencyKey = medusaPaymentId;
|
|
360
|
+
// P14 dedup: check existing transaction
|
|
361
|
+
const repo = this.em.getRepository('VivaTransaction');
|
|
362
|
+
const existing = await repo.findOne({ idempotency_key: idempotencyKey });
|
|
363
|
+
if (existing &&
|
|
364
|
+
existing.status !== 'initiated' &&
|
|
365
|
+
existing.status !== 'failed') {
|
|
366
|
+
// Idempotent re-call: return existing data
|
|
367
|
+
const redirectUrl = existing.viva_order_code
|
|
368
|
+
? buildCheckoutUrl(this.vivaConfig.environment, BigInt(existing.viva_order_code))
|
|
369
|
+
: null;
|
|
370
|
+
return {
|
|
371
|
+
id: medusaPaymentId,
|
|
372
|
+
data: {
|
|
373
|
+
viva_transaction_id: existing.viva_transaction_id,
|
|
374
|
+
order_code: existing.viva_order_code ?? null,
|
|
375
|
+
redirect_url: redirectUrl,
|
|
376
|
+
viva_status: existing.status,
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
// Compute amount in minor units.
|
|
381
|
+
// Medusa passes amount as a BigNumber-compatible value.
|
|
382
|
+
// @see references/viva-docs/md/isv-partner-program.txt:83 (P15 amounts)
|
|
383
|
+
const amountMinor = BigInt(Math.round(Number(input.amount) * 100));
|
|
384
|
+
const currencyCode = toCurrencyCode(input.currency_code);
|
|
385
|
+
// A4 write-pending-first: INSERT before Viva API call.
|
|
386
|
+
// viva_order_code is NULL until createOrder returns.
|
|
387
|
+
// Migration_20260425000002 makes this column nullable.
|
|
388
|
+
// @see references/viva-docs/md/isv-partner-program.txt:61 (A4)
|
|
389
|
+
const vivaTransactionId = (0, uuid_1.v4)();
|
|
390
|
+
// Slice D made viva_merchant_id nullable. Merchant-mode rows store NULL
|
|
391
|
+
// because there is no per-cart tenant merchant id — the plugin operates
|
|
392
|
+
// a single account.
|
|
393
|
+
// @see Migration_20260425000004_webhook_error_and_nullable_merchant
|
|
394
|
+
const storedMerchantId = vivaMerchantId ?? null;
|
|
395
|
+
if (!existing) {
|
|
396
|
+
const now = new Date();
|
|
397
|
+
const entity = repo.create({
|
|
398
|
+
viva_transaction_id: vivaTransactionId,
|
|
399
|
+
viva_order_code: null,
|
|
400
|
+
medusa_payment_id: medusaPaymentId,
|
|
401
|
+
viva_merchant_id: storedMerchantId,
|
|
402
|
+
status: 'initiated',
|
|
403
|
+
claim_substate: null,
|
|
404
|
+
amount_minor: amountMinor.toString(),
|
|
405
|
+
refunded_amount_minor: '0',
|
|
406
|
+
currency_code: currencyCode,
|
|
407
|
+
idempotency_key: idempotencyKey,
|
|
408
|
+
raw_payload: null,
|
|
409
|
+
created_at: now,
|
|
410
|
+
updated_at: now,
|
|
411
|
+
});
|
|
412
|
+
await this.em.persistAndFlush(entity);
|
|
413
|
+
}
|
|
414
|
+
// Step 5: call Viva createOrder.
|
|
415
|
+
// - ISV mode: merchantId from tenant resolution; URL is /isv/orders.
|
|
416
|
+
// - Merchant mode: merchantId omitted; URL is /checkout/v2/orders.
|
|
417
|
+
// `Payments` silently ignores opts.merchantId in merchant mode.
|
|
418
|
+
// @see references/viva-docs/md/payment-source-for-isv.txt:101
|
|
419
|
+
const createResult = await this.isvPayments.createOrder({
|
|
420
|
+
amount: amountMinor,
|
|
421
|
+
currencyCode,
|
|
422
|
+
merchantTrns: medusaPaymentId,
|
|
423
|
+
customerTrns: `Payment via Viva Wallet`,
|
|
424
|
+
sourceCode,
|
|
425
|
+
}, {
|
|
426
|
+
...(vivaMerchantId !== undefined ? { merchantId: vivaMerchantId } : {}),
|
|
427
|
+
idempotencyKey,
|
|
428
|
+
});
|
|
429
|
+
const orderCode = createResult.orderCode;
|
|
430
|
+
// Step 6: update the pending row with the order code
|
|
431
|
+
const txId = existing?.viva_transaction_id ?? vivaTransactionId;
|
|
432
|
+
const txRow = await repo.findOne({ viva_transaction_id: txId });
|
|
433
|
+
if (txRow) {
|
|
434
|
+
txRow.viva_order_code = orderCode.toString();
|
|
435
|
+
await this.em.flush();
|
|
436
|
+
}
|
|
437
|
+
// Step 7: build redirect URL and return.
|
|
438
|
+
// Storefront reads data.redirect_url to redirect the customer.
|
|
439
|
+
// @see references/viva-docs/md/smart-checkout-save-payment.txt:1
|
|
440
|
+
const redirectUrl = buildCheckoutUrl(this.vivaConfig.environment, orderCode);
|
|
441
|
+
return {
|
|
442
|
+
id: medusaPaymentId,
|
|
443
|
+
data: {
|
|
444
|
+
viva_transaction_id: txId,
|
|
445
|
+
order_code: orderCode.toString(),
|
|
446
|
+
redirect_url: redirectUrl,
|
|
447
|
+
viva_status: 'initiated',
|
|
448
|
+
},
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
catch (err) {
|
|
452
|
+
throw toMedusaError(err);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
// --------------------------------------------------------------------------
|
|
456
|
+
// authorizePayment
|
|
457
|
+
// --------------------------------------------------------------------------
|
|
458
|
+
/**
|
|
459
|
+
* Consults the DB for the latest transaction status.
|
|
460
|
+
* For Smart Checkout, authorization is off-band (redirect + webhook).
|
|
461
|
+
* Called during cart-completion; returns current DB status.
|
|
462
|
+
*
|
|
463
|
+
* @see references/viva-docs/md/payment-isv-api.txt:1 (Smart Checkout flow)
|
|
464
|
+
* @see references/viva-docs/md/isv-partner-program.txt:61 (P6 Smart Checkout)
|
|
465
|
+
*/
|
|
466
|
+
async authorizePayment(input) {
|
|
467
|
+
try {
|
|
468
|
+
const medusaPaymentId = extractPaymentId(input.data);
|
|
469
|
+
const repo = this.em.getRepository('VivaTransaction');
|
|
470
|
+
const row = medusaPaymentId
|
|
471
|
+
? await repo.findOne({ medusa_payment_id: medusaPaymentId })
|
|
472
|
+
: null;
|
|
473
|
+
if (!row) {
|
|
474
|
+
return { status: 'pending', data: input.data ?? {} };
|
|
475
|
+
}
|
|
476
|
+
const status = toMedusaStatus(row.status);
|
|
477
|
+
return {
|
|
478
|
+
status,
|
|
479
|
+
data: {
|
|
480
|
+
...(input.data ?? {}),
|
|
481
|
+
viva_transaction_id: row.viva_transaction_id,
|
|
482
|
+
order_code: row.viva_order_code ?? null,
|
|
483
|
+
viva_status: row.status,
|
|
484
|
+
},
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
catch (err) {
|
|
488
|
+
throw toMedusaError(err);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
// --------------------------------------------------------------------------
|
|
492
|
+
// capturePayment
|
|
493
|
+
// --------------------------------------------------------------------------
|
|
494
|
+
/**
|
|
495
|
+
* Capture is implicit on Smart Checkout completion (1796 webhook).
|
|
496
|
+
* This method is idempotent: if already captured, returns success.
|
|
497
|
+
* If not yet captured, logs a warning and returns current state.
|
|
498
|
+
*
|
|
499
|
+
* NOTE: Do NOT call Viva's capture endpoint. Capture is automatic on
|
|
500
|
+
* Smart Checkout completion per Viva's ISV model.
|
|
501
|
+
*
|
|
502
|
+
* @see references/viva-docs/md/payment-isv-api.txt:1 (implicit capture)
|
|
503
|
+
* @see references/viva-docs/md/isv-partner-program.txt:61 (P6 Smart Checkout)
|
|
504
|
+
*/
|
|
505
|
+
async capturePayment(input) {
|
|
506
|
+
try {
|
|
507
|
+
const medusaPaymentId = extractPaymentId(input.data);
|
|
508
|
+
const repo = this.em.getRepository('VivaTransaction');
|
|
509
|
+
const row = medusaPaymentId
|
|
510
|
+
? await repo.findOne({ medusa_payment_id: medusaPaymentId })
|
|
511
|
+
: null;
|
|
512
|
+
if (!row) {
|
|
513
|
+
return { data: input.data ?? {} };
|
|
514
|
+
}
|
|
515
|
+
if (row.status === 'captured') {
|
|
516
|
+
return {
|
|
517
|
+
data: {
|
|
518
|
+
...(input.data ?? {}),
|
|
519
|
+
viva_transaction_id: row.viva_transaction_id,
|
|
520
|
+
order_code: row.viva_order_code ?? null,
|
|
521
|
+
viva_status: row.status,
|
|
522
|
+
},
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
// Not yet captured — asynchronous via webhook 1796
|
|
526
|
+
console.warn(`[viva] capturePayment called on transaction ${row.viva_transaction_id} ` +
|
|
527
|
+
`with status '${row.status}' (not 'captured'). ` +
|
|
528
|
+
`Smart Checkout capture happens asynchronously via Viva webhook 1796. ` +
|
|
529
|
+
`No Viva API call made.`);
|
|
530
|
+
return {
|
|
531
|
+
data: {
|
|
532
|
+
...(input.data ?? {}),
|
|
533
|
+
viva_transaction_id: row.viva_transaction_id,
|
|
534
|
+
order_code: row.viva_order_code ?? null,
|
|
535
|
+
viva_status: row.status,
|
|
536
|
+
},
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
catch (err) {
|
|
540
|
+
throw toMedusaError(err);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
// --------------------------------------------------------------------------
|
|
544
|
+
// refundPayment
|
|
545
|
+
// --------------------------------------------------------------------------
|
|
546
|
+
/**
|
|
547
|
+
* Issues a full or partial refund via Viva ISV API.
|
|
548
|
+
*
|
|
549
|
+
* P18 validation: refundAmountMinor <= capturedAmount - alreadyRefunded.
|
|
550
|
+
* Auth: primary OAuth2 strategy (Q5 — if Viva rejects with 401/403, reseller
|
|
551
|
+
* auth may be required; log warning and re-raise for now).
|
|
552
|
+
* Partial refund: increments refunded_amount_minor; status stays 'captured'.
|
|
553
|
+
* Full refund: transitions status to 'refunded' via lattice.
|
|
554
|
+
*
|
|
555
|
+
* @see references/viva-docs/md/isv-partner-program.txt:296 (P18 refund, ISV fee reversal)
|
|
556
|
+
* @see references/viva-docs/md/payment-isv-api.txt:1 (refund endpoint)
|
|
557
|
+
*/
|
|
558
|
+
async refundPayment(input) {
|
|
559
|
+
try {
|
|
560
|
+
const medusaPaymentId = extractPaymentId(input.data);
|
|
561
|
+
if (!medusaPaymentId) {
|
|
562
|
+
throw new errors_1.VivaValidationError({
|
|
563
|
+
message: 'refundPayment: medusa_payment_id is required in input.data',
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
const repo = this.em.getRepository('VivaTransaction');
|
|
567
|
+
const row = await repo.findOne({ medusa_payment_id: medusaPaymentId });
|
|
568
|
+
if (!row) {
|
|
569
|
+
throw new errors_1.VivaValidationError({
|
|
570
|
+
message: `Transaction not found for medusa_payment_id '${medusaPaymentId}'`,
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
if (row.status !== 'captured') {
|
|
574
|
+
throw new errors_1.VivaValidationError({
|
|
575
|
+
message: `Cannot refund transaction '${row.viva_transaction_id}': ` +
|
|
576
|
+
`status is '${row.status}', expected 'captured'.`,
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
const capturedAmount = BigInt(row.amount_minor);
|
|
580
|
+
const alreadyRefunded = BigInt(row.refunded_amount_minor);
|
|
581
|
+
const available = capturedAmount - alreadyRefunded;
|
|
582
|
+
// input.amount is BigNumberInput (number | string | BigNumber).
|
|
583
|
+
// Convert to minor units (Medusa tracks amounts in currency major units for display).
|
|
584
|
+
// TODO(impl): confirm whether Medusa passes refund amount in minor or major units.
|
|
585
|
+
// Treating as major units (same as initiatePayment amount) for consistency.
|
|
586
|
+
const refundAmountMinor = input.amount !== undefined && input.amount !== null
|
|
587
|
+
? BigInt(Math.round(Number(input.amount) * 100))
|
|
588
|
+
: available; // full refund if amount not specified
|
|
589
|
+
// P18 validation
|
|
590
|
+
if (refundAmountMinor <= 0n) {
|
|
591
|
+
throw new errors_1.VivaValidationError({
|
|
592
|
+
message: `Refund amount must be > 0, got ${refundAmountMinor}.`,
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
if (refundAmountMinor > available) {
|
|
596
|
+
throw new errors_1.VivaValidationError({
|
|
597
|
+
message: `Refund amount ${refundAmountMinor} exceeds available amount ${available} ` +
|
|
598
|
+
`(captured: ${capturedAmount}, already refunded: ${alreadyRefunded}). ` +
|
|
599
|
+
`Plan P18.`,
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
// Get the Viva TransactionId for the refund endpoint.
|
|
603
|
+
// The webhook handler (S8) stores this in raw_payload.TransactionId.
|
|
604
|
+
// @see references/viva-docs/md/payment-isv-api.txt:1 (refund uses TransactionId)
|
|
605
|
+
const vivaTransactionId = (row.raw_payload?.['TransactionId'] ??
|
|
606
|
+
row.raw_payload?.['transactionId'] ??
|
|
607
|
+
row.viva_transaction_id);
|
|
608
|
+
// Build idempotency key for the refund: combines refund id + amount to prevent duplicates
|
|
609
|
+
const medusaRefundId = input.data?.['medusa_refund_id'] ?? medusaPaymentId;
|
|
610
|
+
const refundIdempotencyKey = `refund:${medusaRefundId}:${refundAmountMinor}`;
|
|
611
|
+
// Branch refund path on mode:
|
|
612
|
+
// ISV mode → legacy/Basic refund via Payments.refundPayment (unchanged).
|
|
613
|
+
// Merchant mode → resolveRefundStrategy + FastRefundClient, with auto
|
|
614
|
+
// fallback to Standard (legacy/Basic) refund on HTTP 403.
|
|
615
|
+
//
|
|
616
|
+
// @see docs/ENDPOINTS.md §4 (Fast vs Standard matrix)
|
|
617
|
+
// @see docs/plans/multi-mode-v0.md §9
|
|
618
|
+
const rowMerchantId = row.viva_merchant_id;
|
|
619
|
+
if (this.vivaConfig.mode === 'isv') {
|
|
620
|
+
await this.isvPayments.refundPayment(vivaTransactionId, {
|
|
621
|
+
merchantId: rowMerchantId,
|
|
622
|
+
amountMinor: refundAmountMinor,
|
|
623
|
+
idempotencyKey: refundIdempotencyKey,
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
else {
|
|
627
|
+
await this.refundPaymentMerchantMode({
|
|
628
|
+
vivaTransactionId,
|
|
629
|
+
refundAmountMinor,
|
|
630
|
+
refundIdempotencyKey,
|
|
631
|
+
medusaRefundId,
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
// Update refunded_amount_minor
|
|
635
|
+
const newRefunded = alreadyRefunded + refundAmountMinor;
|
|
636
|
+
row.refunded_amount_minor = newRefunded.toString();
|
|
637
|
+
// Determine new status via lattice.
|
|
638
|
+
// Full refund: captured → refunded. Partial: stays captured.
|
|
639
|
+
// @see references/viva-docs/md/isv-partner-program.txt:296 (P18)
|
|
640
|
+
const isFullRefund = newRefunded >= capturedAmount;
|
|
641
|
+
if (isFullRefund) {
|
|
642
|
+
const transition = (0, webhooks_1.validateStatusTransition)(row.status, 'refunded');
|
|
643
|
+
if (transition.ok) {
|
|
644
|
+
row.status = transition.next;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
await this.em.flush();
|
|
648
|
+
return {
|
|
649
|
+
data: {
|
|
650
|
+
...(input.data ?? {}),
|
|
651
|
+
viva_transaction_id: row.viva_transaction_id,
|
|
652
|
+
order_code: row.viva_order_code ?? null,
|
|
653
|
+
viva_status: row.status,
|
|
654
|
+
refunded_amount_minor: newRefunded.toString(),
|
|
655
|
+
},
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
catch (err) {
|
|
659
|
+
throw toMedusaError(err);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
// --------------------------------------------------------------------------
|
|
663
|
+
// refundPaymentMerchantMode (slice B)
|
|
664
|
+
// --------------------------------------------------------------------------
|
|
665
|
+
/**
|
|
666
|
+
* Merchant-mode refund routing — Fast vs Standard with auto-fallback.
|
|
667
|
+
*
|
|
668
|
+
* Flow:
|
|
669
|
+
* 1. retrieveTransaction → cardType (for refund-strategy decision).
|
|
670
|
+
* 2. resolveRefundStrategy(config.refundStrategy ?? 'auto', { cardType, CNP: true }).
|
|
671
|
+
* 3. If decision.kind === 'fast': call FastRefundClient.refund.
|
|
672
|
+
* - 403 + strategy === 'fast' → throw VivaApiError with vivaCode
|
|
673
|
+
* 'VIVA_FAST_REFUND_INELIGIBLE'.
|
|
674
|
+
* - 403 + strategy === 'auto' → fall through to Standard refund.
|
|
675
|
+
* - any other error → re-throw.
|
|
676
|
+
* 4. Otherwise call BasicAuthClient via Payments.refundPayment (Standard).
|
|
677
|
+
*
|
|
678
|
+
* Smart Checkout is always card-not-present (CNP), so `isCardNotPresent: true`.
|
|
679
|
+
*
|
|
680
|
+
* @see docs/ENDPOINTS.md §4
|
|
681
|
+
* @see docs/plans/multi-mode-v0.md §9
|
|
682
|
+
* @see references/payment-api.yaml:9255 (Fast Refund 403 semantics)
|
|
683
|
+
*/
|
|
684
|
+
async refundPaymentMerchantMode(params) {
|
|
685
|
+
const { vivaTransactionId, refundAmountMinor, refundIdempotencyKey, medusaRefundId } = params;
|
|
686
|
+
const configuredStrategy = this.vivaConfig.refundStrategy ?? 'auto';
|
|
687
|
+
// Look up cardType from Viva to feed the strategy decision. Merchant mode
|
|
688
|
+
// passes no merchantId — Payments ignores opts.merchantId when mode==='merchant'.
|
|
689
|
+
let cardType;
|
|
690
|
+
try {
|
|
691
|
+
const tx = await this.isvPayments.retrieveTransaction(vivaTransactionId);
|
|
692
|
+
cardType = tx.cardType;
|
|
693
|
+
}
|
|
694
|
+
catch (e) {
|
|
695
|
+
// retrieveTransaction failure shouldn't block refund — log and proceed
|
|
696
|
+
// with cardType=undefined, which lands on 'auto-no-card-info' → Standard.
|
|
697
|
+
console.warn(`[viva] retrieveTransaction failed for refund '${vivaTransactionId}': ` +
|
|
698
|
+
`${e instanceof Error ? e.message : String(e)}. ` +
|
|
699
|
+
`Refund strategy will route via 'auto-no-card-info' (Standard).`);
|
|
700
|
+
}
|
|
701
|
+
const decision = (0, refunds_1.resolveRefundStrategy)(configuredStrategy, {
|
|
702
|
+
...(cardType !== undefined ? { cardType } : {}),
|
|
703
|
+
isCardNotPresent: true, // Smart Checkout is always CNP
|
|
704
|
+
});
|
|
705
|
+
if (decision.kind === 'fast') {
|
|
706
|
+
try {
|
|
707
|
+
await this.fastRefundClient.refund({
|
|
708
|
+
transactionId: vivaTransactionId,
|
|
709
|
+
amount: refundAmountMinor,
|
|
710
|
+
sourceCode: this.vivaConfig.mode === 'merchant'
|
|
711
|
+
? this.vivaConfig.sourceCode ?? DEFAULT_SOURCE_CODE
|
|
712
|
+
: DEFAULT_SOURCE_CODE,
|
|
713
|
+
merchantTrns: medusaRefundId,
|
|
714
|
+
idempotencyKey: refundIdempotencyKey,
|
|
715
|
+
});
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
catch (err) {
|
|
719
|
+
// 403 → either fall back (auto) or surface VIVA_FAST_REFUND_INELIGIBLE (fast).
|
|
720
|
+
const is403 = err instanceof errors_1.VivaApiError && err.httpStatus === 403;
|
|
721
|
+
if (is403 && configuredStrategy === 'fast') {
|
|
722
|
+
throw new errors_1.VivaApiError({
|
|
723
|
+
message: `Fast Refund ineligible (HTTP 403). Configured strategy 'fast' does not fall back. ` +
|
|
724
|
+
`Set VIVA_REFUND_STRATEGY=auto to enable automatic Standard-refund fallback.`,
|
|
725
|
+
httpStatus: 403,
|
|
726
|
+
vivaCode: 'VIVA_FAST_REFUND_INELIGIBLE',
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
if (!is403) {
|
|
730
|
+
throw err;
|
|
731
|
+
}
|
|
732
|
+
// is403 + strategy === 'auto' → fall through to Standard refund.
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
// Standard refund — Payments.refundPayment routes through BasicAuthClient.
|
|
736
|
+
// merchantId is required by the Payments method signature but ignored at
|
|
737
|
+
// the wire layer for merchant mode (the legacy refund endpoint doesn't
|
|
738
|
+
// carry merchantId in the URL).
|
|
739
|
+
await this.isvPayments.refundPayment(vivaTransactionId, {
|
|
740
|
+
merchantId: this.vivaConfig.legacyMerchantId,
|
|
741
|
+
amountMinor: refundAmountMinor,
|
|
742
|
+
idempotencyKey: refundIdempotencyKey,
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
// --------------------------------------------------------------------------
|
|
746
|
+
// cancelPayment
|
|
747
|
+
// --------------------------------------------------------------------------
|
|
748
|
+
/**
|
|
749
|
+
* Cancels a Viva order (valid for unpaid orders not yet captured).
|
|
750
|
+
* Idempotent: skip API call if already in terminal cancelled state.
|
|
751
|
+
* Apply lattice: authorized → cancelled (A9 path).
|
|
752
|
+
*
|
|
753
|
+
* @see references/viva-docs/md/payment-isv-api.txt:1 (cancelOrder)
|
|
754
|
+
* @see references/viva-docs/md/isv-partner-program.txt:61 (P18, A9)
|
|
755
|
+
*/
|
|
756
|
+
async cancelPayment(input) {
|
|
757
|
+
try {
|
|
758
|
+
const medusaPaymentId = extractPaymentId(input.data);
|
|
759
|
+
const repo = this.em.getRepository('VivaTransaction');
|
|
760
|
+
const row = medusaPaymentId
|
|
761
|
+
? await repo.findOne({ medusa_payment_id: medusaPaymentId })
|
|
762
|
+
: null;
|
|
763
|
+
if (!row) {
|
|
764
|
+
return { data: input.data ?? {} };
|
|
765
|
+
}
|
|
766
|
+
// Idempotent: already cancelled
|
|
767
|
+
if (row.status === 'cancelled') {
|
|
768
|
+
return {
|
|
769
|
+
data: {
|
|
770
|
+
...(input.data ?? {}),
|
|
771
|
+
viva_transaction_id: row.viva_transaction_id,
|
|
772
|
+
viva_status: 'cancelled',
|
|
773
|
+
},
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
// Validate the transition is allowed
|
|
777
|
+
const transition = (0, webhooks_1.validateStatusTransition)(row.status, 'cancelled');
|
|
778
|
+
if (!transition.ok) {
|
|
779
|
+
throw new errors_1.VivaValidationError({
|
|
780
|
+
message: `Cannot cancel transaction '${row.viva_transaction_id}': ` +
|
|
781
|
+
`status '${row.status}' → 'cancelled' is not allowed (${transition.reason}). ` +
|
|
782
|
+
`Status lattice plan P17/A9.`,
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
// Call Viva cancelOrder if order code is available.
|
|
786
|
+
// - ISV mode: pass merchantId from the stored row → DELETE /checkout/v2/orders/{oc}?merchantId={uuid}.
|
|
787
|
+
// - Merchant mode: omit merchantId → DELETE /checkout/v2/orders/{oc}.
|
|
788
|
+
// Payments silently ignores opts.merchantId in merchant mode anyway.
|
|
789
|
+
if (row.viva_order_code) {
|
|
790
|
+
const cancelOpts = this.vivaConfig.mode === 'isv'
|
|
791
|
+
? { merchantId: row.viva_merchant_id }
|
|
792
|
+
: {};
|
|
793
|
+
await this.isvPayments.cancelOrder(BigInt(row.viva_order_code), cancelOpts);
|
|
794
|
+
}
|
|
795
|
+
row.status = transition.next;
|
|
796
|
+
await this.em.flush();
|
|
797
|
+
return {
|
|
798
|
+
data: {
|
|
799
|
+
...(input.data ?? {}),
|
|
800
|
+
viva_transaction_id: row.viva_transaction_id,
|
|
801
|
+
order_code: row.viva_order_code ?? null,
|
|
802
|
+
viva_status: 'cancelled',
|
|
803
|
+
},
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
catch (err) {
|
|
807
|
+
throw toMedusaError(err);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
// --------------------------------------------------------------------------
|
|
811
|
+
// retrievePayment
|
|
812
|
+
// --------------------------------------------------------------------------
|
|
813
|
+
/**
|
|
814
|
+
* Returns the serialized viva_transaction row for the given payment.
|
|
815
|
+
*
|
|
816
|
+
* @see references/viva-docs/md/isv-partner-program.txt:104
|
|
817
|
+
*/
|
|
818
|
+
async retrievePayment(input) {
|
|
819
|
+
try {
|
|
820
|
+
const medusaPaymentId = extractPaymentId(input.data);
|
|
821
|
+
const repo = this.em.getRepository('VivaTransaction');
|
|
822
|
+
const row = medusaPaymentId
|
|
823
|
+
? await repo.findOne({ medusa_payment_id: medusaPaymentId })
|
|
824
|
+
: null;
|
|
825
|
+
if (!row) {
|
|
826
|
+
return { data: {} };
|
|
827
|
+
}
|
|
828
|
+
return {
|
|
829
|
+
data: {
|
|
830
|
+
viva_transaction_id: row.viva_transaction_id,
|
|
831
|
+
order_code: row.viva_order_code ?? null,
|
|
832
|
+
medusa_payment_id: row.medusa_payment_id,
|
|
833
|
+
status: row.status,
|
|
834
|
+
amount_minor: row.amount_minor,
|
|
835
|
+
refunded_amount_minor: row.refunded_amount_minor,
|
|
836
|
+
currency_code: row.currency_code,
|
|
837
|
+
created_at: row.created_at.toISOString(),
|
|
838
|
+
updated_at: row.updated_at.toISOString(),
|
|
839
|
+
},
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
catch (err) {
|
|
843
|
+
throw toMedusaError(err);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
// --------------------------------------------------------------------------
|
|
847
|
+
// getPaymentStatus
|
|
848
|
+
// --------------------------------------------------------------------------
|
|
849
|
+
/**
|
|
850
|
+
* Returns the current Medusa-mapped payment status from the DB.
|
|
851
|
+
*
|
|
852
|
+
* @see references/viva-docs/md/isv-partner-program.txt:104
|
|
853
|
+
*/
|
|
854
|
+
async getPaymentStatus(input) {
|
|
855
|
+
try {
|
|
856
|
+
const medusaPaymentId = extractPaymentId(input.data);
|
|
857
|
+
const repo = this.em.getRepository('VivaTransaction');
|
|
858
|
+
const row = medusaPaymentId
|
|
859
|
+
? await repo.findOne({ medusa_payment_id: medusaPaymentId })
|
|
860
|
+
: null;
|
|
861
|
+
if (!row) {
|
|
862
|
+
return { status: 'pending' };
|
|
863
|
+
}
|
|
864
|
+
return {
|
|
865
|
+
status: toMedusaStatus(row.status),
|
|
866
|
+
data: {
|
|
867
|
+
viva_transaction_id: row.viva_transaction_id,
|
|
868
|
+
viva_status: row.status,
|
|
869
|
+
},
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
catch (err) {
|
|
873
|
+
throw toMedusaError(err);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
// --------------------------------------------------------------------------
|
|
877
|
+
// deletePayment
|
|
878
|
+
// --------------------------------------------------------------------------
|
|
879
|
+
/**
|
|
880
|
+
* Logical-only deletion — does NOT delete the Viva transaction (audit trail).
|
|
881
|
+
* The viva_transaction row is preserved for forensics and idempotency replay.
|
|
882
|
+
*
|
|
883
|
+
* @see references/viva-docs/md/isv-partner-program.txt:104
|
|
884
|
+
*/
|
|
885
|
+
async deletePayment(input) {
|
|
886
|
+
try {
|
|
887
|
+
// Logical delete only — no Viva API call, no DB row deletion.
|
|
888
|
+
// Audit trail must be preserved per plan design. Mode-agnostic.
|
|
889
|
+
return { data: input.data ?? {} };
|
|
890
|
+
}
|
|
891
|
+
catch (err) {
|
|
892
|
+
throw toMedusaError(err);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
// --------------------------------------------------------------------------
|
|
896
|
+
// updatePayment
|
|
897
|
+
// --------------------------------------------------------------------------
|
|
898
|
+
/**
|
|
899
|
+
* No-op for Smart Checkout: Viva orders cannot be updated after creation.
|
|
900
|
+
* If amount changes, caller should cancel and re-initiate.
|
|
901
|
+
*
|
|
902
|
+
* @see references/viva-docs/md/payment-isv-api.txt:1 (order immutability)
|
|
903
|
+
*/
|
|
904
|
+
async updatePayment(input) {
|
|
905
|
+
try {
|
|
906
|
+
// Smart Checkout orders are immutable once created. Mode-agnostic.
|
|
907
|
+
// TODO(impl): log a warning if input.amount differs from the stored amount,
|
|
908
|
+
// advising the operator to cancel and re-initiate.
|
|
909
|
+
return {
|
|
910
|
+
data: input.data ?? {},
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
catch (err) {
|
|
914
|
+
throw toMedusaError(err);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
// --------------------------------------------------------------------------
|
|
918
|
+
// getWebhookActionAndData
|
|
919
|
+
// --------------------------------------------------------------------------
|
|
920
|
+
/**
|
|
921
|
+
* Processes Viva webhook events received via the /viva/webhook route (S8).
|
|
922
|
+
* S8 routes the verified webhook payload here.
|
|
923
|
+
*
|
|
924
|
+
* Viva webhook event types in v1 scope:
|
|
925
|
+
* 1796 - Transaction Payment Created → action: 'captured'
|
|
926
|
+
* 1797 - Transaction Reversal Created → action: 'authorized' (refund)
|
|
927
|
+
* 1798 - Transaction Failed → action: 'failed'
|
|
928
|
+
* 4865 - Order Updated (cancellation) → action: 'canceled'
|
|
929
|
+
*
|
|
930
|
+
* NOTE: This is the minimal implementation for S6. The full webhook processing
|
|
931
|
+
* subscriber (status lattice updates, Retrieve Transaction API call per plan
|
|
932
|
+
* step 4.a) is implemented in S8.
|
|
933
|
+
*
|
|
934
|
+
* @see references/viva-docs/md/isv-partner-program.txt:104 (webhook flow)
|
|
935
|
+
* @see references/viva-docs/md/webhooks-for-payments.txt:248 (retrieve before update)
|
|
936
|
+
*/
|
|
937
|
+
async getWebhookActionAndData(data) {
|
|
938
|
+
try {
|
|
939
|
+
const eventData = data.data;
|
|
940
|
+
const eventTypeId = eventData?.['EventTypeId'];
|
|
941
|
+
const innerEventData = eventData?.['EventData'];
|
|
942
|
+
const sessionId = (innerEventData?.['MerchantTrns'] ??
|
|
943
|
+
eventData?.['session_id']);
|
|
944
|
+
// Map Viva event types to Medusa actions
|
|
945
|
+
// TODO(impl): full webhook processing in S8 — this is a minimal stub
|
|
946
|
+
// @see references/viva-docs/md/isv-partner-program.txt:104 (webhook event types)
|
|
947
|
+
switch (eventTypeId) {
|
|
948
|
+
case 1796: // Transaction Payment Created
|
|
949
|
+
return {
|
|
950
|
+
action: 'captured',
|
|
951
|
+
data: {
|
|
952
|
+
session_id: sessionId ?? '',
|
|
953
|
+
amount: 0,
|
|
954
|
+
},
|
|
955
|
+
};
|
|
956
|
+
case 1797: // Transaction Reversal Created
|
|
957
|
+
return {
|
|
958
|
+
action: 'authorized',
|
|
959
|
+
data: {
|
|
960
|
+
session_id: sessionId ?? '',
|
|
961
|
+
amount: 0,
|
|
962
|
+
},
|
|
963
|
+
};
|
|
964
|
+
case 1798: // Transaction Failed
|
|
965
|
+
return {
|
|
966
|
+
action: 'failed',
|
|
967
|
+
data: {
|
|
968
|
+
session_id: sessionId ?? '',
|
|
969
|
+
amount: 0,
|
|
970
|
+
},
|
|
971
|
+
};
|
|
972
|
+
case 4865: // Order Updated (cancellation)
|
|
973
|
+
return {
|
|
974
|
+
action: 'canceled',
|
|
975
|
+
data: {
|
|
976
|
+
session_id: sessionId ?? '',
|
|
977
|
+
amount: 0,
|
|
978
|
+
},
|
|
979
|
+
};
|
|
980
|
+
default:
|
|
981
|
+
return { action: 'not_supported' };
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
catch (err) {
|
|
985
|
+
return { action: 'failed' };
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
exports.VivaPaymentProvider = VivaPaymentProvider;
|
|
990
|
+
// ---------------------------------------------------------------------------
|
|
991
|
+
// Helper: extract medusa_payment_id from provider data
|
|
992
|
+
// ---------------------------------------------------------------------------
|
|
993
|
+
/**
|
|
994
|
+
* Extracts the medusa_payment_id from the provider data bag.
|
|
995
|
+
* Checks common keys set by our provider in previous calls.
|
|
996
|
+
*/
|
|
997
|
+
function extractPaymentId(data) {
|
|
998
|
+
if (!data)
|
|
999
|
+
return undefined;
|
|
1000
|
+
return (data['medusa_payment_id'] ??
|
|
1001
|
+
data['session_id']);
|
|
1002
|
+
}
|
|
1003
|
+
//# sourceMappingURL=service.js.map
|