@pandait.tech/payment-bank-transfer 1.0.0 → 2.0.0

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/README.md CHANGED
@@ -4,16 +4,30 @@ Adapter de transferencia bancaria con comprobante para `panda-commerce-kit`. Imp
4
4
  `PaymentMethod` sin red: no llama a ninguna API externa, no recibe webhooks de un banco, no sube ni
5
5
  almacena el archivo del comprobante (solo su metadata).
6
6
 
7
+ ## Canales
8
+
9
+ Un solo método de pago ("transferencia"), con **canales** habilitables por cliente en
10
+ `config.channels`. Cada canal es de dos tipos:
11
+
12
+ - **`bank`** — una cuenta bancaria (`account: BankAccount`).
13
+ - **`wallet-qr`** — el QR **estático** del comercio para DeUna o peiGo (`wallet`, `qrImageUrl`,
14
+ `holder`, `phone?`). Ninguno de los dos tiene aquí una API de comercio: el QR es fijo y no lleva
15
+ monto ni orden, por eso el comprobante sigue siendo obligatorio.
16
+
17
+ Un canal se apaga con `enabled: false` sin borrarlo de la configuración. Los `id` deben ser únicos:
18
+ `toBankTransferPaymentMethod` lanza al construirse si hay dos canales con el mismo `id`.
19
+
7
20
  ## Flujo
8
21
 
9
- 1. **`prepare(order)`** — devuelve las cuentas bancarias configuradas, la referencia (`order.id`),
10
- el monto y un `whatsappUrl` opcional para que el cliente avise por WhatsApp. La orden pasa a
11
- `awaiting_payment_proof`.
22
+ 1. **`prepare(order)`** — devuelve los canales habilitados (`config.channels` filtrados por
23
+ `enabled !== false`), la referencia (`order.id`), el monto y un `whatsappUrl` opcional para que
24
+ el cliente avise por WhatsApp. La orden pasa a `awaiting_payment_proof`. Si no queda ningún canal
25
+ habilitado, lanza un error en vez de devolver un checkout sin formas de pagar.
12
26
  2. El cliente transfiere y **la app** (fuera de este package) sube el archivo del comprobante a
13
- almacenamiento y llama a **`submitProof(input)`** con su metadata (`url`, `mimeType`,
14
- `sizeBytes`, `referenceNumber`, `declaredAmount`). `submitProof` busca la orden y valida si la
15
- referencia ya se usó (vía `ports`), corre `validateProof` — puro y síncrono — y, solo si es
16
- válido, invoca `ports.onProofAccepted`.
27
+ almacenamiento y llama a **`submitProof(input)`** con su metadata (`channelId`, `url`,
28
+ `mimeType`, `sizeBytes`, `referenceNumber`, `declaredAmount`). `submitProof` busca la orden y
29
+ valida si la referencia ya se usó (vía `ports`), corre `validateProof` — puro y síncrono — y,
30
+ solo si es válido, invoca `ports.onProofAccepted`.
17
31
  3. Un admin revisa el comprobante y aprueba o rechaza. Esa decisión llega como
18
32
  **`handleWebhook({ orderRef, decision })`** — aquí "el webhook" es la decisión del admin, no un
19
33
  banco.
@@ -46,6 +60,7 @@ sin mocks async):
46
60
  |---|---|
47
61
  | `order-not-found` | La orden no existe |
48
62
  | `order-not-awaiting-proof` | La orden no está en `awaiting_payment_proof` (ya `paid`, `cancelled`, …) — bloquea re-envío y replay sobre una orden ya cobrada |
63
+ | `unknown-channel` | `channelId` no corresponde a ningún canal habilitado — el mensaje lista los `id` habilitados |
49
64
  | `unsupported-file` | `mimeType` fuera del allowlist, o `sizeBytes` fuera de rango |
50
65
  | `amount-below-total` | `declaredAmount < order.total` (pagar de más se acepta) |
51
66
  | `duplicate-reference` | El mismo número de referencia ya se usó en otra orden — el fraude real de este método |
@@ -59,8 +74,21 @@ import { toBankTransferPaymentMethod } from "@pandait.tech/payment-bank-transfer
59
74
 
60
75
  const bankTransfer = toBankTransferPaymentMethod(
61
76
  {
62
- accounts: [
63
- { bank: "Banco Pichincha", holder: "Mi Tienda SAS", accountNumber: "123", accountType: "corriente", taxId: "099..." },
77
+ channels: [
78
+ {
79
+ id: "banco-pichincha",
80
+ kind: "bank",
81
+ label: "Banco Pichincha — corriente",
82
+ account: { bank: "Banco Pichincha", holder: "Mi Tienda SAS", accountNumber: "123", accountType: "corriente", taxId: "099..." },
83
+ },
84
+ {
85
+ id: "qr-deuna",
86
+ kind: "wallet-qr",
87
+ label: "QR DeUna",
88
+ wallet: "deuna",
89
+ qrImageUrl: "https://storage.mitienda.com/qr-deuna.png",
90
+ holder: "Mi Tienda SAS",
91
+ },
64
92
  ],
65
93
  whatsapp: "593999999999",
66
94
  },
package/dist/index.cjs CHANGED
@@ -13,7 +13,7 @@ var DEFAULT_ALLOWED_MIME_TYPES = [
13
13
  var money = (n) => `$${n.toFixed(2)}`;
14
14
  var megabytes = (bytes) => `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
15
15
  function validateProof(input, ctx) {
16
- const { order, referenceAlreadyUsed, config } = ctx;
16
+ const { order, referenceAlreadyUsed, config, channels } = ctx;
17
17
  if (!order) {
18
18
  return {
19
19
  ok: false,
@@ -28,6 +28,14 @@ function validateProof(input, ctx) {
28
28
  message: `La orden ${order.id} est\xE1 en estado "${order.status}" y no admite un nuevo comprobante. Si ya pagaste, revisa el estado de tu pedido.`
29
29
  };
30
30
  }
31
+ if (!channels.some((channel) => channel.id === input.channelId)) {
32
+ const enabledIds = channels.map((channel) => channel.id).join(", ") || "ninguno";
33
+ return {
34
+ ok: false,
35
+ reason: "unknown-channel",
36
+ message: `El canal de cobro "${input.channelId}" no existe o est\xE1 deshabilitado. Canales habilitados: ${enabledIds}.`
37
+ };
38
+ }
31
39
  const maxBytes = config.maxProofBytes ?? DEFAULT_MAX_PROOF_BYTES;
32
40
  const allowedTypes = config.allowedMimeTypes ?? DEFAULT_ALLOWED_MIME_TYPES;
33
41
  if (!allowedTypes.includes(input.mimeType) || input.sizeBytes <= 0 || input.sizeBytes > maxBytes) {
@@ -55,17 +63,38 @@ function validateProof(input, ctx) {
55
63
  }
56
64
 
57
65
  // src/adapter.ts
66
+ function enabledChannels(config) {
67
+ return config.channels.filter((channel) => channel.enabled !== false);
68
+ }
69
+ function assertNoDuplicateChannelIds(channels) {
70
+ const seen = /* @__PURE__ */ new Set();
71
+ for (const channel of channels) {
72
+ if (seen.has(channel.id)) {
73
+ throw new Error(
74
+ `Configuraci\xF3n de payment-bank-transfer inv\xE1lida: el id de canal "${channel.id}" est\xE1 repetido en channels.`
75
+ );
76
+ }
77
+ seen.add(channel.id);
78
+ }
79
+ }
58
80
  function buildWhatsappUrl(whatsapp, orderId) {
59
81
  const text = encodeURIComponent(`Hola, env\xEDo el comprobante de transferencia de la orden ${orderId}.`);
60
82
  return `https://wa.me/${whatsapp}?text=${text}`;
61
83
  }
62
84
  function toBankTransferPaymentMethod(config, ports) {
85
+ assertNoDuplicateChannelIds(config.channels);
63
86
  return {
64
87
  id: "bank-transfer",
65
88
  label: "Transferencia bancaria",
66
89
  async prepare(order) {
90
+ const channels = enabledChannels(config);
91
+ if (channels.length === 0) {
92
+ throw new Error(
93
+ "No hay ning\xFAn canal de cobro habilitado en la configuraci\xF3n (channels): revisa que al menos uno tenga enabled !== false."
94
+ );
95
+ }
67
96
  return {
68
- accounts: config.accounts,
97
+ channels,
69
98
  reference: order.id,
70
99
  amount: order.total,
71
100
  status: "awaiting_payment_proof",
@@ -77,7 +106,12 @@ function toBankTransferPaymentMethod(config, ports) {
77
106
  ports.findOrder(input.orderRef),
78
107
  ports.isReferenceUsed(input.referenceNumber)
79
108
  ]);
80
- const check = validateProof(input, { order, referenceAlreadyUsed, config });
109
+ const check = validateProof(input, {
110
+ order,
111
+ referenceAlreadyUsed,
112
+ config,
113
+ channels: enabledChannels(config)
114
+ });
81
115
  if (check.ok && order) {
82
116
  await ports.onProofAccepted(input, order);
83
117
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/config.ts","../src/proof.ts","../src/adapter.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["export interface BankAccount {\n bank: string;\n holder: string;\n accountNumber: string;\n accountType: \"ahorros\" | \"corriente\";\n /** Cédula/RUC del titular. */\n taxId: string;\n}\n\nexport interface BankTransferConfig {\n accounts: BankAccount[];\n /** E.164 sin '+', para el link wa.me. */\n whatsapp?: string;\n maxProofBytes?: number;\n allowedMimeTypes?: readonly string[];\n}\n\nexport const DEFAULT_MAX_PROOF_BYTES = 5 * 1024 * 1024;\nexport const DEFAULT_ALLOWED_MIME_TYPES: readonly string[] = [\n \"image/jpeg\",\n \"image/png\",\n \"image/webp\",\n \"application/pdf\",\n];\n","import type { Order } from \"@pandait.tech/commerce-core\";\nimport type { BankTransferConfig } from \"./config.js\";\nimport { DEFAULT_MAX_PROOF_BYTES, DEFAULT_ALLOWED_MIME_TYPES } from \"./config.js\";\n\nexport interface ProofInput {\n orderRef: string;\n url: string;\n mimeType: string;\n sizeBytes: number;\n /** Número de transacción/documento del banco. */\n referenceNumber: string;\n declaredAmount: number;\n depositedAt?: string;\n}\n\nexport type ProofRejection =\n | \"order-not-found\"\n | \"order-not-awaiting-proof\"\n | \"unsupported-file\"\n | \"amount-below-total\"\n | \"duplicate-reference\";\n\nexport type ProofCheck = { ok: true } | { ok: false; reason: ProofRejection; message: string };\n\nexport interface ProofCheckContext {\n order?: Order;\n referenceAlreadyUsed: boolean;\n config: BankTransferConfig;\n}\n\nconst money = (n: number) => `$${n.toFixed(2)}`;\nconst megabytes = (bytes: number) => `${(bytes / (1024 * 1024)).toFixed(1)}MB`;\n\nexport function validateProof(input: ProofInput, ctx: ProofCheckContext): ProofCheck {\n const { order, referenceAlreadyUsed, config } = ctx;\n\n if (!order) {\n return {\n ok: false,\n reason: \"order-not-found\",\n message: `No existe una orden con referencia \"${input.orderRef}\". Verifica el código de orden.`,\n };\n }\n\n if (order.status !== \"awaiting_payment_proof\") {\n return {\n ok: false,\n reason: \"order-not-awaiting-proof\",\n message: `La orden ${order.id} está en estado \"${order.status}\" y no admite un nuevo comprobante. Si ya pagaste, revisa el estado de tu pedido.`,\n };\n }\n\n const maxBytes = config.maxProofBytes ?? DEFAULT_MAX_PROOF_BYTES;\n const allowedTypes = config.allowedMimeTypes ?? DEFAULT_ALLOWED_MIME_TYPES;\n if (\n !allowedTypes.includes(input.mimeType) ||\n input.sizeBytes <= 0 ||\n input.sizeBytes > maxBytes\n ) {\n return {\n ok: false,\n reason: \"unsupported-file\",\n message: `El archivo debe ser ${allowedTypes.join(\", \")} y pesar hasta ${megabytes(maxBytes)}. Sube el comprobante en un formato válido.`,\n };\n }\n\n if (input.declaredAmount < order.total) {\n return {\n ok: false,\n reason: \"amount-below-total\",\n message: `El monto declarado (${money(input.declaredAmount)}) es menor al total de la orden (${money(order.total)}). Sube un comprobante por el total.`,\n };\n }\n\n if (referenceAlreadyUsed) {\n return {\n ok: false,\n reason: \"duplicate-reference\",\n message: `El número de referencia \"${input.referenceNumber}\" ya fue usado en otra orden. Cada comprobante debe tener una referencia única.`,\n };\n }\n\n return { ok: true };\n}\n","// Tipos estructuralmente compatibles con payment-core (sin importarlo — evita dep de workspace en runtime).\nimport type { Order } from \"@pandait.tech/commerce-core\";\nimport type { BankTransferConfig } from \"./config.js\";\nimport type { ProofCheck, ProofInput } from \"./proof.js\";\nimport { validateProof } from \"./proof.js\";\n\ninterface ConfirmResult {\n status: \"paid\" | \"pending\" | \"failed\" | \"challenge\";\n transactionId?: string;\n redirectUrl?: string;\n error?: string;\n}\n\n/**\n * Contrato de handleWebhook (§4 del brief): \"esta orden se pagó, finalízala\" solo cuando\n * handled === true. Un rechazo NO es un fallo silencioso: viaja como\n * { handled: false, orderId, decision: \"rejected\" }, no como { handled: false } a secas.\n */\ninterface WebhookResult {\n handled: boolean;\n orderId?: string;\n decision?: \"approved\" | \"rejected\";\n}\n\ninterface PaymentMethod {\n id: string;\n label: string;\n prepare(order: Order): Promise<Record<string, unknown>>;\n confirm(ref: string): Promise<ConfirmResult>;\n handleWebhook(payload: unknown): Promise<WebhookResult>;\n}\n\nexport interface BankTransferPaymentMethod extends PaymentMethod {\n /** Valida y registra el comprobante subido por el cliente para una orden. */\n submitProof(input: ProofInput): Promise<ProofCheck>;\n}\n\nexport interface BankTransferWebhookPayload {\n orderRef?: string;\n decision?: \"approved\" | \"rejected\";\n reviewerId?: string;\n reviewedAt?: string;\n}\n\nexport interface BankTransferPorts {\n findOrder(orderRef: string): Promise<Order | undefined>;\n isReferenceUsed(referenceNumber: string): Promise<boolean>;\n onProofAccepted(input: ProofInput, order: Order): Promise<void>;\n /** Decisión del admin para una orden; ausente/undefined mientras esté en revisión. */\n getDecision?(orderRef: string): Promise<\"approved\" | \"rejected\" | undefined>;\n}\n\nfunction buildWhatsappUrl(whatsapp: string, orderId: string): string {\n const text = encodeURIComponent(`Hola, envío el comprobante de transferencia de la orden ${orderId}.`);\n return `https://wa.me/${whatsapp}?text=${text}`;\n}\n\nexport function toBankTransferPaymentMethod(\n config: BankTransferConfig,\n ports: BankTransferPorts,\n): BankTransferPaymentMethod {\n return {\n id: \"bank-transfer\",\n label: \"Transferencia bancaria\",\n\n async prepare(order: Order): Promise<Record<string, unknown>> {\n return {\n accounts: config.accounts,\n reference: order.id,\n amount: order.total,\n status: \"awaiting_payment_proof\",\n ...(config.whatsapp ? { whatsappUrl: buildWhatsappUrl(config.whatsapp, order.id) } : {}),\n };\n },\n\n async submitProof(input: ProofInput): Promise<ProofCheck> {\n const [order, referenceAlreadyUsed] = await Promise.all([\n ports.findOrder(input.orderRef),\n ports.isReferenceUsed(input.referenceNumber),\n ]);\n\n const check = validateProof(input, { order, referenceAlreadyUsed, config });\n if (check.ok && order) {\n await ports.onProofAccepted(input, order);\n }\n return check;\n },\n\n async confirm(ref: string): Promise<ConfirmResult> {\n const decision = await ports.getDecision?.(ref);\n if (decision === \"approved\") return { status: \"paid\" };\n if (decision === \"rejected\") return { status: \"failed\", error: \"Comprobante rechazado\" };\n return { status: \"pending\" };\n },\n\n async handleWebhook(payload: unknown): Promise<WebhookResult> {\n const p = payload as BankTransferWebhookPayload | null;\n if (!p?.orderRef || (p.decision !== \"approved\" && p.decision !== \"rejected\")) {\n return { handled: false };\n }\n const { orderRef, decision } = p;\n\n if (decision === \"rejected\") {\n return { handled: false, orderId: orderRef, decision };\n }\n\n return { handled: true, orderId: orderRef, decision };\n },\n };\n}\n"]}
1
+ {"version":3,"sources":["../src/config.ts","../src/proof.ts","../src/adapter.ts"],"names":[],"mappings":"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","file":"index.cjs","sourcesContent":["export interface BankAccount {\n bank: string;\n holder: string;\n accountNumber: string;\n accountType: \"ahorros\" | \"corriente\";\n /** Cédula/RUC del titular. */\n taxId: string;\n}\n\ninterface ChannelBase {\n /** Único dentro de la configuración. Viaja en el comprobante. */\n id: string;\n /** Lo que ve el comprador. Ej. \"Banco Pichincha — ahorros\". */\n label: string;\n /** Default: true. Se apaga sin borrar el canal de la configuración. */\n enabled?: boolean;\n}\n\nexport type TransferChannel =\n | (ChannelBase & { kind: \"bank\"; account: BankAccount })\n | (ChannelBase & {\n kind: \"wallet-qr\";\n wallet: \"deuna\" | \"peigo\";\n // ponytail: QR estático sin monto — se cambia a QR dinámico cuando un comercio tenga contrato de API\n /** Imagen del QR ESTÁTICO del comercio. La sube el comercio; el kit no la genera. */\n qrImageUrl: string;\n /** Titular de la billetera, para que el comprador confirme a quién paga. */\n holder: string;\n /** Teléfono de la billetera, si el comercio también cobra por número. */\n phone?: string;\n });\n\nexport interface BankTransferConfig {\n channels: TransferChannel[];\n /** E.164 sin '+', para el link wa.me. */\n whatsapp?: string;\n maxProofBytes?: number;\n allowedMimeTypes?: readonly string[];\n}\n\nexport const DEFAULT_MAX_PROOF_BYTES = 5 * 1024 * 1024;\nexport const DEFAULT_ALLOWED_MIME_TYPES: readonly string[] = [\n \"image/jpeg\",\n \"image/png\",\n \"image/webp\",\n \"application/pdf\",\n];\n","import type { Order } from \"@pandait.tech/commerce-core\";\nimport type { BankTransferConfig, TransferChannel } from \"./config.js\";\nimport { DEFAULT_MAX_PROOF_BYTES, DEFAULT_ALLOWED_MIME_TYPES } from \"./config.js\";\n\nexport interface ProofInput {\n orderRef: string;\n /** `id` del canal (config.channels) por el que se pagó. */\n channelId: string;\n url: string;\n mimeType: string;\n sizeBytes: number;\n /** Número de transacción/documento del banco. */\n referenceNumber: string;\n declaredAmount: number;\n depositedAt?: string;\n}\n\nexport type ProofRejection =\n | \"order-not-found\"\n | \"order-not-awaiting-proof\"\n | \"unknown-channel\"\n | \"unsupported-file\"\n | \"amount-below-total\"\n | \"duplicate-reference\";\n\nexport type ProofCheck = { ok: true } | { ok: false; reason: ProofRejection; message: string };\n\nexport interface ProofCheckContext {\n order?: Order;\n referenceAlreadyUsed: boolean;\n config: BankTransferConfig;\n /** Canales habilitados (`enabled !== false`) contra los que se valida `channelId`. */\n channels: TransferChannel[];\n}\n\nconst money = (n: number) => `$${n.toFixed(2)}`;\nconst megabytes = (bytes: number) => `${(bytes / (1024 * 1024)).toFixed(1)}MB`;\n\nexport function validateProof(input: ProofInput, ctx: ProofCheckContext): ProofCheck {\n const { order, referenceAlreadyUsed, config, channels } = ctx;\n\n if (!order) {\n return {\n ok: false,\n reason: \"order-not-found\",\n message: `No existe una orden con referencia \"${input.orderRef}\". Verifica el código de orden.`,\n };\n }\n\n if (order.status !== \"awaiting_payment_proof\") {\n return {\n ok: false,\n reason: \"order-not-awaiting-proof\",\n message: `La orden ${order.id} está en estado \"${order.status}\" y no admite un nuevo comprobante. Si ya pagaste, revisa el estado de tu pedido.`,\n };\n }\n\n if (!channels.some((channel) => channel.id === input.channelId)) {\n const enabledIds = channels.map((channel) => channel.id).join(\", \") || \"ninguno\";\n return {\n ok: false,\n reason: \"unknown-channel\",\n message: `El canal de cobro \"${input.channelId}\" no existe o está deshabilitado. Canales habilitados: ${enabledIds}.`,\n };\n }\n\n const maxBytes = config.maxProofBytes ?? DEFAULT_MAX_PROOF_BYTES;\n const allowedTypes = config.allowedMimeTypes ?? DEFAULT_ALLOWED_MIME_TYPES;\n if (\n !allowedTypes.includes(input.mimeType) ||\n input.sizeBytes <= 0 ||\n input.sizeBytes > maxBytes\n ) {\n return {\n ok: false,\n reason: \"unsupported-file\",\n message: `El archivo debe ser ${allowedTypes.join(\", \")} y pesar hasta ${megabytes(maxBytes)}. Sube el comprobante en un formato válido.`,\n };\n }\n\n if (input.declaredAmount < order.total) {\n return {\n ok: false,\n reason: \"amount-below-total\",\n message: `El monto declarado (${money(input.declaredAmount)}) es menor al total de la orden (${money(order.total)}). Sube un comprobante por el total.`,\n };\n }\n\n if (referenceAlreadyUsed) {\n return {\n ok: false,\n reason: \"duplicate-reference\",\n message: `El número de referencia \"${input.referenceNumber}\" ya fue usado en otra orden. Cada comprobante debe tener una referencia única.`,\n };\n }\n\n return { ok: true };\n}\n","// Tipos estructuralmente compatibles con payment-core (sin importarlo — evita dep de workspace en runtime).\nimport type { Order } from \"@pandait.tech/commerce-core\";\nimport type { BankTransferConfig, TransferChannel } from \"./config.js\";\nimport type { ProofCheck, ProofInput } from \"./proof.js\";\nimport { validateProof } from \"./proof.js\";\n\nfunction enabledChannels(config: BankTransferConfig): TransferChannel[] {\n return config.channels.filter((channel) => channel.enabled !== false);\n}\n\nfunction assertNoDuplicateChannelIds(channels: TransferChannel[]): void {\n const seen = new Set<string>();\n for (const channel of channels) {\n if (seen.has(channel.id)) {\n throw new Error(\n `Configuración de payment-bank-transfer inválida: el id de canal \"${channel.id}\" está repetido en channels.`,\n );\n }\n seen.add(channel.id);\n }\n}\n\ninterface ConfirmResult {\n status: \"paid\" | \"pending\" | \"failed\" | \"challenge\";\n transactionId?: string;\n redirectUrl?: string;\n error?: string;\n}\n\n/**\n * Contrato de handleWebhook (§4 del brief): \"esta orden se pagó, finalízala\" solo cuando\n * handled === true. Un rechazo NO es un fallo silencioso: viaja como\n * { handled: false, orderId, decision: \"rejected\" }, no como { handled: false } a secas.\n */\ninterface WebhookResult {\n handled: boolean;\n orderId?: string;\n decision?: \"approved\" | \"rejected\";\n}\n\ninterface PaymentMethod {\n id: string;\n label: string;\n prepare(order: Order): Promise<Record<string, unknown>>;\n confirm(ref: string): Promise<ConfirmResult>;\n handleWebhook(payload: unknown): Promise<WebhookResult>;\n}\n\nexport interface BankTransferPaymentMethod extends PaymentMethod {\n /** Valida y registra el comprobante subido por el cliente para una orden. */\n submitProof(input: ProofInput): Promise<ProofCheck>;\n}\n\nexport interface BankTransferWebhookPayload {\n orderRef?: string;\n decision?: \"approved\" | \"rejected\";\n reviewerId?: string;\n reviewedAt?: string;\n}\n\nexport interface BankTransferPorts {\n findOrder(orderRef: string): Promise<Order | undefined>;\n isReferenceUsed(referenceNumber: string): Promise<boolean>;\n onProofAccepted(input: ProofInput, order: Order): Promise<void>;\n /** Decisión del admin para una orden; ausente/undefined mientras esté en revisión. */\n getDecision?(orderRef: string): Promise<\"approved\" | \"rejected\" | undefined>;\n}\n\nfunction buildWhatsappUrl(whatsapp: string, orderId: string): string {\n const text = encodeURIComponent(`Hola, envío el comprobante de transferencia de la orden ${orderId}.`);\n return `https://wa.me/${whatsapp}?text=${text}`;\n}\n\nexport function toBankTransferPaymentMethod(\n config: BankTransferConfig,\n ports: BankTransferPorts,\n): BankTransferPaymentMethod {\n assertNoDuplicateChannelIds(config.channels);\n\n return {\n id: \"bank-transfer\",\n label: \"Transferencia bancaria\",\n\n async prepare(order: Order): Promise<Record<string, unknown>> {\n const channels = enabledChannels(config);\n if (channels.length === 0) {\n throw new Error(\n \"No hay ningún canal de cobro habilitado en la configuración (channels): revisa que al menos uno tenga enabled !== false.\",\n );\n }\n return {\n channels,\n reference: order.id,\n amount: order.total,\n status: \"awaiting_payment_proof\",\n ...(config.whatsapp ? { whatsappUrl: buildWhatsappUrl(config.whatsapp, order.id) } : {}),\n };\n },\n\n async submitProof(input: ProofInput): Promise<ProofCheck> {\n const [order, referenceAlreadyUsed] = await Promise.all([\n ports.findOrder(input.orderRef),\n ports.isReferenceUsed(input.referenceNumber),\n ]);\n\n const check = validateProof(input, {\n order,\n referenceAlreadyUsed,\n config,\n channels: enabledChannels(config),\n });\n if (check.ok && order) {\n await ports.onProofAccepted(input, order);\n }\n return check;\n },\n\n async confirm(ref: string): Promise<ConfirmResult> {\n const decision = await ports.getDecision?.(ref);\n if (decision === \"approved\") return { status: \"paid\" };\n if (decision === \"rejected\") return { status: \"failed\", error: \"Comprobante rechazado\" };\n return { status: \"pending\" };\n },\n\n async handleWebhook(payload: unknown): Promise<WebhookResult> {\n const p = payload as BankTransferWebhookPayload | null;\n if (!p?.orderRef || (p.decision !== \"approved\" && p.decision !== \"rejected\")) {\n return { handled: false };\n }\n const { orderRef, decision } = p;\n\n if (decision === \"rejected\") {\n return { handled: false, orderId: orderRef, decision };\n }\n\n return { handled: true, orderId: orderRef, decision };\n },\n };\n}\n"]}
package/dist/index.d.cts CHANGED
@@ -6,8 +6,29 @@ interface BankAccount {
6
6
  /** Cédula/RUC del titular. */
7
7
  taxId: string;
8
8
  }
9
+ interface ChannelBase {
10
+ /** Único dentro de la configuración. Viaja en el comprobante. */
11
+ id: string;
12
+ /** Lo que ve el comprador. Ej. "Banco Pichincha — ahorros". */
13
+ label: string;
14
+ /** Default: true. Se apaga sin borrar el canal de la configuración. */
15
+ enabled?: boolean;
16
+ }
17
+ type TransferChannel = (ChannelBase & {
18
+ kind: "bank";
19
+ account: BankAccount;
20
+ }) | (ChannelBase & {
21
+ kind: "wallet-qr";
22
+ wallet: "deuna" | "peigo";
23
+ /** Imagen del QR ESTÁTICO del comercio. La sube el comercio; el kit no la genera. */
24
+ qrImageUrl: string;
25
+ /** Titular de la billetera, para que el comprador confirme a quién paga. */
26
+ holder: string;
27
+ /** Teléfono de la billetera, si el comercio también cobra por número. */
28
+ phone?: string;
29
+ });
9
30
  interface BankTransferConfig {
10
- accounts: BankAccount[];
31
+ channels: TransferChannel[];
11
32
  /** E.164 sin '+', para el link wa.me. */
12
33
  whatsapp?: string;
13
34
  maxProofBytes?: number;
@@ -88,6 +109,8 @@ interface Order {
88
109
 
89
110
  interface ProofInput {
90
111
  orderRef: string;
112
+ /** `id` del canal (config.channels) por el que se pagó. */
113
+ channelId: string;
91
114
  url: string;
92
115
  mimeType: string;
93
116
  sizeBytes: number;
@@ -96,7 +119,7 @@ interface ProofInput {
96
119
  declaredAmount: number;
97
120
  depositedAt?: string;
98
121
  }
99
- type ProofRejection = "order-not-found" | "order-not-awaiting-proof" | "unsupported-file" | "amount-below-total" | "duplicate-reference";
122
+ type ProofRejection = "order-not-found" | "order-not-awaiting-proof" | "unknown-channel" | "unsupported-file" | "amount-below-total" | "duplicate-reference";
100
123
  type ProofCheck = {
101
124
  ok: true;
102
125
  } | {
@@ -108,6 +131,8 @@ interface ProofCheckContext {
108
131
  order?: Order;
109
132
  referenceAlreadyUsed: boolean;
110
133
  config: BankTransferConfig;
134
+ /** Canales habilitados (`enabled !== false`) contra los que se valida `channelId`. */
135
+ channels: TransferChannel[];
111
136
  }
112
137
  declare function validateProof(input: ProofInput, ctx: ProofCheckContext): ProofCheck;
113
138
 
@@ -153,4 +178,4 @@ interface BankTransferPorts {
153
178
  }
154
179
  declare function toBankTransferPaymentMethod(config: BankTransferConfig, ports: BankTransferPorts): BankTransferPaymentMethod;
155
180
 
156
- export { type BankAccount, type BankTransferConfig, type BankTransferPaymentMethod, type BankTransferPorts, type BankTransferWebhookPayload, DEFAULT_ALLOWED_MIME_TYPES, DEFAULT_MAX_PROOF_BYTES, type ProofCheck, type ProofCheckContext, type ProofInput, type ProofRejection, toBankTransferPaymentMethod, validateProof };
181
+ export { type BankAccount, type BankTransferConfig, type BankTransferPaymentMethod, type BankTransferPorts, type BankTransferWebhookPayload, DEFAULT_ALLOWED_MIME_TYPES, DEFAULT_MAX_PROOF_BYTES, type ProofCheck, type ProofCheckContext, type ProofInput, type ProofRejection, type TransferChannel, toBankTransferPaymentMethod, validateProof };
package/dist/index.d.ts CHANGED
@@ -6,8 +6,29 @@ interface BankAccount {
6
6
  /** Cédula/RUC del titular. */
7
7
  taxId: string;
8
8
  }
9
+ interface ChannelBase {
10
+ /** Único dentro de la configuración. Viaja en el comprobante. */
11
+ id: string;
12
+ /** Lo que ve el comprador. Ej. "Banco Pichincha — ahorros". */
13
+ label: string;
14
+ /** Default: true. Se apaga sin borrar el canal de la configuración. */
15
+ enabled?: boolean;
16
+ }
17
+ type TransferChannel = (ChannelBase & {
18
+ kind: "bank";
19
+ account: BankAccount;
20
+ }) | (ChannelBase & {
21
+ kind: "wallet-qr";
22
+ wallet: "deuna" | "peigo";
23
+ /** Imagen del QR ESTÁTICO del comercio. La sube el comercio; el kit no la genera. */
24
+ qrImageUrl: string;
25
+ /** Titular de la billetera, para que el comprador confirme a quién paga. */
26
+ holder: string;
27
+ /** Teléfono de la billetera, si el comercio también cobra por número. */
28
+ phone?: string;
29
+ });
9
30
  interface BankTransferConfig {
10
- accounts: BankAccount[];
31
+ channels: TransferChannel[];
11
32
  /** E.164 sin '+', para el link wa.me. */
12
33
  whatsapp?: string;
13
34
  maxProofBytes?: number;
@@ -88,6 +109,8 @@ interface Order {
88
109
 
89
110
  interface ProofInput {
90
111
  orderRef: string;
112
+ /** `id` del canal (config.channels) por el que se pagó. */
113
+ channelId: string;
91
114
  url: string;
92
115
  mimeType: string;
93
116
  sizeBytes: number;
@@ -96,7 +119,7 @@ interface ProofInput {
96
119
  declaredAmount: number;
97
120
  depositedAt?: string;
98
121
  }
99
- type ProofRejection = "order-not-found" | "order-not-awaiting-proof" | "unsupported-file" | "amount-below-total" | "duplicate-reference";
122
+ type ProofRejection = "order-not-found" | "order-not-awaiting-proof" | "unknown-channel" | "unsupported-file" | "amount-below-total" | "duplicate-reference";
100
123
  type ProofCheck = {
101
124
  ok: true;
102
125
  } | {
@@ -108,6 +131,8 @@ interface ProofCheckContext {
108
131
  order?: Order;
109
132
  referenceAlreadyUsed: boolean;
110
133
  config: BankTransferConfig;
134
+ /** Canales habilitados (`enabled !== false`) contra los que se valida `channelId`. */
135
+ channels: TransferChannel[];
111
136
  }
112
137
  declare function validateProof(input: ProofInput, ctx: ProofCheckContext): ProofCheck;
113
138
 
@@ -153,4 +178,4 @@ interface BankTransferPorts {
153
178
  }
154
179
  declare function toBankTransferPaymentMethod(config: BankTransferConfig, ports: BankTransferPorts): BankTransferPaymentMethod;
155
180
 
156
- export { type BankAccount, type BankTransferConfig, type BankTransferPaymentMethod, type BankTransferPorts, type BankTransferWebhookPayload, DEFAULT_ALLOWED_MIME_TYPES, DEFAULT_MAX_PROOF_BYTES, type ProofCheck, type ProofCheckContext, type ProofInput, type ProofRejection, toBankTransferPaymentMethod, validateProof };
181
+ export { type BankAccount, type BankTransferConfig, type BankTransferPaymentMethod, type BankTransferPorts, type BankTransferWebhookPayload, DEFAULT_ALLOWED_MIME_TYPES, DEFAULT_MAX_PROOF_BYTES, type ProofCheck, type ProofCheckContext, type ProofInput, type ProofRejection, type TransferChannel, toBankTransferPaymentMethod, validateProof };
package/dist/index.js CHANGED
@@ -11,7 +11,7 @@ var DEFAULT_ALLOWED_MIME_TYPES = [
11
11
  var money = (n) => `$${n.toFixed(2)}`;
12
12
  var megabytes = (bytes) => `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
13
13
  function validateProof(input, ctx) {
14
- const { order, referenceAlreadyUsed, config } = ctx;
14
+ const { order, referenceAlreadyUsed, config, channels } = ctx;
15
15
  if (!order) {
16
16
  return {
17
17
  ok: false,
@@ -26,6 +26,14 @@ function validateProof(input, ctx) {
26
26
  message: `La orden ${order.id} est\xE1 en estado "${order.status}" y no admite un nuevo comprobante. Si ya pagaste, revisa el estado de tu pedido.`
27
27
  };
28
28
  }
29
+ if (!channels.some((channel) => channel.id === input.channelId)) {
30
+ const enabledIds = channels.map((channel) => channel.id).join(", ") || "ninguno";
31
+ return {
32
+ ok: false,
33
+ reason: "unknown-channel",
34
+ message: `El canal de cobro "${input.channelId}" no existe o est\xE1 deshabilitado. Canales habilitados: ${enabledIds}.`
35
+ };
36
+ }
29
37
  const maxBytes = config.maxProofBytes ?? DEFAULT_MAX_PROOF_BYTES;
30
38
  const allowedTypes = config.allowedMimeTypes ?? DEFAULT_ALLOWED_MIME_TYPES;
31
39
  if (!allowedTypes.includes(input.mimeType) || input.sizeBytes <= 0 || input.sizeBytes > maxBytes) {
@@ -53,17 +61,38 @@ function validateProof(input, ctx) {
53
61
  }
54
62
 
55
63
  // src/adapter.ts
64
+ function enabledChannels(config) {
65
+ return config.channels.filter((channel) => channel.enabled !== false);
66
+ }
67
+ function assertNoDuplicateChannelIds(channels) {
68
+ const seen = /* @__PURE__ */ new Set();
69
+ for (const channel of channels) {
70
+ if (seen.has(channel.id)) {
71
+ throw new Error(
72
+ `Configuraci\xF3n de payment-bank-transfer inv\xE1lida: el id de canal "${channel.id}" est\xE1 repetido en channels.`
73
+ );
74
+ }
75
+ seen.add(channel.id);
76
+ }
77
+ }
56
78
  function buildWhatsappUrl(whatsapp, orderId) {
57
79
  const text = encodeURIComponent(`Hola, env\xEDo el comprobante de transferencia de la orden ${orderId}.`);
58
80
  return `https://wa.me/${whatsapp}?text=${text}`;
59
81
  }
60
82
  function toBankTransferPaymentMethod(config, ports) {
83
+ assertNoDuplicateChannelIds(config.channels);
61
84
  return {
62
85
  id: "bank-transfer",
63
86
  label: "Transferencia bancaria",
64
87
  async prepare(order) {
88
+ const channels = enabledChannels(config);
89
+ if (channels.length === 0) {
90
+ throw new Error(
91
+ "No hay ning\xFAn canal de cobro habilitado en la configuraci\xF3n (channels): revisa que al menos uno tenga enabled !== false."
92
+ );
93
+ }
65
94
  return {
66
- accounts: config.accounts,
95
+ channels,
67
96
  reference: order.id,
68
97
  amount: order.total,
69
98
  status: "awaiting_payment_proof",
@@ -75,7 +104,12 @@ function toBankTransferPaymentMethod(config, ports) {
75
104
  ports.findOrder(input.orderRef),
76
105
  ports.isReferenceUsed(input.referenceNumber)
77
106
  ]);
78
- const check = validateProof(input, { order, referenceAlreadyUsed, config });
107
+ const check = validateProof(input, {
108
+ order,
109
+ referenceAlreadyUsed,
110
+ config,
111
+ channels: enabledChannels(config)
112
+ });
79
113
  if (check.ok && order) {
80
114
  await ports.onProofAccepted(input, order);
81
115
  }
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/config.ts","../src/proof.ts","../src/adapter.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["export interface BankAccount {\n bank: string;\n holder: string;\n accountNumber: string;\n accountType: \"ahorros\" | \"corriente\";\n /** Cédula/RUC del titular. */\n taxId: string;\n}\n\nexport interface BankTransferConfig {\n accounts: BankAccount[];\n /** E.164 sin '+', para el link wa.me. */\n whatsapp?: string;\n maxProofBytes?: number;\n allowedMimeTypes?: readonly string[];\n}\n\nexport const DEFAULT_MAX_PROOF_BYTES = 5 * 1024 * 1024;\nexport const DEFAULT_ALLOWED_MIME_TYPES: readonly string[] = [\n \"image/jpeg\",\n \"image/png\",\n \"image/webp\",\n \"application/pdf\",\n];\n","import type { Order } from \"@pandait.tech/commerce-core\";\nimport type { BankTransferConfig } from \"./config.js\";\nimport { DEFAULT_MAX_PROOF_BYTES, DEFAULT_ALLOWED_MIME_TYPES } from \"./config.js\";\n\nexport interface ProofInput {\n orderRef: string;\n url: string;\n mimeType: string;\n sizeBytes: number;\n /** Número de transacción/documento del banco. */\n referenceNumber: string;\n declaredAmount: number;\n depositedAt?: string;\n}\n\nexport type ProofRejection =\n | \"order-not-found\"\n | \"order-not-awaiting-proof\"\n | \"unsupported-file\"\n | \"amount-below-total\"\n | \"duplicate-reference\";\n\nexport type ProofCheck = { ok: true } | { ok: false; reason: ProofRejection; message: string };\n\nexport interface ProofCheckContext {\n order?: Order;\n referenceAlreadyUsed: boolean;\n config: BankTransferConfig;\n}\n\nconst money = (n: number) => `$${n.toFixed(2)}`;\nconst megabytes = (bytes: number) => `${(bytes / (1024 * 1024)).toFixed(1)}MB`;\n\nexport function validateProof(input: ProofInput, ctx: ProofCheckContext): ProofCheck {\n const { order, referenceAlreadyUsed, config } = ctx;\n\n if (!order) {\n return {\n ok: false,\n reason: \"order-not-found\",\n message: `No existe una orden con referencia \"${input.orderRef}\". Verifica el código de orden.`,\n };\n }\n\n if (order.status !== \"awaiting_payment_proof\") {\n return {\n ok: false,\n reason: \"order-not-awaiting-proof\",\n message: `La orden ${order.id} está en estado \"${order.status}\" y no admite un nuevo comprobante. Si ya pagaste, revisa el estado de tu pedido.`,\n };\n }\n\n const maxBytes = config.maxProofBytes ?? DEFAULT_MAX_PROOF_BYTES;\n const allowedTypes = config.allowedMimeTypes ?? DEFAULT_ALLOWED_MIME_TYPES;\n if (\n !allowedTypes.includes(input.mimeType) ||\n input.sizeBytes <= 0 ||\n input.sizeBytes > maxBytes\n ) {\n return {\n ok: false,\n reason: \"unsupported-file\",\n message: `El archivo debe ser ${allowedTypes.join(\", \")} y pesar hasta ${megabytes(maxBytes)}. Sube el comprobante en un formato válido.`,\n };\n }\n\n if (input.declaredAmount < order.total) {\n return {\n ok: false,\n reason: \"amount-below-total\",\n message: `El monto declarado (${money(input.declaredAmount)}) es menor al total de la orden (${money(order.total)}). Sube un comprobante por el total.`,\n };\n }\n\n if (referenceAlreadyUsed) {\n return {\n ok: false,\n reason: \"duplicate-reference\",\n message: `El número de referencia \"${input.referenceNumber}\" ya fue usado en otra orden. Cada comprobante debe tener una referencia única.`,\n };\n }\n\n return { ok: true };\n}\n","// Tipos estructuralmente compatibles con payment-core (sin importarlo — evita dep de workspace en runtime).\nimport type { Order } from \"@pandait.tech/commerce-core\";\nimport type { BankTransferConfig } from \"./config.js\";\nimport type { ProofCheck, ProofInput } from \"./proof.js\";\nimport { validateProof } from \"./proof.js\";\n\ninterface ConfirmResult {\n status: \"paid\" | \"pending\" | \"failed\" | \"challenge\";\n transactionId?: string;\n redirectUrl?: string;\n error?: string;\n}\n\n/**\n * Contrato de handleWebhook (§4 del brief): \"esta orden se pagó, finalízala\" solo cuando\n * handled === true. Un rechazo NO es un fallo silencioso: viaja como\n * { handled: false, orderId, decision: \"rejected\" }, no como { handled: false } a secas.\n */\ninterface WebhookResult {\n handled: boolean;\n orderId?: string;\n decision?: \"approved\" | \"rejected\";\n}\n\ninterface PaymentMethod {\n id: string;\n label: string;\n prepare(order: Order): Promise<Record<string, unknown>>;\n confirm(ref: string): Promise<ConfirmResult>;\n handleWebhook(payload: unknown): Promise<WebhookResult>;\n}\n\nexport interface BankTransferPaymentMethod extends PaymentMethod {\n /** Valida y registra el comprobante subido por el cliente para una orden. */\n submitProof(input: ProofInput): Promise<ProofCheck>;\n}\n\nexport interface BankTransferWebhookPayload {\n orderRef?: string;\n decision?: \"approved\" | \"rejected\";\n reviewerId?: string;\n reviewedAt?: string;\n}\n\nexport interface BankTransferPorts {\n findOrder(orderRef: string): Promise<Order | undefined>;\n isReferenceUsed(referenceNumber: string): Promise<boolean>;\n onProofAccepted(input: ProofInput, order: Order): Promise<void>;\n /** Decisión del admin para una orden; ausente/undefined mientras esté en revisión. */\n getDecision?(orderRef: string): Promise<\"approved\" | \"rejected\" | undefined>;\n}\n\nfunction buildWhatsappUrl(whatsapp: string, orderId: string): string {\n const text = encodeURIComponent(`Hola, envío el comprobante de transferencia de la orden ${orderId}.`);\n return `https://wa.me/${whatsapp}?text=${text}`;\n}\n\nexport function toBankTransferPaymentMethod(\n config: BankTransferConfig,\n ports: BankTransferPorts,\n): BankTransferPaymentMethod {\n return {\n id: \"bank-transfer\",\n label: \"Transferencia bancaria\",\n\n async prepare(order: Order): Promise<Record<string, unknown>> {\n return {\n accounts: config.accounts,\n reference: order.id,\n amount: order.total,\n status: \"awaiting_payment_proof\",\n ...(config.whatsapp ? { whatsappUrl: buildWhatsappUrl(config.whatsapp, order.id) } : {}),\n };\n },\n\n async submitProof(input: ProofInput): Promise<ProofCheck> {\n const [order, referenceAlreadyUsed] = await Promise.all([\n ports.findOrder(input.orderRef),\n ports.isReferenceUsed(input.referenceNumber),\n ]);\n\n const check = validateProof(input, { order, referenceAlreadyUsed, config });\n if (check.ok && order) {\n await ports.onProofAccepted(input, order);\n }\n return check;\n },\n\n async confirm(ref: string): Promise<ConfirmResult> {\n const decision = await ports.getDecision?.(ref);\n if (decision === \"approved\") return { status: \"paid\" };\n if (decision === \"rejected\") return { status: \"failed\", error: \"Comprobante rechazado\" };\n return { status: \"pending\" };\n },\n\n async handleWebhook(payload: unknown): Promise<WebhookResult> {\n const p = payload as BankTransferWebhookPayload | null;\n if (!p?.orderRef || (p.decision !== \"approved\" && p.decision !== \"rejected\")) {\n return { handled: false };\n }\n const { orderRef, decision } = p;\n\n if (decision === \"rejected\") {\n return { handled: false, orderId: orderRef, decision };\n }\n\n return { handled: true, orderId: orderRef, decision };\n },\n };\n}\n"]}
1
+ {"version":3,"sources":["../src/config.ts","../src/proof.ts","../src/adapter.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["export interface BankAccount {\n bank: string;\n holder: string;\n accountNumber: string;\n accountType: \"ahorros\" | \"corriente\";\n /** Cédula/RUC del titular. */\n taxId: string;\n}\n\ninterface ChannelBase {\n /** Único dentro de la configuración. Viaja en el comprobante. */\n id: string;\n /** Lo que ve el comprador. Ej. \"Banco Pichincha — ahorros\". */\n label: string;\n /** Default: true. Se apaga sin borrar el canal de la configuración. */\n enabled?: boolean;\n}\n\nexport type TransferChannel =\n | (ChannelBase & { kind: \"bank\"; account: BankAccount })\n | (ChannelBase & {\n kind: \"wallet-qr\";\n wallet: \"deuna\" | \"peigo\";\n // ponytail: QR estático sin monto — se cambia a QR dinámico cuando un comercio tenga contrato de API\n /** Imagen del QR ESTÁTICO del comercio. La sube el comercio; el kit no la genera. */\n qrImageUrl: string;\n /** Titular de la billetera, para que el comprador confirme a quién paga. */\n holder: string;\n /** Teléfono de la billetera, si el comercio también cobra por número. */\n phone?: string;\n });\n\nexport interface BankTransferConfig {\n channels: TransferChannel[];\n /** E.164 sin '+', para el link wa.me. */\n whatsapp?: string;\n maxProofBytes?: number;\n allowedMimeTypes?: readonly string[];\n}\n\nexport const DEFAULT_MAX_PROOF_BYTES = 5 * 1024 * 1024;\nexport const DEFAULT_ALLOWED_MIME_TYPES: readonly string[] = [\n \"image/jpeg\",\n \"image/png\",\n \"image/webp\",\n \"application/pdf\",\n];\n","import type { Order } from \"@pandait.tech/commerce-core\";\nimport type { BankTransferConfig, TransferChannel } from \"./config.js\";\nimport { DEFAULT_MAX_PROOF_BYTES, DEFAULT_ALLOWED_MIME_TYPES } from \"./config.js\";\n\nexport interface ProofInput {\n orderRef: string;\n /** `id` del canal (config.channels) por el que se pagó. */\n channelId: string;\n url: string;\n mimeType: string;\n sizeBytes: number;\n /** Número de transacción/documento del banco. */\n referenceNumber: string;\n declaredAmount: number;\n depositedAt?: string;\n}\n\nexport type ProofRejection =\n | \"order-not-found\"\n | \"order-not-awaiting-proof\"\n | \"unknown-channel\"\n | \"unsupported-file\"\n | \"amount-below-total\"\n | \"duplicate-reference\";\n\nexport type ProofCheck = { ok: true } | { ok: false; reason: ProofRejection; message: string };\n\nexport interface ProofCheckContext {\n order?: Order;\n referenceAlreadyUsed: boolean;\n config: BankTransferConfig;\n /** Canales habilitados (`enabled !== false`) contra los que se valida `channelId`. */\n channels: TransferChannel[];\n}\n\nconst money = (n: number) => `$${n.toFixed(2)}`;\nconst megabytes = (bytes: number) => `${(bytes / (1024 * 1024)).toFixed(1)}MB`;\n\nexport function validateProof(input: ProofInput, ctx: ProofCheckContext): ProofCheck {\n const { order, referenceAlreadyUsed, config, channels } = ctx;\n\n if (!order) {\n return {\n ok: false,\n reason: \"order-not-found\",\n message: `No existe una orden con referencia \"${input.orderRef}\". Verifica el código de orden.`,\n };\n }\n\n if (order.status !== \"awaiting_payment_proof\") {\n return {\n ok: false,\n reason: \"order-not-awaiting-proof\",\n message: `La orden ${order.id} está en estado \"${order.status}\" y no admite un nuevo comprobante. Si ya pagaste, revisa el estado de tu pedido.`,\n };\n }\n\n if (!channels.some((channel) => channel.id === input.channelId)) {\n const enabledIds = channels.map((channel) => channel.id).join(\", \") || \"ninguno\";\n return {\n ok: false,\n reason: \"unknown-channel\",\n message: `El canal de cobro \"${input.channelId}\" no existe o está deshabilitado. Canales habilitados: ${enabledIds}.`,\n };\n }\n\n const maxBytes = config.maxProofBytes ?? DEFAULT_MAX_PROOF_BYTES;\n const allowedTypes = config.allowedMimeTypes ?? DEFAULT_ALLOWED_MIME_TYPES;\n if (\n !allowedTypes.includes(input.mimeType) ||\n input.sizeBytes <= 0 ||\n input.sizeBytes > maxBytes\n ) {\n return {\n ok: false,\n reason: \"unsupported-file\",\n message: `El archivo debe ser ${allowedTypes.join(\", \")} y pesar hasta ${megabytes(maxBytes)}. Sube el comprobante en un formato válido.`,\n };\n }\n\n if (input.declaredAmount < order.total) {\n return {\n ok: false,\n reason: \"amount-below-total\",\n message: `El monto declarado (${money(input.declaredAmount)}) es menor al total de la orden (${money(order.total)}). Sube un comprobante por el total.`,\n };\n }\n\n if (referenceAlreadyUsed) {\n return {\n ok: false,\n reason: \"duplicate-reference\",\n message: `El número de referencia \"${input.referenceNumber}\" ya fue usado en otra orden. Cada comprobante debe tener una referencia única.`,\n };\n }\n\n return { ok: true };\n}\n","// Tipos estructuralmente compatibles con payment-core (sin importarlo — evita dep de workspace en runtime).\nimport type { Order } from \"@pandait.tech/commerce-core\";\nimport type { BankTransferConfig, TransferChannel } from \"./config.js\";\nimport type { ProofCheck, ProofInput } from \"./proof.js\";\nimport { validateProof } from \"./proof.js\";\n\nfunction enabledChannels(config: BankTransferConfig): TransferChannel[] {\n return config.channels.filter((channel) => channel.enabled !== false);\n}\n\nfunction assertNoDuplicateChannelIds(channels: TransferChannel[]): void {\n const seen = new Set<string>();\n for (const channel of channels) {\n if (seen.has(channel.id)) {\n throw new Error(\n `Configuración de payment-bank-transfer inválida: el id de canal \"${channel.id}\" está repetido en channels.`,\n );\n }\n seen.add(channel.id);\n }\n}\n\ninterface ConfirmResult {\n status: \"paid\" | \"pending\" | \"failed\" | \"challenge\";\n transactionId?: string;\n redirectUrl?: string;\n error?: string;\n}\n\n/**\n * Contrato de handleWebhook (§4 del brief): \"esta orden se pagó, finalízala\" solo cuando\n * handled === true. Un rechazo NO es un fallo silencioso: viaja como\n * { handled: false, orderId, decision: \"rejected\" }, no como { handled: false } a secas.\n */\ninterface WebhookResult {\n handled: boolean;\n orderId?: string;\n decision?: \"approved\" | \"rejected\";\n}\n\ninterface PaymentMethod {\n id: string;\n label: string;\n prepare(order: Order): Promise<Record<string, unknown>>;\n confirm(ref: string): Promise<ConfirmResult>;\n handleWebhook(payload: unknown): Promise<WebhookResult>;\n}\n\nexport interface BankTransferPaymentMethod extends PaymentMethod {\n /** Valida y registra el comprobante subido por el cliente para una orden. */\n submitProof(input: ProofInput): Promise<ProofCheck>;\n}\n\nexport interface BankTransferWebhookPayload {\n orderRef?: string;\n decision?: \"approved\" | \"rejected\";\n reviewerId?: string;\n reviewedAt?: string;\n}\n\nexport interface BankTransferPorts {\n findOrder(orderRef: string): Promise<Order | undefined>;\n isReferenceUsed(referenceNumber: string): Promise<boolean>;\n onProofAccepted(input: ProofInput, order: Order): Promise<void>;\n /** Decisión del admin para una orden; ausente/undefined mientras esté en revisión. */\n getDecision?(orderRef: string): Promise<\"approved\" | \"rejected\" | undefined>;\n}\n\nfunction buildWhatsappUrl(whatsapp: string, orderId: string): string {\n const text = encodeURIComponent(`Hola, envío el comprobante de transferencia de la orden ${orderId}.`);\n return `https://wa.me/${whatsapp}?text=${text}`;\n}\n\nexport function toBankTransferPaymentMethod(\n config: BankTransferConfig,\n ports: BankTransferPorts,\n): BankTransferPaymentMethod {\n assertNoDuplicateChannelIds(config.channels);\n\n return {\n id: \"bank-transfer\",\n label: \"Transferencia bancaria\",\n\n async prepare(order: Order): Promise<Record<string, unknown>> {\n const channels = enabledChannels(config);\n if (channels.length === 0) {\n throw new Error(\n \"No hay ningún canal de cobro habilitado en la configuración (channels): revisa que al menos uno tenga enabled !== false.\",\n );\n }\n return {\n channels,\n reference: order.id,\n amount: order.total,\n status: \"awaiting_payment_proof\",\n ...(config.whatsapp ? { whatsappUrl: buildWhatsappUrl(config.whatsapp, order.id) } : {}),\n };\n },\n\n async submitProof(input: ProofInput): Promise<ProofCheck> {\n const [order, referenceAlreadyUsed] = await Promise.all([\n ports.findOrder(input.orderRef),\n ports.isReferenceUsed(input.referenceNumber),\n ]);\n\n const check = validateProof(input, {\n order,\n referenceAlreadyUsed,\n config,\n channels: enabledChannels(config),\n });\n if (check.ok && order) {\n await ports.onProofAccepted(input, order);\n }\n return check;\n },\n\n async confirm(ref: string): Promise<ConfirmResult> {\n const decision = await ports.getDecision?.(ref);\n if (decision === \"approved\") return { status: \"paid\" };\n if (decision === \"rejected\") return { status: \"failed\", error: \"Comprobante rechazado\" };\n return { status: \"pending\" };\n },\n\n async handleWebhook(payload: unknown): Promise<WebhookResult> {\n const p = payload as BankTransferWebhookPayload | null;\n if (!p?.orderRef || (p.decision !== \"approved\" && p.decision !== \"rejected\")) {\n return { handled: false };\n }\n const { orderRef, decision } = p;\n\n if (decision === \"rejected\") {\n return { handled: false, orderId: orderRef, decision };\n }\n\n return { handled: true, orderId: orderRef, decision };\n },\n };\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pandait.tech/payment-bank-transfer",
3
- "version": "1.0.0",
3
+ "version": "2.0.0",
4
4
  "description": "Transferencia bancaria con comprobante para panda-commerce-kit. Implementa PaymentMethod sin red: datos de cuentas + validación de comprobante + aprobación manual del admin.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -45,7 +45,7 @@
45
45
  "typescript": "^5.6.3",
46
46
  "vitest": "^4.1.7",
47
47
  "@pandait.tech/commerce-core": "0.1.0",
48
- "@pandait.tech/payment-core": "0.0.1"
48
+ "@pandait.tech/payment-core": "0.0.3"
49
49
  },
50
50
  "scripts": {
51
51
  "build": "tsup",