Compare commits

...
Author SHA1 Message Date
devopsandClaude Sonnet 5 c507da8abc feat(fulfillment): integrar EnvíoClick para cálculo real de envío
ADR 0009 §2 ya había anticipado esto: la shipping option actual es un
placeholder de negocio ($15.000 COP fijo), no un cálculo real por
peso/volumen -- y el modelo de direcciones del checkout (IDs DIVIPOLA
en vez de texto libre en lib/data/colombia.ts) ya se había construido
pensando en esta integración. Este commit la implementa.

Investigación en vivo (no solo documentación -- misma disciplina que
ya costó varios rehechos con las pasarelas de pago): la SPA de docs
públicas (apidoc.envioclickpro.com.co) no renderiza sin sesión, pero
está generada con apiDoc, así que se bajó y leyó directo su
api_data.json/api_project.json (spec completa real), y se probó
/quotation en vivo con la API key real del usuario -- 200 OK con
tarifas reales de PIBOX/COORDINADORA/ENVIA/RAPPI/TCC. Esto corrigió
varios datos mal asumidos de un plugin de WordPress desactualizado
usado como referencia inicial (API v1): la URL base real es
api.envioclickpro.com.co/api/v2 (no api.envioclickpro.com a secas), y
el header de auth real es "Authorization" a secas, no
"AuthorizationKey".

Hallazgo crítico evitado leyendo el código fuente de
@medusajs/utils/dist/common/define-config.js: defineConfig() ya
registra por defecto un módulo de fulfillment con el provider
manual_manual, aunque medusa-config.ts no lo mencione explícitamente
-- pero transformModules() indexa por serviceName y el último que
llega pisa al anterior (no mezcla providers). Agregar un bloque propio
de fulfillment sin incluir también manual_manual ahí habría roto en
silencio el envío manual que ya usa la shipping option existente.
Verificado cargando la config compilada real: ambos providers quedan
registrados juntos.

- src/modules/envioclick-fulfillment/: AbstractFulfillmentProviderService.
  calculatePrice() cotiza en vivo durante el checkout -- Medusa advierte
  explícitamente que un error acá bloquea el checkout completo, así que
  NUNCA se deja propagar una excepción: cualquier falla (red, dirección
  sin id DIVIPOLA, EnvíoClick caído) cae al precio fijo de $15.000 que
  ya se usaba, para no romper la compra. createFulfillment() vuelve a
  cotizar en el momento de generar la guía real en vez de reusar el
  idRate mostrado en checkout (más correcto: CalculatedShippingOptionPrice
  no tiene forma de persistir ese id entre llamadas, confirmado leyendo
  el tipo real, y un idRate viejo podría estar vencido si el fulfillment
  pasa horas/días después). cancelFulfillment() sí cancela de verdad
  (cancellation/batch/order existe y está confirmado, a diferencia del
  refund de ePayco que quedó sin implementar).
- Solo 1 paquete por cotización/guía (límite real de la API, confirmado:
  "min 1, max 1") -- el carrito se agrega a un solo paquete. Como hoy
  NINGÚN producto tiene peso/dimensiones cargados (confirmado en vivo:
  18 productos activos, 0 con weight), se usa un paquete de fallback
  conservador completo en vez de mezclar datos reales con inventados a
  medias -- la cotización no va a ser precisa hasta que se carguen esos
  datos (tarea de datos, no de código, fuera de alcance de este commit).
- Frontend: cambio chico pero necesario en checkout-client.tsx/
  medusa-cart.ts -- el checkout mandaba el nombre de la ciudad a Medusa,
  nunca el id DIVIPOLA (aunque el form sí lo tenía disponible). Hay
  municipios con el mismo nombre en distintos departamentos de Colombia,
  así que el nombre solo no alcanza para identificar la ciudad sin
  ambigüedad -- se guarda el id real en la metadata de la dirección
  (Medusa sí tiene ese campo), que el fulfillment provider lee para
  derivar el código DANE real que pide EnvíoClick (5 dígitos + "000" de
  relleno, confirmado contra su propio ejemplo: "11001000" = Bogotá).

Pendiente de verificar con una llamada real (no se quiso generar una
guía de prueba en la cuenta real del usuario sin avisar): /shipment_sandbox
y cancellation/batch/order, verificados contra la spec oficial real
pero no probados en vivo todavía.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
2026-09-08 21:11:43 +00:00
devops 4992da394b Merge fix/epayco-package-lock into main
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 23s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 23m24s
2026-09-07 04:17:03 +00:00
devopsandClaude Sonnet 5 f2e0c02013 fix(commerce-backend): regenerar package-lock.json con epayco-sdk-node
El commit anterior agregó epayco-sdk-node a package.json pero el
lockfile nunca se actualizó (se generó localmente con
--package-lock=false para evitar un problema no relacionado del
registry en este entorno) -- npm ci en el Dockerfile exige que el
lockfile esté sincronizado con package.json y rompió el build en CI.
Regenerado con npm install normal; confirmado que npm ci (el comando
real del Dockerfile) ahora sí instala limpio.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
2026-09-07 04:16:52 +00:00
devops d4716382d3 Merge fix/epayco-pse-reemplaza-payu into main
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 30s
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 23s
Build and Push Medusa / Construir y publicar Medusa (push) Failing after 4m19s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 18m42s
2026-09-07 03:58:44 +00:00
devopsandClaude Sonnet 5 12c4afa09c fix(payments): reemplazar PayU LatAm por ePayco como pasarela PSE
PayU LatAm (integrado en el commit anterior) resultó inviable: Rapyd
compró ese negocio completo en LatAm/África en 2023, y el registro real
del usuario quedó en la plataforma de Rapyd (board.rapyd.net,
credenciales access_key/secret_key incompatibles con el módulo ya
construido, API completamente distinta). Se evaluó reconstruir contra
Rapyd, pero el usuario comparó costos para su volumen real (~8
ventas/mes) y encontró que ePayco cobra menos en PSE. Se investigó
ePayco en vivo: es 100% de Davivienda desde 2024, acepta liquidación a
cualquier banco colombiano (no exige Bancolombia/Nequi como Wompi) y da
tarifa preferencial a cuentas Davivienda -- el usuario ya tiene una.
Mejor encaje de negocio y más barato que PayU/Rapyd. Confirmado como
elección definitiva.

- src/modules/epayco-pse/ reemplaza a src/modules/payu-pse/: mismo
  contrato AbstractPaymentProvider (initiatePayment/authorizePayment
  "pending_authorization"/getWebhookActionAndData con firma SHA256
  verificada), pero envuelve el SDK oficial `epayco-sdk-node` en vez de
  llamar la API cruda -- leyendo el código fuente del SDK (no la
  documentación pública, que resultó desactualizada en más de un campo)
  se confirmó que el protocolo real de ePayco para PSE es bastante más
  particular de lo que sugieren los ejemplos: cada campo del payload se
  cifra con AES usando la private key del comercio, más un login previo
  que da un bearer token que también viaja en la misma request.
  Reimplementar esto a mano tenía mucho más riesgo de error que delegar
  en el SDK mantenido por el propio proveedor.
- Corregido en el camino: el método real para listar bancos es
  `bank.getBanks()`, no `pseBank()` (así aparecía en un resumen de
  búsqueda, pero no existe en el SDK real -- confirmado leyendo
  node_modules/epayco-sdk-node/lib/resources/bank.js directamente). El
  campo real para la URL de confirmación es `method_confirmation`
  (con guión bajo en medio), no `metodoconfirmacion` como mostraba la
  documentación pública -- confirmado contra tests/bank.js del propio
  paquete.
- retrievePayment sí se implementó esta vez (endpoint de validación de
  ePayco documentado); refundPayment sigue sin implementar -- ese
  endpoint no quedó verificado en vivo, tira error explícito en vez de
  fingir éxito.
- Frontend: el step de pago recupera el selector de banco + tipo de
  persona + documento (a diferencia del diseño que se había planeado
  para Rapyd, donde esos datos se recolectaban en la página del propio
  Rapyd/SafetyPay) -- ePayco sí necesita esos campos de nuestro lado,
  como PayU.
- Dos puntos quedan sin verificar contra un sandbox real (anotados en
  el código con qué hacer si fallan): el nombre exacto del campo con la
  URL del banco en la respuesta de bank.create() (initiatePayment tira
  un error con el payload completo si no matchea ningún candidato
  probado), y si p_cust_id_cliente/p_key del webhook son iguales a las
  API keys o valores aparte del dashboard de ePayco.

Verificado: build real de Medusa con las 7 env vars EPAYCO_* dummy,
smoke test del wrapper del SDK cargando y la verificación de firma
dando el resultado esperado contra un caso conocido, typecheck+lint+
build completo del frontend.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01THZFwPzrwv6VAbuwmaf5vR
2026-09-07 03:58:33 +00:00
gitea-actions f0b4245676 chore(gitops): deploy Medusa v1.0.229 [skip ci] 2026-09-07 02:24:57 +00:00
devops e9e78e64c1 chore: release v1.0.230 [skip ci] 2026-09-07 02:02:25 +00:00
devops 95cf791737 Merge feat/payu-pse-pago into main
Build and Push Frontend / Escaneo de Secretos (Gitleaks) (push) Successful in 1m8s
Build and Push Frontend / Construir y Subir Imagen (push) Successful in 14m42s
Build and Push Medusa / Escaneo de Secretos (Gitleaks) (push) Successful in 22s
Build and Push Medusa / Construir y publicar Medusa (push) Successful in 19m56s
2026-09-07 01:18:06 +00:00
devops 4d1804eeeb chore: release v1.0.228 [skip ci] 2026-09-06 21:46:27 +00:00
gitea-actions 9b8b96fd02 chore(gitops): deploy Medusa v1.0.227 [skip ci] 2026-09-06 21:31:24 +00:00
25 changed files with 1265 additions and 513 deletions

No files matched your search

+16 -7
View File
@@ -27,13 +27,22 @@ ENV CI=true \
GOOGLE_CLIENT_ID=build-only-google-client-id \
GOOGLE_CLIENT_SECRET=build-only-google-client-secret \
GOOGLE_CALLBACK_URL=http://localhost:9000/auth/customer/google/callback \
PAYU_API_LOGIN=build-only-payu-login \
PAYU_API_KEY=build-only-payu-key \
PAYU_MERCHANT_ID=999999 \
PAYU_ACCOUNT_ID=999999 \
PAYU_ENV=sandbox \
PAYU_RESPONSE_URL=http://localhost:8000/checkout/pse/return \
PAYU_NOTIFY_URL=http://localhost:9000/hooks/payment/payu_pse
EPAYCO_PUBLIC_KEY=build-only-epayco-public \
EPAYCO_PRIVATE_KEY=build-only-epayco-private \
EPAYCO_P_CUST_ID_CLIENTE=build-only-epayco-pcust \
EPAYCO_P_KEY=build-only-epayco-pkey \
EPAYCO_ENV=sandbox \
EPAYCO_RESPONSE_URL=http://localhost:8000/checkout/pse/return \
EPAYCO_NOTIFY_URL=http://localhost:9000/hooks/payment/epayco_pse \
ENVIOCLICK_API_KEY=build-only-envioclick-key \
ENVIOCLICK_SANDBOX=true \
ENVIOCLICK_ORIGIN_DANE_CODE=11001000 \
ENVIOCLICK_ORIGIN_ADDRESS="Calle 1 No 1-1" \
ENVIOCLICK_ORIGIN_COMPANY="ARI Shopping" \
ENVIOCLICK_ORIGIN_FIRST_NAME=ARI \
ENVIOCLICK_ORIGIN_LAST_NAME=Shopping \
ENVIOCLICK_ORIGIN_EMAIL=[email protected] \
ENVIOCLICK_ORIGIN_PHONE=3000000000
COPY package.json package-lock.json .npmrc ./
+59 -14
View File
@@ -133,6 +133,45 @@ module.exports = defineConfig({
},
modules: [
// ADR 0009 §2 / EnvíoClick: defineConfig() ya registra por defecto
// un modulo de fulfillment con el provider manual_manual
// (confirmado leyendo @medusajs/utils/dist/common/define-config.js
// directo) -- pero como este bloque propio comparte el mismo tipo de
// modulo, transformModules() lo pisa por completo (indexa por
// serviceName, el ultimo que llega gana), no lo mezcla. Por eso
// manual_manual va explicito ACA tambien: si se omitiera, la
// shipping option existente que ya usa ese provider se rompe en
// silencio. envioclick-fulfillment implementa el mismo contrato
// (AbstractFulfillmentProviderService) contra
// developers.envioclickpro.com.co, verificado en vivo (no solo doc)
// con una llamada de prueba real que devolvio tarifas reales de
// varias transportadoras.
{
resolve: "@medusajs/medusa/fulfillment",
options: {
providers: [
{
resolve: "@medusajs/medusa/fulfillment-manual",
id: "manual",
},
{
resolve: "./src/modules/envioclick-fulfillment",
id: "standard",
options: {
apiKey: required("ENVIOCLICK_API_KEY"),
sandbox: process.env.ENVIOCLICK_SANDBOX !== "false",
originDaneCode: process.env.ENVIOCLICK_ORIGIN_DANE_CODE || "11001000",
originAddress: required("ENVIOCLICK_ORIGIN_ADDRESS"),
originCompany: process.env.ENVIOCLICK_ORIGIN_COMPANY || "ARI Shopping",
originFirstName: process.env.ENVIOCLICK_ORIGIN_FIRST_NAME || "ARI",
originLastName: process.env.ENVIOCLICK_ORIGIN_LAST_NAME || "Shopping",
originEmail: required("ENVIOCLICK_ORIGIN_EMAIL"),
originPhone: required("ENVIOCLICK_ORIGIN_PHONE"),
},
},
],
},
},
{
resolve: "@medusajs/medusa/file",
options: {
@@ -181,28 +220,34 @@ module.exports = defineConfig({
],
},
},
// ADR 0009: PayU LatAm elegido como proveedor de pago -- no existe
// ningun plugin real de Medusa para su API (los paquetes que
// aparecen buscando "PayU" son para PayU Polonia/CEE, API distinta
// por completo), asi que es un modulo custom propio. Alcance inicial:
// solo PSE (transferencia bancaria), no Nequi/Daviplata/tarjetas.
// provider_id resultante: "pp_payu_pse" (identifier "payu" + id
// ADR 0009: proveedor de pago -- tercera vuelta. PayU LatAm fue la
// decision original, pero Rapyd compro ese negocio en 2023 y el
// registro real del usuario quedo con credenciales de Rapyd
// (incompatibles, API completamente distinta). Se evaluo Rapyd en
// vivo y se termino descartando: comision mas alta que otras
// opciones para el volumen real del usuario. ePayco elegido en su
// lugar -- 100% de Davivienda desde 2024, comision PSE mas baja,
// tarifa preferencial con cuenta Davivienda (que el usuario ya
// tiene), sin exigir Bancolombia/Nequi como si exige Wompi. Tampoco
// existe plugin real de Medusa para ePayco, modulo custom propio.
// Alcance inicial: solo PSE, no tarjetas/otros metodos.
// provider_id resultante: "pp_epayco_pse" (identifier "epayco" + id
// "pse", convencion de Medusa).
{
resolve: "@medusajs/medusa/payment",
options: {
providers: [
{
resolve: "./src/modules/payu-pse",
resolve: "./src/modules/epayco-pse",
id: "pse",
options: {
apiLogin: required("PAYU_API_LOGIN"),
apiKey: required("PAYU_API_KEY"),
merchantId: required("PAYU_MERCHANT_ID"),
accountId: required("PAYU_ACCOUNT_ID"),
env: process.env.PAYU_ENV || "sandbox",
responseUrl: required("PAYU_RESPONSE_URL"),
notifyUrl: required("PAYU_NOTIFY_URL"),
publicKey: required("EPAYCO_PUBLIC_KEY"),
privateKey: required("EPAYCO_PRIVATE_KEY"),
pCustIdCliente: required("EPAYCO_P_CUST_ID_CLIENTE"),
pKey: required("EPAYCO_P_KEY"),
env: process.env.EPAYCO_ENV || "sandbox",
responseUrl: required("EPAYCO_RESPONSE_URL"),
notifyUrl: required("EPAYCO_NOTIFY_URL"),
},
},
],
+213 -1
View File
@@ -13,7 +13,8 @@
"@medusajs/cli": "2.18.0",
"@medusajs/framework": "2.18.0",
"@medusajs/medusa": "2.18.0",
"@rokmohar/medusa-plugin-meilisearch": "^1.4.1"
"@rokmohar/medusa-plugin-meilisearch": "^1.4.1",
"epayco-sdk-node": "^1.4.4"
},
"devDependencies": {
"@swc/core": "^1.7.28",
@@ -9200,6 +9201,15 @@
"node": ">=4.0.0"
}
},
"node_modules/component-emitter": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz",
"integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/compressible": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
@@ -9363,6 +9373,12 @@
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/cookiejar": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz",
"integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
"license": "MIT"
},
"node_modules/copy-to-clipboard": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz",
@@ -9372,6 +9388,12 @@
"toggle-selection": "^1.0.6"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
@@ -9434,6 +9456,13 @@
"node": ">= 8"
}
},
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"deprecated": "Active development of CryptoJS has been discontinued. This library is no longer maintained.",
"license": "MIT"
},
"node_modules/crypto-random-string": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz",
@@ -9806,6 +9835,35 @@
"node": ">= 0.8"
}
},
"node_modules/epayco-sdk-node": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/epayco-sdk-node/-/epayco-sdk-node-1.4.4.tgz",
"integrity": "sha512-i6SpQru6Hwp096yxAVaD4lsV76ASB0cqAbJsvXQoc0leCsePHRjetV/9C1AASw8ZFbnkmTajw9DhWsAvjdTEFw==",
"license": "MIT",
"dependencies": {
"axios": "^1.8.2",
"crypto-js": "^4.2.0",
"debug": "2.6.9",
"dotenv": "^16.3.1",
"node-fetch": "^2.6.0",
"superagent": "^3.7.0"
}
},
"node_modules/epayco-sdk-node/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/epayco-sdk-node/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -10099,6 +10157,12 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -10292,6 +10356,16 @@
"node": ">= 6"
}
},
"node_modules/formidable": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.6.tgz",
"integrity": "sha512-KcpbcpuLNOwrEjnbpMC0gS+X8ciDoZE1kkqzat4a8vrprf+s9pKNQ/QIwWfbfs4ltgmFl3MD177SNTkve3BwGQ==",
"deprecated": "Please upgrade to latest, formidable@v2 or formidable@v3! Check these notes: https://bit.ly/2ZEqIau",
"license": "MIT",
"funding": {
"url": "https://ko-fi.com/tunnckoCore/commissions"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -11207,6 +11281,12 @@
"node": ">=0.10.0"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -12164,6 +12244,26 @@
"node": ">= 8.0.0"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-gyp-build-optional-packages": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
@@ -12924,6 +13024,12 @@
"node": ">=6"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/prompts": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
@@ -14354,6 +14460,90 @@
"node": ">= 6"
}
},
"node_modules/superagent": {
"version": "3.8.3",
"resolved": "https://registry.npmjs.org/superagent/-/superagent-3.8.3.tgz",
"integrity": "sha512-GLQtLMCoEIK4eDv6OGtkOoSMt3D+oq0y3dsxMuYuDvaNUvuT8eFBuLmfR0iYYzHC1e8hpzC6ZsxbuP6DIalMFA==",
"deprecated": "Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net",
"license": "MIT",
"dependencies": {
"component-emitter": "^1.2.0",
"cookiejar": "^2.1.0",
"debug": "^3.1.0",
"extend": "^3.0.0",
"form-data": "^2.3.1",
"formidable": "^1.2.0",
"methods": "^1.1.1",
"mime": "^1.4.1",
"qs": "^6.5.1",
"readable-stream": "^2.3.5"
},
"engines": {
"node": ">= 4.0"
}
},
"node_modules/superagent/node_modules/debug": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.1"
}
},
"node_modules/superagent/node_modules/form-data": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.6.tgz",
"integrity": "sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.4",
"mime-types": "^2.1.35",
"safe-buffer": "^5.2.1"
},
"engines": {
"node": ">= 0.12"
}
},
"node_modules/superagent/node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/superagent/node_modules/readable-stream/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/superagent/node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/superagent/node_modules/string_decoder/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -14635,6 +14825,12 @@
"node": ">=0.6"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/triple-beam": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz",
@@ -15099,6 +15295,22 @@
"defaults": "^1.0.3"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+2 -1
View File
@@ -15,7 +15,8 @@
"@medusajs/cli": "2.18.0",
"@medusajs/framework": "2.18.0",
"@medusajs/medusa": "2.18.0",
"@rokmohar/medusa-plugin-meilisearch": "^1.4.1"
"@rokmohar/medusa-plugin-meilisearch": "^1.4.1",
"epayco-sdk-node": "^1.4.4"
},
"devDependencies": {
"@swc/core": "^1.7.28",
@@ -1,15 +1,19 @@
import type { MedusaRequest, MedusaResponse } from "@medusajs/framework";
import { fetchPseBanks, type PayuBank, type PayuEnv } from "../../../../lib/payu";
import { createEpaycoClient, type EpaycoEnv } from "../../../../lib/epayco";
// Lista de bancos PSE -- no hay forma de pedirle esto al modulo de pago
// custom directamente (AbstractPaymentProvider no expone sus metodos
// internos fuera del contrato de pago), asi que esta ruta llama a PayU
// con las mismas credenciales de medusa-config.ts. Cache en memoria (sin
// Redis/cron) porque PayU solo pide refrescar "una vez al dia" -- mismo
// criterio de simplicidad que ya usa la seccion de Meilisearch en
// medusa-config.ts.
// internos fuera del contrato de pago), asi que esta ruta llama a
// ePayco con las mismas credenciales de medusa-config.ts. Cache en
// memoria (sin Redis/cron), mismo criterio de simplicidad que ya usa la
// seccion de Meilisearch en medusa-config.ts.
//
// El shape exacto de la respuesta de bank.getBanks() no quedo verificado
// contra el sandbox real en esta sesion -- se prueban los nombres de
// campo mas probables (bankCode/bankName y variantes) y se degrada a
// lista vacia si no matchea nada, en vez de romper el checkout entero.
const CACHE_TTL_MS = 6 * 60 * 60 * 1000;
let cache: { banks: PayuBank[]; fetchedAt: number } | null = null;
let cache: { banks: Array<{ pseCode: string; description: string }>; fetchedAt: number } | null = null;
export async function GET(req: MedusaRequest, res: MedusaResponse) {
if (cache && Date.now() - cache.fetchedAt < CACHE_TTL_MS) {
@@ -18,20 +22,31 @@ export async function GET(req: MedusaRequest, res: MedusaResponse) {
}
try {
const banks = await fetchPseBanks({
apiLogin: process.env.PAYU_API_LOGIN ?? "",
apiKey: process.env.PAYU_API_KEY ?? "",
merchantId: process.env.PAYU_MERCHANT_ID ?? "",
accountId: process.env.PAYU_ACCOUNT_ID ?? "",
env: (process.env.PAYU_ENV as PayuEnv) || "sandbox",
const client = createEpaycoClient({
publicKey: process.env.EPAYCO_PUBLIC_KEY ?? "",
privateKey: process.env.EPAYCO_PRIVATE_KEY ?? "",
pCustIdCliente: process.env.EPAYCO_P_CUST_ID_CLIENTE ?? "",
pKey: process.env.EPAYCO_P_KEY ?? "",
env: (process.env.EPAYCO_ENV as EpaycoEnv) || "sandbox",
});
const response = await client.bank.getBanks();
const rawBanks = (response.data ?? response.banks ?? response) as unknown;
const list = Array.isArray(rawBanks) ? rawBanks : [];
const banks = list
.map((bank) => {
const record = bank as Record<string, unknown>;
const pseCode = record.bankCode ?? record.bank_code ?? record.codigo ?? record.id;
const description = record.bankName ?? record.bank_name ?? record.nombre ?? record.name ?? record.description;
return typeof pseCode === "string" || typeof pseCode === "number"
? { pseCode: String(pseCode), description: String(description ?? pseCode) }
: null;
})
.filter((bank): bank is { pseCode: string; description: string } => bank !== null);
cache = { banks, fetchedAt: Date.now() };
res.json({ banks });
} catch (error) {
// Degrada a lista vacia en vez de tirar 500 -- el checkout debe poder
// mostrar un mensaje de "no disponible" en vez de romper la pagina
// entera, mismo criterio que /store/search cuando Meilisearch no
// responde.
res.json({ banks: cache?.banks ?? [], error: (error as Error).message });
}
}
@@ -0,0 +1,179 @@
// EnvíoClick (ADR 0009 §2, ya anticipado desde el modelo de direcciones
// del checkout): cotización y generación de guía real por peso/volumen,
// reemplazando la tarifa fija de $15.000 COP. Contrato REST confirmado en
// vivo contra la cuenta real del usuario (apiDoc real,
// apidoc.envioclickpro.com.co/api_data.json -- la SPA de la doc no
// renderiza sin JS, pero ese archivo generado por apidoc trae la spec
// completa) y una llamada de prueba real que devolvió 200 con tarifas
// reales de PIBOX/COORDINADORA/ENVIA/RAPPI/TCC.
const BASE_URL = "https://api.envioclickpro.com.co/api/v2";
export interface EnvioClickCredentials {
apiKey: string;
}
export interface EnvioClickPackage {
weight: number;
height: number;
length: number;
// "widht" es un typo real de la API (confirmado en el body de
// /shipment de la doc oficial), no un error nuestro -- solo se usa acá,
// en el request de /quotation el campo si se llama "width" (confirmado
// tambien contra la doc real, son dos endpoints con esa inconsistencia).
width: number;
}
export interface EnvioClickRate {
idRate: number;
idProduct: number;
product: string;
idCarrier: number;
carrier: string;
flete: number;
minimumInsurance: number;
extraInsurance: number;
deliveryDays: number;
cod: boolean;
}
export interface EnvioClickAddress {
daneCode: string;
address: string;
}
async function envioclickRequest(
credentials: EnvioClickCredentials,
path: string,
body: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const response = await fetch(`${BASE_URL}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
// Header real confirmado contra la doc oficial y una llamada en
// vivo -- "Authorization" a secas (sin "Bearer"), NO
// "AuthorizationKey" como usaba el plugin de WordPress de
// referencia (ese era para la API v1, desactualizada).
Authorization: credentials.apiKey,
},
body: JSON.stringify(body),
});
const text = await response.text();
let json: Record<string, unknown>;
try {
json = JSON.parse(text);
} catch {
throw new Error(`Respuesta no-JSON de EnvíoClick (HTTP ${response.status}): ${text.slice(0, 300)}`);
}
if (json.status !== "OK") {
throw new Error(`EnvíoClick respondió con error: ${JSON.stringify(json).slice(0, 400)}`);
}
return json;
}
// Solo 1 paquete por cotizacion (limite real documentado y confirmado:
// "min 1, max 1" -- cada vez menos transportadoras ofrecen multi-paquete).
export async function quoteShipment(
credentials: EnvioClickCredentials,
params: {
pkg: EnvioClickPackage;
description: string;
contentValue: number;
origin: EnvioClickAddress;
destination: EnvioClickAddress;
},
): Promise<EnvioClickRate[]> {
const json = await envioclickRequest(credentials, "/quotation", {
packages: [params.pkg],
description: params.description,
contentValue: params.contentValue,
origin: params.origin,
destination: params.destination,
});
const data = json.data as Record<string, unknown>;
return (data.rates as EnvioClickRate[]) ?? [];
}
export interface EnvioClickPerson {
company: string;
firstName: string;
lastName: string;
email: string;
phone: string;
address: string;
suburb: string;
crossStreet: string;
reference: string;
daneCode: string;
}
export interface CreateGuideResult {
guide: string;
url: string;
tracker: string;
idOrder: number;
}
export async function createGuide(
credentials: EnvioClickCredentials,
sandbox: boolean,
params: {
idRate: number;
myShipmentReference: string;
externalOrderId: string;
pkg: EnvioClickPackage;
description: string;
contentValue: number;
origin: EnvioClickPerson;
destination: EnvioClickPerson;
},
): Promise<CreateGuideResult> {
// /shipment_sandbox es el UNICO endpoint con modo de pruebas real (la
// cotizacion siempre usa tarifas reales de las transportadoras, no
// tiene sandbox) -- confirmado en la doc oficial ("Deberás cambiar a
// la URL de producción para que puedas generar guías reales").
const path = sandbox ? "/shipment_sandbox" : "/shipment";
const json = await envioclickRequest(credentials, path, {
idRate: params.idRate,
myShipmentReference: params.myShipmentReference,
external_order_id: params.externalOrderId,
requestPickup: false,
pickupDate: new Date().toISOString().slice(0, 10),
insurance: false,
description: params.description,
contentValue: params.contentValue,
packages: [
{
weight: params.pkg.weight,
height: params.pkg.height,
length: params.pkg.length,
widht: params.pkg.width,
},
],
origin: params.origin,
destination: params.destination,
});
const data = json.data as Record<string, unknown>;
return {
guide: String(data.guide ?? ""),
url: String(data.url ?? ""),
tracker: String(data.tracker ?? ""),
idOrder: Number(data.idOrder ?? 0),
};
}
// Hasta 50 ordenes por llamada (limite real documentado).
export async function cancelGuides(credentials: EnvioClickCredentials, idOrders: number[]): Promise<void> {
await envioclickRequest(credentials, "cancellation/batch/order", { idOrders });
}
// código DANE estandar (5 digitos: 2 de departamento + 3 de municipio,
// ej. 11001 = Bogota, 5001 = Medellin -- mismo id que ya usa
// lib/data/colombia.ts del storefront, confirmado contra su propio
// comentario) con el padding real que espera EnvioClick (8 caracteres,
// confirmado contra su propio ejemplo real: "11001000" para Bogota =
// "11001" + "000").
export function toEnvioClickDaneCode(divipolaCityId: number): string {
return `${String(divipolaCityId).padStart(5, "0")}000`;
}
@@ -0,0 +1,68 @@
import { createHash } from "node:crypto";
// ePayco (100% de Davivienda desde 2024, ver ADR 0009 -- tercer proveedor
// evaluado tras PayU LatAm/Rapyd, elegido por comision PSE mas baja para
// bajo volumen y tarifa preferencial con cuenta Davivienda, que el
// usuario ya tiene). A diferencia de PayU/Rapyd, no llamamos la API REST
// cruda nosotros: usamos el SDK oficial `epayco-sdk-node` (mantenido por
// github.com/epayco, ultimo publish reciente) porque su protocolo real
// -- leido del codigo fuente, no de la doc -- es bastante mas raro de lo
// que parece en los ejemplos: cada campo del payload se cifra con
// AES-CBC usando la private key como llave (IV fijo en cero) ANTES de
// mandarse a secure.payco.co, ademas de un login previo a
// api.secure.payco.co/v1/auth/login que da un bearer token que tambien
// va en la request. Reimplementar esto a mano tiene mucho mas riesgo de
// error que delegarlo al SDK del propio proveedor.
export type EpaycoEnv = "sandbox" | "production";
export interface EpaycoCredentials {
publicKey: string;
privateKey: string;
pCustIdCliente: string;
pKey: string;
env: EpaycoEnv;
}
// Sin tipos de TypeScript en el paquete (confirmado: package.json no
// declara "types") -- se tipa manualmente el subset que usamos.
// eslint-disable-next-line @typescript-eslint/no-var-requires
const EpaycoSdk = require("epayco-sdk-node");
export interface EpaycoClient {
bank: {
create(options: Record<string, unknown>): Promise<Record<string, unknown>>;
get(transactionId: string): Promise<Record<string, unknown>>;
getBanks(): Promise<Record<string, unknown>>;
};
}
export function createEpaycoClient(credentials: EpaycoCredentials): EpaycoClient {
return EpaycoSdk({
apiKey: credentials.publicKey,
privateKey: credentials.privateKey,
lang: "ES",
test: credentials.env === "sandbox",
});
}
// Firma del webhook de confirmacion (url_confirmation) -- formula
// verificada contra la documentacion real de ePayco:
// sha256(p_cust_id_cliente^p_key^x_ref_payco^x_transaction_id^x_amount^x_currency_code).
// p_cust_id_cliente/p_key son valores propios del dashboard de ePayco
// (Configuracion > Llaves secretas), distintos de publicKey/privateKey
// -- confirmar contra el dashboard real antes de la primera prueba.
// Falla cerrado: cualquier mismatch devuelve false, nunca se traga la
// excepcion.
export function verifyWebhookSignature(params: {
pCustIdCliente: string;
pKey: string;
refPayco: string;
transactionId: string;
amount: string;
currencyCode: string;
signature: string;
}): boolean {
const raw = `${params.pCustIdCliente}^${params.pKey}^${params.refPayco}^${params.transactionId}^${params.amount}^${params.currencyCode}`;
const expected = createHash("sha256").update(raw).digest("hex");
return expected.toLowerCase() === params.signature.toLowerCase();
}
-111
View File
@@ -1,111 +0,0 @@
import { createHash } from "node:crypto";
// Contrato real de la API de PayU LatAm (developers.payulatam.com,
// verificado en vivo 2026-09-06/07 -- NO es la misma API que los plugins
// de Medusa que aparecen buscando "PayU": esos son para PayU Polonia/CEE
// (clientId/posId/secondKey), API distinta por completo. Esta es la de
// apiLogin/apiKey/merchantId/accountId, la que cubre Colombia.
export type PayuEnv = "sandbox" | "production";
export interface PayuCredentials {
apiLogin: string;
apiKey: string;
merchantId: string;
accountId: string;
env: PayuEnv;
}
const BASE_URL: Record<PayuEnv, string> = {
sandbox: "https://sandbox.api.payulatam.com/payments-api/4.0/service.cgi",
production: "https://api.payulatam.com/payments-api/4.0/service.cgi",
};
export async function payuRequest(
credentials: PayuCredentials,
body: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const response = await fetch(BASE_URL[credentials.env], {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({
test: credentials.env === "sandbox",
language: "es",
...body,
}),
});
const text = await response.text();
let json: Record<string, unknown>;
try {
json = JSON.parse(text);
} catch {
throw new Error(`Respuesta no-JSON de PayU (HTTP ${response.status}): ${text.slice(0, 300)}`);
}
if (!response.ok) {
throw new Error(`PayU respondió HTTP ${response.status}: ${JSON.stringify(json).slice(0, 300)}`);
}
return json;
}
export interface PayuBank {
id: string;
description: string;
pseCode: string;
}
export async function fetchPseBanks(credentials: PayuCredentials): Promise<PayuBank[]> {
const json = await payuRequest(credentials, {
command: "GET_BANKS_LIST",
bankListInformation: { paymentMethod: "PSE", paymentCountry: "CO" },
});
const banks = (json.banks ?? []) as Array<{
id?: unknown;
description?: unknown;
pseCode?: unknown;
}>;
return banks
.filter((bank) => typeof bank.pseCode === "string" && typeof bank.description === "string")
.map((bank) => ({
id: String(bank.id ?? bank.pseCode),
description: String(bank.description),
pseCode: String(bank.pseCode),
}));
}
// Regla documentada de PayU para el campo de monto de la firma: si el
// segundo decimal es cero, un solo decimal (150.00 -> "150.0",
// 150.20 -> "150.2"); si no, dos decimales (150.25 -> "150.25").
export function formatPayuAmount(amount: number): string {
const twoDecimals = amount.toFixed(2);
return twoDecimals.endsWith("0") ? amount.toFixed(1) : twoDecimals;
}
// Firma de la orden (SUBMIT_TRANSACTION) -- distinta de la firma del
// webhook de abajo, confirmado contra el SDK PHP oficial de PayU.
export function buildOrderSignature(params: {
apiKey: string;
merchantId: string;
referenceCode: string;
amount: string;
currency: string;
}): string {
const raw = `${params.apiKey}~${params.merchantId}~${params.referenceCode}~${params.amount}~${params.currency}`;
return createHash("md5").update(raw).digest("hex");
}
// Firma del webhook de confirmación (notifyUrl) -- lo único que impide
// que cualquiera falsifique una confirmación de pago. Falla cerrado:
// cualquier mismatch devuelve false, nunca se traga la excepción.
export function verifyWebhookSignature(params: {
apiKey: string;
merchantId: string;
referenceSale: string;
value: string;
currency: string;
statePol: string;
sign: string;
}): boolean {
const raw = `${params.apiKey}~${params.merchantId}~${params.referenceSale}~${params.value}~${params.currency}~${params.statePol}`;
const expected = createHash("md5").update(raw).digest("hex");
return expected.toLowerCase() === params.sign.toLowerCase();
}
@@ -0,0 +1,6 @@
import { ModuleProvider, Modules } from "@medusajs/framework/utils";
import EnvioClickFulfillmentService from "./service";
export default ModuleProvider(Modules.FULFILLMENT, {
services: [EnvioClickFulfillmentService],
});
@@ -0,0 +1,275 @@
import { AbstractFulfillmentProviderService } from "@medusajs/framework/utils";
import type {
CalculatedShippingOptionPrice,
CalculateShippingOptionPriceContext,
CalculateShippingOptionPriceDTO,
CreateFulfillmentResult,
CreateShippingOptionDTO,
FulfillmentDTO,
FulfillmentItemDTO,
FulfillmentOption,
FulfillmentOrderDTO,
Logger,
ValidateFulfillmentDataContext,
} from "@medusajs/framework/types";
import { cancelGuides, createGuide, quoteShipment, toEnvioClickDaneCode } from "../../lib/envioclick";
import { DEFAULT_PACKAGE, FALLBACK_PRICE_COP, type EnvioClickOptions } from "./types";
type InjectedDependencies = {
logger: Logger;
};
// Contexto minimo compartido entre calculatePrice/createFulfillment --
// ambos necesitan lo mismo (id DIVIPOLA de la ciudad destino, guardado
// como metadata en la direccion por el checkout -- ver ADR 0009 y
// checkout-client.tsx del storefront) y el paquete agregado del pedido.
interface AddressLike {
metadata?: Record<string, unknown> | null;
address_1?: string | null;
first_name?: string | null;
last_name?: string | null;
phone?: string | null;
email?: string | null;
}
function destinationDaneCode(address: AddressLike | null | undefined): string | null {
const cityId = address?.metadata?.cityId;
return typeof cityId === "number" ? toEnvioClickDaneCode(cityId) : null;
}
// Agrega el carrito/orden a UN solo paquete (EnvioClick solo acepta 1 por
// cotizacion/guia, confirmado en la doc real: "min 1, max 1"). Si algun
// item no tiene peso real cargado (hoy NINGUN producto lo tiene, ver
// plan de implementacion), se usa el paquete de fallback conservador
// completo en vez de mezclar datos reales con inventados a medias.
function aggregatePackage(items: Array<{ quantity: number; variant?: { weight?: number | null } | null }>) {
if (items.length === 0) return { ...DEFAULT_PACKAGE };
let totalWeight = 0;
for (const item of items) {
const weight = item.variant?.weight;
if (!weight) return { ...DEFAULT_PACKAGE };
totalWeight += weight * item.quantity;
}
return { ...DEFAULT_PACKAGE, weight: totalWeight };
}
export default class EnvioClickFulfillmentService extends AbstractFulfillmentProviderService {
static identifier = "envioclick";
protected logger_: Logger;
protected options_: EnvioClickOptions;
constructor(container: InjectedDependencies, options: EnvioClickOptions) {
super();
this.logger_ = container.logger;
this.options_ = options;
}
static validateOptions(options: Record<string, unknown>): void {
for (const key of ["apiKey", "originDaneCode", "originAddress", "originCompany", "originFirstName", "originLastName", "originEmail", "originPhone"]) {
if (!options[key]) {
throw new Error(`envioclick-fulfillment: falta la opción "${key}" en medusa-config.ts`);
}
}
}
async getFulfillmentOptions(): Promise<FulfillmentOption[]> {
// Una sola opcion: siempre toma la tarifa mas barata entre las
// transportadoras que cotizan, mismo criterio que el plugin oficial
// de WooCommerce de referencia ("toma el precio mas economico").
return [{ id: "standard", name: "Envío nacional (calculado)" }];
}
async validateFulfillmentData(
optionData: Record<string, unknown>,
data: Record<string, unknown>,
_context: ValidateFulfillmentDataContext,
): Promise<any> {
return data;
}
async validateOption(_data: Record<string, unknown>): Promise<boolean> {
return true;
}
async canCalculate(_data: CreateShippingOptionDTO): Promise<boolean> {
return true;
}
// Se invoca en vivo durante el checkout cada vez que se piden/refrescan
// las shipping options del carrito. Medusa advierte explicitamente que
// un error acá bloquea el checkout entero -- por eso NUNCA se deja
// propagar una excepcion: cualquier falla (red, ciudad sin id DIVIPOLA,
// EnvioClick caido) cae al precio fijo que ya se usaba antes de esta
// integracion, para no romper la compra.
async calculatePrice(
optionData: CalculateShippingOptionPriceDTO["optionData"],
_data: CalculateShippingOptionPriceDTO["data"],
context: CalculateShippingOptionPriceContext,
): Promise<CalculatedShippingOptionPrice> {
const fallback: CalculatedShippingOptionPrice = {
calculated_amount: FALLBACK_PRICE_COP,
is_calculated_price_tax_inclusive: false,
};
try {
const address = context.shipping_address as AddressLike | undefined;
const daneCode = destinationDaneCode(address);
if (!daneCode) {
this.logger_.warn("envioclick-fulfillment: dirección sin id DIVIPOLA en metadata, usando precio fijo de respaldo");
return fallback;
}
const items = (context.items ?? []) as Array<{ quantity: number; variant?: { weight?: number | null } | null }>;
const pkg = aggregatePackage(items);
const rates = await quoteShipment(
{ apiKey: this.options_.apiKey },
{
pkg,
description: "Pedido ARI Shopping",
contentValue: 50000,
origin: { daneCode: this.options_.originDaneCode, address: this.options_.originAddress },
destination: { daneCode, address: address?.address_1 || "Dirección de entrega" },
},
);
if (rates.length === 0) return fallback;
const cheapest = rates.reduce((min, rate) =>
rate.flete + rate.minimumInsurance < min.flete + min.minimumInsurance ? rate : min,
);
return {
calculated_amount: cheapest.flete + cheapest.minimumInsurance,
is_calculated_price_tax_inclusive: false,
};
} catch (error) {
this.logger_.warn(`envioclick-fulfillment: falló calculatePrice (${(error as Error).message}), usando precio fijo de respaldo`);
return fallback;
}
}
// A diferencia de calculatePrice, acá SÍ se deja propagar el error --
// el idRate de la cotización mostrada en checkout no se persiste en
// ningún lado (CalculatedShippingOptionPrice no tiene un campo `data`
// para eso, confirmado leyendo el tipo real), así que se vuelve a
// cotizar en el momento de generar la guía (más correcto igual: el
// fulfillment puede pasar horas/días después del checkout, un idRate
// viejo podría estar vencido) y se reserva con la tarifa más barata de
// esa cotización fresca.
async createFulfillment(
_data: Record<string, unknown>,
_items: Partial<Omit<FulfillmentItemDTO, "fulfillment">>[],
order: Partial<FulfillmentOrderDTO> | undefined,
_fulfillment: Partial<Omit<FulfillmentDTO, "provider_id" | "data" | "items">>,
): Promise<CreateFulfillmentResult> {
const address = order?.shipping_address as AddressLike | undefined;
const daneCode = destinationDaneCode(address);
if (!daneCode) {
throw new Error("envioclick-fulfillment: no se puede generar la guía sin un id DIVIPOLA en la dirección de envío.");
}
const items = (order?.items ?? []) as unknown as Array<{ quantity: number; variant?: { weight?: number | null } | null }>;
const pkg = aggregatePackage(items);
const rates = await quoteShipment(
{ apiKey: this.options_.apiKey },
{
pkg,
description: "Pedido ARI Shopping",
contentValue: 50000,
origin: { daneCode: this.options_.originDaneCode, address: this.options_.originAddress },
destination: { daneCode, address: address?.address_1 || "Dirección de entrega" },
},
);
if (rates.length === 0) {
throw new Error("envioclick-fulfillment: no hay tarifas disponibles para generar la guía.");
}
const cheapest = rates.reduce((min, rate) =>
rate.flete + rate.minimumInsurance < min.flete + min.minimumInsurance ? rate : min,
);
const guide = await createGuide(
{ apiKey: this.options_.apiKey },
this.options_.sandbox,
{
idRate: cheapest.idRate,
myShipmentReference: `Orden ${order?.display_id ?? order?.id ?? ""}`,
externalOrderId: String(order?.id ?? ""),
pkg,
description: "Pedido ARI Shopping",
contentValue: 50000,
origin: {
company: this.options_.originCompany,
firstName: this.options_.originFirstName,
lastName: this.options_.originLastName,
email: this.options_.originEmail,
phone: this.options_.originPhone,
address: this.options_.originAddress,
suburb: "N/A",
crossStreet: "N/A",
reference: "N/A",
daneCode: this.options_.originDaneCode,
},
destination: {
company: "N/A",
firstName: address?.first_name || "Cliente",
lastName: address?.last_name || "ARI",
email: address?.email || order?.email || "[email protected]",
phone: address?.phone || "0000000000",
address: address?.address_1 || "Dirección de entrega",
suburb: "N/A",
crossStreet: "N/A",
reference: "N/A",
daneCode,
},
},
);
return {
data: {
idOrder: guide.idOrder,
tracker: guide.tracker,
labelUrl: guide.url,
},
labels: [
{
tracking_number: guide.tracker,
tracking_url: guide.url,
label_url: guide.url,
},
],
};
}
async cancelFulfillment(data: Record<string, unknown>): Promise<any> {
const idOrder = data.idOrder;
if (typeof idOrder !== "number") {
this.logger_.warn("envioclick-fulfillment: cancelFulfillment sin idOrder guardado, no hay nada que cancelar en EnvíoClick");
return { data };
}
await cancelGuides({ apiKey: this.options_.apiKey }, [idOrder]);
return { data };
}
async getFulfillmentDocuments(_data: Record<string, unknown>): Promise<never[]> {
return [];
}
async createReturnFulfillment(_fulfillment: Record<string, unknown>): Promise<CreateFulfillmentResult> {
// Devoluciones no estan en alcance de esta primera version.
throw new Error("envioclick-fulfillment: createReturnFulfillment no está implementado todavía.");
}
async getReturnDocuments(_data: Record<string, unknown>): Promise<never[]> {
return [];
}
async getShipmentDocuments(_data: Record<string, unknown>): Promise<never[]> {
return [];
}
async retrieveDocuments(_fulfillmentData: Record<string, unknown>, _documentType: string): Promise<void> {
return undefined;
}
}
@@ -0,0 +1,33 @@
export interface EnvioClickOptions {
apiKey: string;
// El unico endpoint con modo de pruebas real es la generacion de guia
// (ver src/lib/envioclick.ts) -- la cotizacion siempre usa tarifas
// reales, no hay nada que alternar ahi.
sandbox: boolean;
// Datos de la bodega de origen -- la stock location real todavia no
// tiene direccion fisica cargada ("Sin dirección física registrada"),
// asi que estos vienen de config hasta que se cargue una real.
originDaneCode: string;
originAddress: string;
originCompany: string;
originFirstName: string;
originLastName: string;
originEmail: string;
originPhone: string;
}
// Precio de fallback cuando EnvíoClick falla o no hay peso/dimensiones
// reales del pedido (hoy ningun producto los tiene cargados) -- mismo
// valor que ya usaba la shipping option fija de ADR 0009, para no
// empeorar la experiencia mientras no haya datos reales.
export const FALLBACK_PRICE_COP = 15000;
// Paquete por defecto cuando algun item del carrito no tiene
// peso/dimensiones reales -- conservador, no preciso. Ver nota en el
// plan de implementacion: esto es una tarea de datos, no de codigo.
export const DEFAULT_PACKAGE = {
weight: 1,
length: 20,
height: 20,
width: 20,
};
@@ -1,6 +1,6 @@
import { ModuleProvider, Modules } from "@medusajs/framework/utils";
import PayuPseProviderService from "./service";
import EpaycoPseProviderService from "./service";
export default ModuleProvider(Modules.PAYMENT, {
services: [PayuPseProviderService],
services: [EpaycoPseProviderService],
});
@@ -0,0 +1,234 @@
import { AbstractPaymentProvider, BigNumber } from "@medusajs/framework/utils";
import type {
AuthorizePaymentInput,
AuthorizePaymentOutput,
CancelPaymentInput,
CancelPaymentOutput,
CapturePaymentInput,
CapturePaymentOutput,
DeletePaymentInput,
DeletePaymentOutput,
GetPaymentStatusInput,
GetPaymentStatusOutput,
InitiatePaymentInput,
InitiatePaymentOutput,
Logger,
ProviderWebhookPayload,
RefundPaymentInput,
RefundPaymentOutput,
RetrievePaymentInput,
RetrievePaymentOutput,
UpdatePaymentInput,
UpdatePaymentOutput,
WebhookActionResult,
} from "@medusajs/framework/types";
import { createEpaycoClient, verifyWebhookSignature } from "../../lib/epayco";
import type { EpaycoPseInputData, EpaycoPseOptions, EpaycoPseSessionData } from "./types";
type InjectedDependencies = {
logger: Logger;
};
// PSE via ePayco es asincrono igual que PayU/Rapyd: no hay confirmacion
// real hasta que llega el webhook (url_confirmation), nunca en la
// respuesta sincrona de bank.create(). authorizePayment/capturePayment
// por eso son casi no-ops -- el estado real lo trae siempre el webhook.
export default class EpaycoPseProviderService extends AbstractPaymentProvider<EpaycoPseOptions> {
static identifier = "epayco";
protected logger_: Logger;
protected options_: EpaycoPseOptions;
constructor(container: InjectedDependencies, options: EpaycoPseOptions) {
super(container, options);
this.logger_ = container.logger;
this.options_ = options;
}
static validateOptions(options: Record<string, unknown>): void {
for (const key of ["publicKey", "privateKey", "pCustIdCliente", "pKey", "env", "responseUrl", "notifyUrl"]) {
if (!options[key]) {
throw new Error(`epayco-pse: falta la opción "${key}" en medusa-config.ts`);
}
}
}
async initiatePayment(input: InitiatePaymentInput): Promise<InitiatePaymentOutput> {
const data = input.data as unknown as EpaycoPseInputData & { session_id: string };
const sessionId = data.session_id;
const amount = new BigNumber(input.amount).numeric;
const currency = input.currency_code.toUpperCase();
const client = createEpaycoClient(this.options_);
// Campos confirmados leyendo node_modules/epayco-sdk-node/lib/keylang.json
// y tests/bank.js directamente (no la documentacion publica -- esa
// mostraba "metodoconfirmacion", el codigo real que de verdad manda
// el SDK usa "method_confirmation"). El SDK cifra estos valores y
// agrega ip/test/auth por su cuenta, no hace falta hacerlo nosotros.
const response = await client.bank.create({
bank: data.bank_code,
invoice: sessionId,
description: `Orden ARI Shopping ${sessionId}`,
value: String(amount),
tax: "0",
tax_base: "0",
currency,
type_person: data.user_type,
doc_type: data.doc_type,
doc_number: data.doc_number,
name: data.first_name,
last_name: data.last_name,
email: data.email,
country: "CO",
city: data.city,
cell_phone: data.phone,
url_response: this.options_.responseUrl,
url_confirmation: this.options_.notifyUrl,
method_confirmation: "POST",
});
if (response.error) {
throw new Error(`ePayco rechazó la transacción para la sesión ${sessionId}: ${JSON.stringify(response).slice(0, 300)}`);
}
// El nombre real del campo con la URL de redirección al banco NO
// quedó verificado contra una respuesta real de sandbox en esta
// sesión (ni la doc ni los tests del SDK lo muestran) -- se prueban
// los candidatos más probables y, si ninguno aparece, se tira un
// error con el payload completo para poder leerlo en los logs y
// corregir esto en el primer intento real contra el sandbox.
const flat = (response.data ?? response) as Record<string, unknown>;
const bankUrlCandidates = ["urlbanco", "url_banco", "urlBanco", "url", "redirect_url"];
const bankUrl = bankUrlCandidates.map((key) => flat[key]).find((value) => typeof value === "string" && value);
if (typeof bankUrl !== "string" || !bankUrl) {
throw new Error(
`ePayco no devolvió una URL de banco reconocible para la sesión ${sessionId}. Respuesta completa (revisar para corregir el nombre del campo): ${JSON.stringify(response).slice(0, 500)}`,
);
}
const sessionData: EpaycoPseSessionData = {
...data,
session_id: sessionId,
ref_payco: String(flat.ref_payco ?? flat.refPayco ?? ""),
transaction_id: String(flat.transactionID ?? flat.transaction_id ?? ""),
bank_url: bankUrl,
state: "PENDING",
amount,
currency,
};
return { id: sessionId, data: sessionData as unknown as Record<string, unknown>, status: "pending" };
}
// PSE es diferido: en el momento de completar el carrito, ePayco
// todavia no confirmo nada -- "pending_authorization" es el mecanismo
// documentado de Medusa para esto (la orden queda "awaiting" y el
// navegador ya puede redirigirse al banco).
async authorizePayment(input: AuthorizePaymentInput): Promise<AuthorizePaymentOutput> {
return { data: input.data, status: "pending_authorization" };
}
// Casi no-op: cuando el webhook reporta "captured", ePayco ya liquido
// el dinero de su lado -- solo persiste el data actualizado.
async capturePayment(input: CapturePaymentInput): Promise<CapturePaymentOutput> {
return { data: input.data };
}
async cancelPayment(input: CancelPaymentInput): Promise<CancelPaymentOutput> {
this.logger_.warn("epayco-pse: cancelPayment llamado, pero PSE no soporta cancelar una transacción ya enviada");
return { data: input.data };
}
async deletePayment(input: DeletePaymentInput): Promise<DeletePaymentOutput> {
return { data: input.data };
}
// Endpoint de reembolso de ePayco no quedó verificado en vivo en esta
// sesión -- con dinero real de por medio, mejor tirar un error
// explícito que fingir un reembolso que nunca se procesó.
async refundPayment(_input: RefundPaymentInput): Promise<RefundPaymentOutput> {
throw new Error(
"epayco-pse: refundPayment no está implementado todavía -- procesar el reembolso manualmente desde el panel de ePayco por ahora.",
);
}
// GET /validation/v1/reference/{ref_payco} -- endpoint de verificación
// server-to-server documentado por ePayco para consultar el estado
// real de una transacción.
async retrievePayment(input: RetrievePaymentInput): Promise<RetrievePaymentOutput> {
const data = input.data as unknown as EpaycoPseSessionData | undefined;
const refPayco = data?.ref_payco;
if (!refPayco) {
throw new Error("epayco-pse: retrievePayment necesita ref_payco, no está guardado en esta sesión.");
}
const response = await fetch(`https://secure.epayco.co/validation/v1/reference/${refPayco}`);
const json = await response.json();
return { data: json as Record<string, unknown> };
}
async updatePayment(input: UpdatePaymentInput): Promise<UpdatePaymentOutput> {
// ePayco no tiene concepto de "modificar" una transaccion ya
// enviada. El checkout solo llega al step de pago despues de fijar
// direccion y envio, asi que el total no deberia cambiar una vez que
// existe una sesion PSE.
return { data: input.data };
}
async getPaymentStatus(input: GetPaymentStatusInput): Promise<GetPaymentStatusOutput> {
const data = input.data as unknown as EpaycoPseSessionData | undefined;
const state = data?.state;
if (state === "1") return { status: "captured" };
if (state === "3") return { status: "pending" };
if (state === "PENDING") return { status: "pending" };
return { status: "canceled" };
}
// Unico punto real de confirmacion asincrona. Falla cerrado: si la
// firma no matchea, no se hace nada -- nunca se confia en un webhook
// sin firma valida.
async getWebhookActionAndData(payload: ProviderWebhookPayload["payload"]): Promise<WebhookActionResult> {
const body = payload.data as Record<string, unknown>;
const refPayco = String(body.x_ref_payco ?? "");
const transactionId = String(body.x_transaction_id ?? "");
const amount = String(body.x_amount ?? "");
const currencyCode = String(body.x_currency_code ?? "");
const codState = String(body.x_cod_transaction_state ?? "");
const signature = String(body.x_signature ?? "");
// session_id sale del invoice que nosotros mandamos -- ePayco lo
// devuelve como x_id_invoice (o x_extra1 si no viniera) segun el
// webhook real; se intentan ambos.
const sessionId = String(body.x_id_invoice ?? body.x_extra1 ?? refPayco);
const validSignature = verifyWebhookSignature({
pCustIdCliente: this.options_.pCustIdCliente,
pKey: this.options_.pKey,
refPayco,
transactionId,
amount,
currencyCode,
signature,
});
if (!validSignature) {
this.logger_.warn(`epayco-pse: webhook con firma inválida para x_ref_payco=${refPayco}, ignorado`);
return { action: "not_supported" };
}
// Solo "1" (Aceptada) es exito -- cualquier otro codigo (2 Rechazada,
// 4 Fallida, 6 Reversada, 9 caducada, 10 abandonada, 11 Cancelada,
// 3 Pendiente) se trata como fallido en vez de asumir un exito
// distinto. Un "failed" de mas es reintentable; un "captured" de mas
// (dinero que nunca se aprobo) no lo es.
const action = codState === "1" ? "captured" : "failed";
return {
action,
data: {
session_id: sessionId,
amount: new BigNumber(amount || 0),
},
};
}
}
@@ -0,0 +1,54 @@
import type { EpaycoEnv } from "../../lib/epayco";
export interface EpaycoPseOptions {
publicKey: string;
privateKey: string;
// p_cust_id_cliente / p_key -- valores propios del dashboard de ePayco
// para verificar la firma del webhook, distintos de publicKey/privateKey.
pCustIdCliente: string;
pKey: string;
env: EpaycoEnv;
// A donde ePayco redirige al comprador de vuelta tras pagar en el
// banco (pagina propia del storefront, no confirma nada por si sola).
responseUrl: string;
// URL publica de confirmacion -- por-transaccion (a diferencia de
// Rapyd, que solo permite configurarlo una vez a nivel de cuenta),
// apunta a la ruta nativa de Medusa /hooks/payment/:provider.
notifyUrl: string;
}
export type EpaycoDocType = "CC" | "CE" | "NIT" | "TI" | "PP";
// "0" persona natural, "1" persona juridica -- valores reales que espera
// el campo type_person de ePayco.
export type EpaycoUserType = "0" | "1";
// Lo que el storefront manda como `data` al crear la sesion de pago PSE
// (recolectado en el step "address" del checkout, mas el banco/tipo de
// persona/documento del nuevo step "payment"). No viene de
// `context.customer` porque el checkout es de invitado -- ver ADR 0009.
export interface EpaycoPseInputData {
bank_code: string;
user_type: EpaycoUserType;
doc_type: EpaycoDocType;
doc_number: string;
first_name: string;
last_name: string;
email: string;
phone: string;
city: string;
}
// Lo que initiatePayment devuelve y queda guardado en `data` de la
// PaymentSession -- publico para el storefront, nunca poner credenciales
// acá. `bank_url`: nombre de campo AUN NO VERIFICADO contra una
// respuesta real de sandbox (ver comentario en service.ts) -- primer
// punto a confirmar en la primera prueba end-to-end.
export interface EpaycoPseSessionData extends EpaycoPseInputData {
session_id: string;
ref_payco: string;
transaction_id: string;
bank_url: string;
state: string;
amount: number;
currency: string;
}
@@ -1,260 +0,0 @@
import { AbstractPaymentProvider, BigNumber } from "@medusajs/framework/utils";
import type {
AuthorizePaymentInput,
AuthorizePaymentOutput,
CancelPaymentInput,
CancelPaymentOutput,
CapturePaymentInput,
CapturePaymentOutput,
DeletePaymentInput,
DeletePaymentOutput,
GetPaymentStatusInput,
GetPaymentStatusOutput,
InitiatePaymentInput,
InitiatePaymentOutput,
Logger,
ProviderWebhookPayload,
RefundPaymentInput,
RefundPaymentOutput,
RetrievePaymentInput,
RetrievePaymentOutput,
UpdatePaymentInput,
UpdatePaymentOutput,
WebhookActionResult,
} from "@medusajs/framework/types";
import { buildOrderSignature, formatPayuAmount, payuRequest, verifyWebhookSignature } from "../../lib/payu";
import type { PayuPseInputData, PayuPseOptions, PayuPseSessionData } from "./types";
type InjectedDependencies = {
logger: Logger;
};
// PSE es AUTHORIZATION_AND_CAPTURE en un solo paso del lado de PayU, y
// asincrono: no hay confirmacion real hasta que llega el webhook (ver
// getWebhookActionAndData). authorizePayment/capturePayment por eso son
// casi no-ops -- el estado real lo trae siempre el webhook, nunca una
// llamada sincrona nuestra a PayU.
export default class PayuPseProviderService extends AbstractPaymentProvider<PayuPseOptions> {
static identifier = "payu";
protected logger_: Logger;
protected options_: PayuPseOptions;
constructor(container: InjectedDependencies, options: PayuPseOptions) {
super(container, options);
this.logger_ = container.logger;
this.options_ = options;
}
static validateOptions(options: Record<string, unknown>): void {
for (const key of ["apiLogin", "apiKey", "merchantId", "accountId", "env", "responseUrl", "notifyUrl"]) {
if (!options[key]) {
throw new Error(`payu-pse: falta la opción "${key}" en medusa-config.ts`);
}
}
}
async initiatePayment(input: InitiatePaymentInput): Promise<InitiatePaymentOutput> {
const data = input.data as unknown as PayuPseInputData & { session_id: string };
const sessionId = data.session_id;
const amount = new BigNumber(input.amount).numeric;
const currency = input.currency_code.toUpperCase();
const formattedAmount = formatPayuAmount(amount);
const signature = buildOrderSignature({
apiKey: this.options_.apiKey,
merchantId: this.options_.merchantId,
referenceCode: sessionId,
amount: formattedAmount,
currency,
});
// Shape del request confirmado contra la documentacion real de PayU
// LatAm para command/paymentMethod/extraParameters -- la anidacion
// exacta de buyer/payer (nombres de campo dentro de esos objetos) no
// quedo verificada campo por campo contra una respuesta real de
// sandbox en esta sesion. Primera prueba end-to-end contra el
// sandbox real de PayU debe confirmar esto (ver plan de
// implementacion, seccion Verificacion).
const json = await payuRequest(
{ apiLogin: this.options_.apiLogin, apiKey: this.options_.apiKey, merchantId: this.options_.merchantId, accountId: this.options_.accountId, env: this.options_.env },
{
command: "SUBMIT_TRANSACTION",
merchant: {
apiLogin: this.options_.apiLogin,
apiKey: this.options_.apiKey,
},
transaction: {
order: {
accountId: this.options_.accountId,
referenceCode: sessionId,
description: `Orden ARI Shopping ${sessionId}`,
language: "es",
signature,
notifyUrl: this.options_.notifyUrl,
additionalValues: {
TX_VALUE: { value: amount, currency },
},
buyer: {
fullName: data.full_name,
emailAddress: data.email,
contactPhone: data.phone,
dniNumber: data.doc_number,
shippingAddress: {
street1: data.address,
city: data.city,
state: data.state,
country: "CO",
},
},
},
payer: {
fullName: data.full_name,
emailAddress: data.email,
contactPhone: data.phone,
dniNumber: data.doc_number,
billingAddress: {
street1: data.address,
city: data.city,
state: data.state,
country: "CO",
},
},
type: "AUTHORIZATION_AND_CAPTURE",
paymentMethod: "PSE",
paymentCountry: "CO",
extraParameters: {
RESPONSE_URL: this.options_.responseUrl,
FINANCIAL_INSTITUTION_CODE: data.bank_code,
USER_TYPE: data.user_type,
PSE_REFERENCE2: data.doc_type,
PSE_REFERENCE3: data.doc_number,
},
},
},
);
const transactionResponse = (json.transactionResponse ?? {}) as Record<string, unknown>;
const extraParameters = (transactionResponse.extraParameters ?? {}) as Record<string, unknown>;
const bankUrl = extraParameters.BANK_URL;
if (typeof bankUrl !== "string" || !bankUrl) {
throw new Error(
`PayU no devolvió BANK_URL para la sesión ${sessionId}: ${JSON.stringify(transactionResponse).slice(0, 300)}`,
);
}
const sessionData: PayuPseSessionData = {
...data,
session_id: sessionId,
payu_order_id: String(transactionResponse.orderId ?? ""),
payu_transaction_id: String(transactionResponse.transactionId ?? ""),
reference_code: sessionId,
bank_url: bankUrl,
state: String(transactionResponse.state ?? "PENDING"),
amount,
currency,
};
return { id: sessionId, data: sessionData as unknown as Record<string, unknown>, status: "pending" };
}
// PSE es diferido: en el momento de completar el carrito, PayU todavia
// no confirmo nada -- "pending_authorization" es el mecanismo
// documentado de Medusa para esto (la orden queda "awaiting" y el
// navegador ya puede redirigirse al banco).
async authorizePayment(input: AuthorizePaymentInput): Promise<AuthorizePaymentOutput> {
return { data: input.data, status: "pending_authorization" };
}
// Casi no-op: cuando el webhook reporta "captured", PayU ya liquido el
// dinero de su lado (un solo paso AUTHORIZATION_AND_CAPTURE) -- solo
// persiste el data actualizado.
async capturePayment(input: CapturePaymentInput): Promise<CapturePaymentOutput> {
return { data: input.data };
}
// PSE no tiene concepto de cancelar una transaccion ya enviada al banco.
async cancelPayment(input: CancelPaymentInput): Promise<CancelPaymentOutput> {
this.logger_.warn("payu-pse: cancelPayment llamado, pero PSE no soporta cancelar una transacción ya enviada");
return { data: input.data };
}
async deletePayment(input: DeletePaymentInput): Promise<DeletePaymentOutput> {
return { data: input.data };
}
// No verificado contra el endpoint real de PayU en esta sesión -- con
// dinero real de por medio, mejor tirar un error explícito que fingir
// un reembolso que nunca llamó a PayU.
async refundPayment(_input: RefundPaymentInput): Promise<RefundPaymentOutput> {
throw new Error(
"payu-pse: refundPayment no está implementado todavía -- el endpoint de reembolso de PayU LatAm no quedó verificado en vivo. Procesar el reembolso manualmente desde el panel de PayU por ahora.",
);
}
// Idem refundPayment: no verificado en vivo, mejor fallar explícito.
async retrievePayment(_input: RetrievePaymentInput): Promise<RetrievePaymentOutput> {
throw new Error("payu-pse: retrievePayment no está implementado todavía -- usar el panel de PayU para consultar una transacción.");
}
async updatePayment(input: UpdatePaymentInput): Promise<UpdatePaymentOutput> {
// PayU no tiene concepto de "modificar" una transaccion ya enviada.
// El checkout solo llega al step de pago despues de fijar direccion
// y envio, asi que el total no deberia cambiar una vez que existe
// una sesion PSE -- si esto se dispara, es una señal de que algo
// cambió el carrito después de crear la sesión.
return { data: input.data };
}
async getPaymentStatus(input: GetPaymentStatusInput): Promise<GetPaymentStatusOutput> {
const data = input.data as unknown as PayuPseSessionData | undefined;
const state = data?.state;
if (state === "APPROVED") return { status: "captured" };
if (state === "DECLINED" || state === "EXPIRED" || state === "ERROR") return { status: "canceled" };
return { status: "pending" };
}
// Unico punto real de confirmacion asincrona. Falla cerrado: si la
// firma no matchea, no se hace nada -- nunca se confia en un webhook
// sin firma valida, es lo unico que impide que cualquiera falsifique
// un pago aprobado.
async getWebhookActionAndData(payload: ProviderWebhookPayload["payload"]): Promise<WebhookActionResult> {
const body = payload.data as Record<string, unknown>;
const merchantId = String(body.merchant_id ?? "");
const referenceSale = String(body.reference_sale ?? "");
const value = String(body.value ?? "");
const currency = String(body.currency ?? "");
const statePol = String(body.state_pol ?? "");
const sign = String(body.sign ?? "");
const validSignature = verifyWebhookSignature({
apiKey: this.options_.apiKey,
merchantId,
referenceSale,
value,
currency,
statePol,
sign,
});
if (!validSignature) {
this.logger_.warn(`payu-pse: webhook con firma inválida para reference_sale=${referenceSale}, ignorado`);
return { action: "not_supported" };
}
// Solo state_pol "4" (aprobado) es exito -- la tabla completa de
// codigos no quedo 100% verificada, asi que cualquier otro valor se
// trata como fallido en vez de asumir que es un exito distinto. Un
// "failed" de mas es reintentable; un "captured" de mas (dinero que
// nunca se aprobo) no lo es.
const action = statePol === "4" ? "captured" : "failed";
return {
action,
data: {
session_id: referenceSale,
amount: new BigNumber(value || 0),
},
};
}
}
@@ -1,53 +0,0 @@
import type { PayuEnv } from "../../lib/payu";
export interface PayuPseOptions {
apiLogin: string;
apiKey: string;
merchantId: string;
accountId: string;
env: PayuEnv;
// A donde PayU redirige al comprador de vuelta tras pagar en el banco
// (pagina propia del storefront, no confirma nada por si sola -- ver
// src/modules/payu-pse/service.ts).
responseUrl: string;
// URL publica de notifyUrl -- la rellena Medusa (ruta nativa
// /hooks/payment/:provider, no la escribimos nosotros), ver
// medusa-config.ts.
notifyUrl: string;
}
export type PayuDocType = "CC" | "CE" | "NIT" | "TI" | "PP";
export type PayuUserType = "N" | "J";
// Lo que el storefront manda como `data` al crear la sesion de pago PSE
// (recolectado en el step "address" del checkout, mas el banco elegido en
// el nuevo step "payment"). No viene de `context.customer` porque el
// checkout es de invitado -- ver ADR 0009.
export interface PayuPseInputData {
bank_code: string;
user_type: PayuUserType;
doc_type: PayuDocType;
doc_number: string;
full_name: string;
email: string;
phone: string;
address: string;
city: string;
// Nombre del departamento (PayU pide "state" como texto libre, no un
// codigo DIVIPOLA como el resto del checkout).
state: string;
}
// Lo que initiatePayment devuelve y queda guardado en `data` de la
// PaymentSession -- publico para el storefront (ver nota en
// AbstractPaymentProvider.initiatePayment), nunca poner credenciales acá.
export interface PayuPseSessionData extends PayuPseInputData {
session_id: string;
payu_order_id: string;
payu_transaction_id: string;
reference_code: string;
bank_url: string;
state: string;
amount: number;
currency: string;
}
@@ -2,7 +2,7 @@ import { createPaymentCollection, createPsePaymentSession } from "@/lib/headless
export async function POST(request: Request) {
const { cartId, ...pse } = await request.json();
if (!cartId || !pse.bankCode || !pse.docNumber || !pse.fullName || !pse.email || !pse.phone) {
if (!cartId || !pse.bankCode || !pse.docNumber || !pse.firstName || !pse.lastName || !pse.email || !pse.phone) {
return Response.json({ error: "Faltan campos obligatorios para el pago con PSE" }, { status: 400 });
}
try {
+22 -6
View File
@@ -25,10 +25,26 @@ data:
# MEILI_MASTER_KEY en secrets.template.yaml).
MEILISEARCH_HOST: http://meilisearch-svc:7700
# PayU LatAm (ADR 0009) -- solo URLs/config no sensible, credenciales
# reales (apiLogin/apiKey/merchantId/accountId) van en
# secrets.template.yaml. PAYU_ENV=sandbox hasta que haya credenciales
# ePayco (ADR 0009 -- tercer proveedor evaluado, ver medusa-config.ts
# para el historial completo) -- solo URLs/config no sensible,
# credenciales reales (publicKey/privateKey/pCustIdCliente/pKey) van en
# secrets.template.yaml. EPAYCO_ENV=sandbox hasta que haya credenciales
# de produccion reales.
PAYU_ENV: sandbox
PAYU_RESPONSE_URL: https://shop.cruzcloud.net/checkout/pse/return
PAYU_NOTIFY_URL: https://commerce.cruzcloud.net/hooks/payment/payu_pse
EPAYCO_ENV: sandbox
EPAYCO_RESPONSE_URL: https://shop.cruzcloud.net/checkout/pse/return
EPAYCO_NOTIFY_URL: https://commerce.cruzcloud.net/hooks/payment/epayco_pse
# EnvíoClick (ADR 0009 §2) -- fulfillment provider real por peso/volumen,
# reemplaza la tarifa fija. Solo config no sensible; ENVIOCLICK_API_KEY
# va en secrets.template.yaml. ENVIOCLICK_SANDBOX=true usa el endpoint
# de guía de pruebas (/shipment_sandbox) -- la cotización siempre usa
# tarifas reales, no tiene modo sandbox. Origen = bodega Bogotá
# ("Bodega principal ARI Shopping", sin dirección física real cargada
# todavía -- ENVIOCLICK_ORIGIN_ADDRESS es un placeholder hasta que la
# haya).
ENVIOCLICK_SANDBOX: "true"
ENVIOCLICK_ORIGIN_DANE_CODE: "11001000"
ENVIOCLICK_ORIGIN_ADDRESS: "Calle 1 No 1-1"
ENVIOCLICK_ORIGIN_COMPANY: "ARI Shopping"
ENVIOCLICK_ORIGIN_EMAIL: "[email protected]"
ENVIOCLICK_ORIGIN_PHONE: "3000000000"
@@ -65,7 +65,7 @@ spec:
memory: 64Mi
- name: migrations
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.225
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.229
imagePullPolicy: IfNotPresent
command:
- node
@@ -86,7 +86,7 @@ spec:
containers:
- name: app
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.225
image: gitea.cruzcloud.net/devops/ecommerce-medusa:v1.0.229
imagePullPolicy: IfNotPresent
envFrom:
- configMapRef:
@@ -36,14 +36,21 @@ stringData:
# x-revalidate-secret al invocar POST /api/revalidate en el storefront.
REVALIDATE_SECRET: CAMBIAR
# PayU LatAm (ADR 0009) -- credenciales del comercio. Mientras no exista
# cuenta real, usar las de sandbox genéricas y públicas de PayU
# (developers.payulatam.com/latam/en/docs/getting-started/test-your-solution.html),
# nunca hardcodeadas acá.
PAYU_API_LOGIN: CAMBIAR
PAYU_API_KEY: CAMBIAR
PAYU_MERCHANT_ID: CAMBIAR
PAYU_ACCOUNT_ID: CAMBIAR
# ePayco (ADR 0009 -- tercer proveedor evaluado: PayU LatAm quedó
# inviable porque Rapyd compró ese negocio en 2023, Rapyd descartado
# por costo; ePayco es de Davivienda desde 2024, mejor tarifa PSE para
# el volumen real del usuario). Credenciales del comercio real, nunca
# hardcodeadas acá. p_cust_id_cliente/p_key son valores propios del
# dashboard de ePayco (Configuración > Llaves secretas), distintos de
# publicKey/privateKey.
EPAYCO_PUBLIC_KEY: CAMBIAR
EPAYCO_PRIVATE_KEY: CAMBIAR
EPAYCO_P_CUST_ID_CLIENTE: CAMBIAR
EPAYCO_P_KEY: CAMBIAR
# EnvíoClick (ADR 0009 §2) -- API key real de la cuenta
# (envioclickpro.com.co > API Key / Desarrolladores).
ENVIOCLICK_API_KEY: CAMBIAR
---
apiVersion: v1
kind: Secret
@@ -14,8 +14,8 @@ const SELECT_CLASS =
"h-12 w-full rounded-full border border-slate-200 bg-white px-5 text-sm text-slate-950 outline-none transition focus:border-cyan-500 focus:ring-4 focus:ring-cyan-300/30 disabled:opacity-50";
// ADR 0009: direccion + envio + pago real, todo contra el Store API real.
// El pago es PayU LatAm/PSE (modulo custom del backend, ver
// workloads/commerce-backend/src/modules/payu-pse) -- redirige al banco
// El pago es ePayco/PSE (modulo custom del backend, ver
// workloads/commerce-backend/src/modules/epayco-pse) -- redirige al banco
// del comprador, la confirmacion real llega despues via webhook async
// (nunca por esta pantalla). WhatsApp queda como respaldo manual si la
// iniciacion del pago falla, ya no como camino principal.
@@ -68,7 +68,7 @@ export function CheckoutClient() {
const [error, setError] = useState<string | null>(null);
const [banks, setBanks] = useState<PseBank[]>([]);
const [bankCode, setBankCode] = useState("");
const [userType, setUserType] = useState<"N" | "J">("N");
const [userType, setUserType] = useState<"0" | "1">("0");
const [docType, setDocType] = useState<"CC" | "CE" | "NIT" | "TI" | "PP">("CC");
const [docNumber, setDocNumber] = useState("");
@@ -88,6 +88,12 @@ export function CheckoutClient() {
address1: form.address1,
city: selectedCiudad.name,
province: selectedDepartamento.name,
// ids DIVIPOLA reales -- EnvioClick (fulfillment provider) los
// necesita para cotizar sin ambiguedad, el nombre solo no
// alcanza (hay municipios con el mismo nombre en distintos
// departamentos).
cityId: selectedCiudad.id,
departmentId: selectedDepartamento.id,
phone: form.phone,
email: form.email,
});
@@ -139,17 +145,16 @@ export function CheckoutClient() {
userType,
docType,
docNumber,
fullName: `${form.firstName} ${form.lastName}`.trim(),
firstName: form.firstName,
lastName: form.lastName,
email: form.email,
phone: form.phone,
address: form.address1,
city: selectedCiudad.name,
state: selectedDepartamento.name,
});
if (session.error) throw new Error(session.error);
const completion = await postJson("/api/cart/complete", { cartId });
if (completion.error) throw new Error(completion.error);
// Navegacion completa, no iframe -- asi lo exige PayU para el
// Navegacion completa, no iframe -- asi lo exige ePayco para el
// flujo de PSE. La confirmacion real llega despues via webhook,
// esta pagina nunca vuelve a cargar sola.
window.location.href = session.bankUrl;
@@ -260,8 +265,8 @@ export function CheckoutClient() {
)}
<div className="flex gap-4 text-sm font-bold">
<label className="flex items-center gap-2"><input type="radio" name="userType" checked={userType === "N"} onChange={() => setUserType("N")} />Persona natural</label>
<label className="flex items-center gap-2"><input type="radio" name="userType" checked={userType === "J"} onChange={() => setUserType("J")} />Persona jurídica</label>
<label className="flex items-center gap-2"><input type="radio" name="userType" checked={userType === "0"} onChange={() => setUserType("0")} />Persona natural</label>
<label className="flex items-center gap-2"><input type="radio" name="userType" checked={userType === "1"} onChange={() => setUserType("1")} />Persona jurídica</label>
</div>
<div className="grid gap-4 sm:grid-cols-2">
@@ -5,12 +5,12 @@ import { Clock } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
// PayU redirige aca despues de que el comprador paga en su banco -- esto
// NO confirma el pago. Los parametros de esta URL son solo informativos;
// el estado real y definitivo llega por el webhook async de PayU al
// backend (getWebhookActionAndData en el modulo payu-pse), nunca por
// esta pantalla. Por eso el mensaje nunca afirma exito, solo que
// estamos esperando la confirmacion.
// ePayco redirige aca despues de que el comprador paga en su banco --
// esto NO confirma el pago. Los parametros de esta URL son solo
// informativos; el estado real y definitivo llega por el webhook async
// de ePayco al backend (getWebhookActionAndData en el modulo
// epayco-pse), nunca por esta pantalla. Por eso el mensaje nunca afirma
// exito, solo que estamos esperando la confirmacion.
export function PseReturnClient() {
const searchParams = useSearchParams();
const referenceCode = searchParams.get("referenceCode") ?? searchParams.get("reference_sale");
@@ -20,7 +20,7 @@ export function PseReturnClient() {
<Clock className="mx-auto size-12 text-ari-marino" />
<h1 className="mt-6 text-2xl font-black">Estamos confirmando tu pago</h1>
<p className="mt-3 text-slate-500">
Tu banco ya procesó la transferencia. PayU nos avisa en unos minutos si quedó aprobada — te llegará la confirmación por correo.
Tu banco ya procesó la transferencia. ePayco nos avisa en unos minutos si quedó aprobada — te llegará la confirmación por correo.
{referenceCode && (
<>
{" "}Tu número de referencia es <strong>{referenceCode}</strong>.
@@ -25,7 +25,7 @@ spec:
runAsUser: 1001
containers:
- name: app
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.226
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.230
env:
- name: HEADLESS_PROVIDER
value: medusa
@@ -46,6 +46,14 @@ export interface ShippingAddressInput {
province?: string;
phone: string;
email: string;
// ids DIVIPOLA/DANE reales (lib/data/colombia.ts) -- se guardan en la
// metadata de la direccion porque Medusa no tiene un campo propio para
// esto. EnvioClick (fulfillment provider real, ver ADR 0009 §2) los
// necesita para cotizar/generar guia sin ambiguedad -- el nombre de
// ciudad solo no alcanza (hay nombres de municipio repetidos entre
// distintos departamentos en Colombia).
cityId?: number;
departmentId?: number;
}
export interface ShippingOption {
@@ -212,6 +220,10 @@ export async function setShippingAddress(
province: address.province || undefined,
country_code: "co",
phone: address.phone,
metadata:
address.cityId || address.departmentId
? { cityId: address.cityId, departmentId: address.departmentId }
: undefined,
},
},
});
@@ -1,6 +1,11 @@
// ADR 0009: pago real via PayU LatAm (PSE), modulo custom del backend
// (workloads/commerce-backend/src/modules/payu-pse). Archivo separado de
// medusa-cart.ts por el mismo motivo que ese archivo esta separado de
// ADR 0009: pago real via ePayco (PSE) -- tercer proveedor evaluado
// (PayU LatAm descartado porque Rapyd compró ese negocio en 2023 y el
// registro real quedó con credenciales incompatibles; Rapyd descartado
// por costo). ePayco: 100% de Davivienda desde 2024, comisión PSE más
// baja para el volumen real del usuario, tarifa preferencial con cuenta
// Davivienda. Módulo custom del backend
// (workloads/commerce-backend/src/modules/epayco-pse). Archivo separado
// de medusa-cart.ts por el mismo motivo que ese archivo esta separado de
// medusa.ts -- reutiliza su mismo fetch helper (medusaCartFetch), no
// duplica la logica de headers/no-store.
import { medusaCartFetch } from "./medusa-cart";
@@ -10,7 +15,9 @@ export interface PseBank {
description: string;
}
export type PseUserType = "N" | "J";
// "0" persona natural, "1" persona juridica -- valores reales que espera
// ePayco (campo type_person), no un codigo propio nuestro.
export type PseUserType = "0" | "1";
export type PseDocType = "CC" | "CE" | "NIT" | "TI" | "PP";
export interface PsePaymentInput {
@@ -18,18 +25,17 @@ export interface PsePaymentInput {
userType: PseUserType;
docType: PseDocType;
docNumber: string;
fullName: string;
firstName: string;
lastName: string;
email: string;
phone: string;
address: string;
city: string;
state: string;
}
// "pp_{identifier}_{id}" -- convencion de Medusa, confirmada contra el
// codigo fuente de @medusajs/payment (identifier "payu" + id "pse" en
// codigo fuente de @medusajs/payment (identifier "epayco" + id "pse" en
// medusa-config.ts).
const PSE_PROVIDER_ID = "pp_payu_pse";
const PSE_PROVIDER_ID = "pp_epayco_pse";
export async function getPseBanks(): Promise<PseBank[]> {
const data = await medusaCartFetch("/store/payments/pse-banks");
@@ -58,12 +64,11 @@ export async function createPsePaymentSession(paymentCollectionId: string, input
user_type: input.userType,
doc_type: input.docType,
doc_number: input.docNumber,
full_name: input.fullName,
first_name: input.firstName,
last_name: input.lastName,
email: input.email,
phone: input.phone,
address: input.address,
city: input.city,
state: input.state,
},
},
});
@@ -73,7 +78,7 @@ export async function createPsePaymentSession(paymentCollectionId: string, input
const session = collection.payment_sessions?.find((paymentSession) => paymentSession.provider_id === PSE_PROVIDER_ID);
const bankUrl = session?.data?.bank_url;
if (!bankUrl) {
throw new Error("PayU no devolvió la URL del banco para completar el pago.");
throw new Error("ePayco no devolvió la URL del banco para completar el pago.");
}
return bankUrl;
}