@pandait.tech/payment-bank-transfer 1.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 +81 -0
- package/dist/index.cjs +111 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +156 -0
- package/dist/index.d.ts +156 -0
- package/dist/index.js +106 -0
- package/dist/index.js.map +1 -0
- package/package.json +59 -0
package/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# @pandait.tech/payment-bank-transfer
|
|
2
|
+
|
|
3
|
+
Adapter de transferencia bancaria con comprobante para `panda-commerce-kit`. Implementa
|
|
4
|
+
`PaymentMethod` sin red: no llama a ninguna API externa, no recibe webhooks de un banco, no sube ni
|
|
5
|
+
almacena el archivo del comprobante (solo su metadata).
|
|
6
|
+
|
|
7
|
+
## Flujo
|
|
8
|
+
|
|
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`.
|
|
12
|
+
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`.
|
|
17
|
+
3. Un admin revisa el comprobante y aprueba o rechaza. Esa decisión llega como
|
|
18
|
+
**`handleWebhook({ orderRef, decision })`** — aquí "el webhook" es la decisión del admin, no un
|
|
19
|
+
banco.
|
|
20
|
+
4. **`confirm(ref)`** consulta `ports.getDecision` para el estado de pago: sin decisión → `pending`,
|
|
21
|
+
nunca `paid` sin revisión explícita.
|
|
22
|
+
|
|
23
|
+
## Contrato de `handleWebhook`
|
|
24
|
+
|
|
25
|
+
En todos los adapters del kit, `{ handled: true, orderId }` significa **"esta orden se pagó,
|
|
26
|
+
finalízala"**. Un rechazo no puede devolver eso, pero tampoco puede volverse indistinguible de un
|
|
27
|
+
payload corrupto devolviendo `{ handled: false }` a secas. Contrato exacto:
|
|
28
|
+
|
|
29
|
+
| Payload | Resultado |
|
|
30
|
+
|---|---|
|
|
31
|
+
| `{ orderRef, decision: "approved" }` | `{ handled: true, orderId, decision: "approved" }` |
|
|
32
|
+
| `{ orderRef, decision: "rejected" }` | `{ handled: false, orderId, decision: "rejected" }` ← rechazo **nombrado**, no silencioso |
|
|
33
|
+
| `null` / sin `orderRef` / `decision` desconocida | `{ handled: false }` |
|
|
34
|
+
|
|
35
|
+
`handleWebhook` **no es idempotente y no pretende serlo**: la misma decisión aprobada dos veces
|
|
36
|
+
devuelve `{handled:true, ...}` las dos veces. La deduplicación es responsabilidad del **finalizador**,
|
|
37
|
+
que debe descartar transiciones ya aplicadas contra el estado durable de la orden antes de cobrar,
|
|
38
|
+
enviar el correo o disparar fulfillment (referencia viva en el repo: `payment-nuvei/src/handlers/webhook.ts`).
|
|
39
|
+
|
|
40
|
+
## `validateProof` — los 5 rechazos
|
|
41
|
+
|
|
42
|
+
Función pura y síncrona (las búsquedas las hace el llamador vía `ports`, así la puerta se prueba
|
|
43
|
+
sin mocks async):
|
|
44
|
+
|
|
45
|
+
| `reason` | Cuándo |
|
|
46
|
+
|---|---|
|
|
47
|
+
| `order-not-found` | La orden no existe |
|
|
48
|
+
| `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 |
|
|
49
|
+
| `unsupported-file` | `mimeType` fuera del allowlist, o `sizeBytes` fuera de rango |
|
|
50
|
+
| `amount-below-total` | `declaredAmount < order.total` (pagar de más se acepta) |
|
|
51
|
+
| `duplicate-reference` | El mismo número de referencia ya se usó en otra orden — el fraude real de este método |
|
|
52
|
+
|
|
53
|
+
Cada rechazo trae un `message` accionable en español.
|
|
54
|
+
|
|
55
|
+
## Uso
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { toBankTransferPaymentMethod } from "@pandait.tech/payment-bank-transfer";
|
|
59
|
+
|
|
60
|
+
const bankTransfer = toBankTransferPaymentMethod(
|
|
61
|
+
{
|
|
62
|
+
accounts: [
|
|
63
|
+
{ bank: "Banco Pichincha", holder: "Mi Tienda SAS", accountNumber: "123", accountType: "corriente", taxId: "099..." },
|
|
64
|
+
],
|
|
65
|
+
whatsapp: "593999999999",
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
findOrder: (orderRef) => db.orders.findById(orderRef),
|
|
69
|
+
isReferenceUsed: (ref) => db.proofs.existsByReference(ref),
|
|
70
|
+
onProofAccepted: (input, order) => db.orders.update(order.id, { status: "awaiting_payment_proof", proof: input }),
|
|
71
|
+
getDecision: (orderRef) => db.proofs.getDecision(orderRef),
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Qué NO hace este package
|
|
77
|
+
|
|
78
|
+
- No sube ni almacena el archivo del comprobante (solo su metadata).
|
|
79
|
+
- No tiene componentes React ni plantillas de correo — eso vive en la capa de UI/`email-kit`.
|
|
80
|
+
- No genera QR ni parsea PDFs.
|
|
81
|
+
- Sin dependencias de runtime.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/config.ts
|
|
4
|
+
var DEFAULT_MAX_PROOF_BYTES = 5 * 1024 * 1024;
|
|
5
|
+
var DEFAULT_ALLOWED_MIME_TYPES = [
|
|
6
|
+
"image/jpeg",
|
|
7
|
+
"image/png",
|
|
8
|
+
"image/webp",
|
|
9
|
+
"application/pdf"
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
// src/proof.ts
|
|
13
|
+
var money = (n) => `$${n.toFixed(2)}`;
|
|
14
|
+
var megabytes = (bytes) => `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
15
|
+
function validateProof(input, ctx) {
|
|
16
|
+
const { order, referenceAlreadyUsed, config } = ctx;
|
|
17
|
+
if (!order) {
|
|
18
|
+
return {
|
|
19
|
+
ok: false,
|
|
20
|
+
reason: "order-not-found",
|
|
21
|
+
message: `No existe una orden con referencia "${input.orderRef}". Verifica el c\xF3digo de orden.`
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
if (order.status !== "awaiting_payment_proof") {
|
|
25
|
+
return {
|
|
26
|
+
ok: false,
|
|
27
|
+
reason: "order-not-awaiting-proof",
|
|
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
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const maxBytes = config.maxProofBytes ?? DEFAULT_MAX_PROOF_BYTES;
|
|
32
|
+
const allowedTypes = config.allowedMimeTypes ?? DEFAULT_ALLOWED_MIME_TYPES;
|
|
33
|
+
if (!allowedTypes.includes(input.mimeType) || input.sizeBytes <= 0 || input.sizeBytes > maxBytes) {
|
|
34
|
+
return {
|
|
35
|
+
ok: false,
|
|
36
|
+
reason: "unsupported-file",
|
|
37
|
+
message: `El archivo debe ser ${allowedTypes.join(", ")} y pesar hasta ${megabytes(maxBytes)}. Sube el comprobante en un formato v\xE1lido.`
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
if (input.declaredAmount < order.total) {
|
|
41
|
+
return {
|
|
42
|
+
ok: false,
|
|
43
|
+
reason: "amount-below-total",
|
|
44
|
+
message: `El monto declarado (${money(input.declaredAmount)}) es menor al total de la orden (${money(order.total)}). Sube un comprobante por el total.`
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
if (referenceAlreadyUsed) {
|
|
48
|
+
return {
|
|
49
|
+
ok: false,
|
|
50
|
+
reason: "duplicate-reference",
|
|
51
|
+
message: `El n\xFAmero de referencia "${input.referenceNumber}" ya fue usado en otra orden. Cada comprobante debe tener una referencia \xFAnica.`
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return { ok: true };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// src/adapter.ts
|
|
58
|
+
function buildWhatsappUrl(whatsapp, orderId) {
|
|
59
|
+
const text = encodeURIComponent(`Hola, env\xEDo el comprobante de transferencia de la orden ${orderId}.`);
|
|
60
|
+
return `https://wa.me/${whatsapp}?text=${text}`;
|
|
61
|
+
}
|
|
62
|
+
function toBankTransferPaymentMethod(config, ports) {
|
|
63
|
+
return {
|
|
64
|
+
id: "bank-transfer",
|
|
65
|
+
label: "Transferencia bancaria",
|
|
66
|
+
async prepare(order) {
|
|
67
|
+
return {
|
|
68
|
+
accounts: config.accounts,
|
|
69
|
+
reference: order.id,
|
|
70
|
+
amount: order.total,
|
|
71
|
+
status: "awaiting_payment_proof",
|
|
72
|
+
...config.whatsapp ? { whatsappUrl: buildWhatsappUrl(config.whatsapp, order.id) } : {}
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
async submitProof(input) {
|
|
76
|
+
const [order, referenceAlreadyUsed] = await Promise.all([
|
|
77
|
+
ports.findOrder(input.orderRef),
|
|
78
|
+
ports.isReferenceUsed(input.referenceNumber)
|
|
79
|
+
]);
|
|
80
|
+
const check = validateProof(input, { order, referenceAlreadyUsed, config });
|
|
81
|
+
if (check.ok && order) {
|
|
82
|
+
await ports.onProofAccepted(input, order);
|
|
83
|
+
}
|
|
84
|
+
return check;
|
|
85
|
+
},
|
|
86
|
+
async confirm(ref) {
|
|
87
|
+
const decision = await ports.getDecision?.(ref);
|
|
88
|
+
if (decision === "approved") return { status: "paid" };
|
|
89
|
+
if (decision === "rejected") return { status: "failed", error: "Comprobante rechazado" };
|
|
90
|
+
return { status: "pending" };
|
|
91
|
+
},
|
|
92
|
+
async handleWebhook(payload) {
|
|
93
|
+
const p = payload;
|
|
94
|
+
if (!p?.orderRef || p.decision !== "approved" && p.decision !== "rejected") {
|
|
95
|
+
return { handled: false };
|
|
96
|
+
}
|
|
97
|
+
const { orderRef, decision } = p;
|
|
98
|
+
if (decision === "rejected") {
|
|
99
|
+
return { handled: false, orderId: orderRef, decision };
|
|
100
|
+
}
|
|
101
|
+
return { handled: true, orderId: orderRef, decision };
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
exports.DEFAULT_ALLOWED_MIME_TYPES = DEFAULT_ALLOWED_MIME_TYPES;
|
|
107
|
+
exports.DEFAULT_MAX_PROOF_BYTES = DEFAULT_MAX_PROOF_BYTES;
|
|
108
|
+
exports.toBankTransferPaymentMethod = toBankTransferPaymentMethod;
|
|
109
|
+
exports.validateProof = validateProof;
|
|
110
|
+
//# sourceMappingURL=index.cjs.map
|
|
111
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +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"]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
interface BankAccount {
|
|
2
|
+
bank: string;
|
|
3
|
+
holder: string;
|
|
4
|
+
accountNumber: string;
|
|
5
|
+
accountType: "ahorros" | "corriente";
|
|
6
|
+
/** Cédula/RUC del titular. */
|
|
7
|
+
taxId: string;
|
|
8
|
+
}
|
|
9
|
+
interface BankTransferConfig {
|
|
10
|
+
accounts: BankAccount[];
|
|
11
|
+
/** E.164 sin '+', para el link wa.me. */
|
|
12
|
+
whatsapp?: string;
|
|
13
|
+
maxProofBytes?: number;
|
|
14
|
+
allowedMimeTypes?: readonly string[];
|
|
15
|
+
}
|
|
16
|
+
declare const DEFAULT_MAX_PROOF_BYTES: number;
|
|
17
|
+
declare const DEFAULT_ALLOWED_MIME_TYPES: readonly string[];
|
|
18
|
+
|
|
19
|
+
interface FirestoreTimestamp {
|
|
20
|
+
toMillis(): number;
|
|
21
|
+
toDate(): Date;
|
|
22
|
+
seconds: number;
|
|
23
|
+
nanoseconds: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Shipping address captured at checkout and stored on the order. */
|
|
27
|
+
interface ShippingAddress {
|
|
28
|
+
fullName: string;
|
|
29
|
+
phone: string;
|
|
30
|
+
address: string;
|
|
31
|
+
city: string;
|
|
32
|
+
province: string;
|
|
33
|
+
postalCode: string;
|
|
34
|
+
country: string;
|
|
35
|
+
}
|
|
36
|
+
/** Buyer details captured for guest (unauthenticated) checkout. */
|
|
37
|
+
interface GuestInfo {
|
|
38
|
+
firstName: string;
|
|
39
|
+
lastName: string;
|
|
40
|
+
idNumber: string;
|
|
41
|
+
email: string;
|
|
42
|
+
phone: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type PromotionType = "percentage" | "fixed_amount" | "free_shipping";
|
|
46
|
+
|
|
47
|
+
type OrderStatus = "pending" | "processing"
|
|
48
|
+
/** Bank-transfer orders awaiting the customer's payment proof. */
|
|
49
|
+
| "awaiting_payment_proof" | "paid" | "shipped" | "delivered" | "cancelled" | "failed" | "refunded"
|
|
50
|
+
/** Intermediate Nuvei 3DS / OTP states (see @pandait.tech/payment-nuvei). */
|
|
51
|
+
| "3ds-pending" | "otp-pending";
|
|
52
|
+
interface OrderItem {
|
|
53
|
+
productId: string;
|
|
54
|
+
name: string;
|
|
55
|
+
price: number;
|
|
56
|
+
quantity: number;
|
|
57
|
+
/** Set when the purchased unit is a specific variant. */
|
|
58
|
+
variantSku?: string;
|
|
59
|
+
variantName?: string;
|
|
60
|
+
image?: string;
|
|
61
|
+
}
|
|
62
|
+
interface Order {
|
|
63
|
+
id: string;
|
|
64
|
+
userId: string;
|
|
65
|
+
items: OrderItem[];
|
|
66
|
+
subtotal: number;
|
|
67
|
+
vat: number;
|
|
68
|
+
shipping: number;
|
|
69
|
+
total: number;
|
|
70
|
+
status: OrderStatus;
|
|
71
|
+
/** Eligible payment method id, e.g. "nuvei" | "bank-transfer". */
|
|
72
|
+
paymentMethod?: string;
|
|
73
|
+
paymentToken?: string;
|
|
74
|
+
paymentTransactionId?: string;
|
|
75
|
+
authorizationCode?: string;
|
|
76
|
+
cardBrand?: string;
|
|
77
|
+
cardLast4?: string;
|
|
78
|
+
shippingAddress: ShippingAddress;
|
|
79
|
+
discount?: number;
|
|
80
|
+
couponCode?: string;
|
|
81
|
+
promotionId?: string;
|
|
82
|
+
discountType?: PromotionType;
|
|
83
|
+
/** Present for guest (unauthenticated) checkout. */
|
|
84
|
+
guestInfo?: GuestInfo;
|
|
85
|
+
createdAt: FirestoreTimestamp;
|
|
86
|
+
updatedAt: FirestoreTimestamp;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface ProofInput {
|
|
90
|
+
orderRef: string;
|
|
91
|
+
url: string;
|
|
92
|
+
mimeType: string;
|
|
93
|
+
sizeBytes: number;
|
|
94
|
+
/** Número de transacción/documento del banco. */
|
|
95
|
+
referenceNumber: string;
|
|
96
|
+
declaredAmount: number;
|
|
97
|
+
depositedAt?: string;
|
|
98
|
+
}
|
|
99
|
+
type ProofRejection = "order-not-found" | "order-not-awaiting-proof" | "unsupported-file" | "amount-below-total" | "duplicate-reference";
|
|
100
|
+
type ProofCheck = {
|
|
101
|
+
ok: true;
|
|
102
|
+
} | {
|
|
103
|
+
ok: false;
|
|
104
|
+
reason: ProofRejection;
|
|
105
|
+
message: string;
|
|
106
|
+
};
|
|
107
|
+
interface ProofCheckContext {
|
|
108
|
+
order?: Order;
|
|
109
|
+
referenceAlreadyUsed: boolean;
|
|
110
|
+
config: BankTransferConfig;
|
|
111
|
+
}
|
|
112
|
+
declare function validateProof(input: ProofInput, ctx: ProofCheckContext): ProofCheck;
|
|
113
|
+
|
|
114
|
+
interface ConfirmResult {
|
|
115
|
+
status: "paid" | "pending" | "failed" | "challenge";
|
|
116
|
+
transactionId?: string;
|
|
117
|
+
redirectUrl?: string;
|
|
118
|
+
error?: string;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Contrato de handleWebhook (§4 del brief): "esta orden se pagó, finalízala" solo cuando
|
|
122
|
+
* handled === true. Un rechazo NO es un fallo silencioso: viaja como
|
|
123
|
+
* { handled: false, orderId, decision: "rejected" }, no como { handled: false } a secas.
|
|
124
|
+
*/
|
|
125
|
+
interface WebhookResult {
|
|
126
|
+
handled: boolean;
|
|
127
|
+
orderId?: string;
|
|
128
|
+
decision?: "approved" | "rejected";
|
|
129
|
+
}
|
|
130
|
+
interface PaymentMethod {
|
|
131
|
+
id: string;
|
|
132
|
+
label: string;
|
|
133
|
+
prepare(order: Order): Promise<Record<string, unknown>>;
|
|
134
|
+
confirm(ref: string): Promise<ConfirmResult>;
|
|
135
|
+
handleWebhook(payload: unknown): Promise<WebhookResult>;
|
|
136
|
+
}
|
|
137
|
+
interface BankTransferPaymentMethod extends PaymentMethod {
|
|
138
|
+
/** Valida y registra el comprobante subido por el cliente para una orden. */
|
|
139
|
+
submitProof(input: ProofInput): Promise<ProofCheck>;
|
|
140
|
+
}
|
|
141
|
+
interface BankTransferWebhookPayload {
|
|
142
|
+
orderRef?: string;
|
|
143
|
+
decision?: "approved" | "rejected";
|
|
144
|
+
reviewerId?: string;
|
|
145
|
+
reviewedAt?: string;
|
|
146
|
+
}
|
|
147
|
+
interface BankTransferPorts {
|
|
148
|
+
findOrder(orderRef: string): Promise<Order | undefined>;
|
|
149
|
+
isReferenceUsed(referenceNumber: string): Promise<boolean>;
|
|
150
|
+
onProofAccepted(input: ProofInput, order: Order): Promise<void>;
|
|
151
|
+
/** Decisión del admin para una orden; ausente/undefined mientras esté en revisión. */
|
|
152
|
+
getDecision?(orderRef: string): Promise<"approved" | "rejected" | undefined>;
|
|
153
|
+
}
|
|
154
|
+
declare function toBankTransferPaymentMethod(config: BankTransferConfig, ports: BankTransferPorts): BankTransferPaymentMethod;
|
|
155
|
+
|
|
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 };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
interface BankAccount {
|
|
2
|
+
bank: string;
|
|
3
|
+
holder: string;
|
|
4
|
+
accountNumber: string;
|
|
5
|
+
accountType: "ahorros" | "corriente";
|
|
6
|
+
/** Cédula/RUC del titular. */
|
|
7
|
+
taxId: string;
|
|
8
|
+
}
|
|
9
|
+
interface BankTransferConfig {
|
|
10
|
+
accounts: BankAccount[];
|
|
11
|
+
/** E.164 sin '+', para el link wa.me. */
|
|
12
|
+
whatsapp?: string;
|
|
13
|
+
maxProofBytes?: number;
|
|
14
|
+
allowedMimeTypes?: readonly string[];
|
|
15
|
+
}
|
|
16
|
+
declare const DEFAULT_MAX_PROOF_BYTES: number;
|
|
17
|
+
declare const DEFAULT_ALLOWED_MIME_TYPES: readonly string[];
|
|
18
|
+
|
|
19
|
+
interface FirestoreTimestamp {
|
|
20
|
+
toMillis(): number;
|
|
21
|
+
toDate(): Date;
|
|
22
|
+
seconds: number;
|
|
23
|
+
nanoseconds: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Shipping address captured at checkout and stored on the order. */
|
|
27
|
+
interface ShippingAddress {
|
|
28
|
+
fullName: string;
|
|
29
|
+
phone: string;
|
|
30
|
+
address: string;
|
|
31
|
+
city: string;
|
|
32
|
+
province: string;
|
|
33
|
+
postalCode: string;
|
|
34
|
+
country: string;
|
|
35
|
+
}
|
|
36
|
+
/** Buyer details captured for guest (unauthenticated) checkout. */
|
|
37
|
+
interface GuestInfo {
|
|
38
|
+
firstName: string;
|
|
39
|
+
lastName: string;
|
|
40
|
+
idNumber: string;
|
|
41
|
+
email: string;
|
|
42
|
+
phone: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type PromotionType = "percentage" | "fixed_amount" | "free_shipping";
|
|
46
|
+
|
|
47
|
+
type OrderStatus = "pending" | "processing"
|
|
48
|
+
/** Bank-transfer orders awaiting the customer's payment proof. */
|
|
49
|
+
| "awaiting_payment_proof" | "paid" | "shipped" | "delivered" | "cancelled" | "failed" | "refunded"
|
|
50
|
+
/** Intermediate Nuvei 3DS / OTP states (see @pandait.tech/payment-nuvei). */
|
|
51
|
+
| "3ds-pending" | "otp-pending";
|
|
52
|
+
interface OrderItem {
|
|
53
|
+
productId: string;
|
|
54
|
+
name: string;
|
|
55
|
+
price: number;
|
|
56
|
+
quantity: number;
|
|
57
|
+
/** Set when the purchased unit is a specific variant. */
|
|
58
|
+
variantSku?: string;
|
|
59
|
+
variantName?: string;
|
|
60
|
+
image?: string;
|
|
61
|
+
}
|
|
62
|
+
interface Order {
|
|
63
|
+
id: string;
|
|
64
|
+
userId: string;
|
|
65
|
+
items: OrderItem[];
|
|
66
|
+
subtotal: number;
|
|
67
|
+
vat: number;
|
|
68
|
+
shipping: number;
|
|
69
|
+
total: number;
|
|
70
|
+
status: OrderStatus;
|
|
71
|
+
/** Eligible payment method id, e.g. "nuvei" | "bank-transfer". */
|
|
72
|
+
paymentMethod?: string;
|
|
73
|
+
paymentToken?: string;
|
|
74
|
+
paymentTransactionId?: string;
|
|
75
|
+
authorizationCode?: string;
|
|
76
|
+
cardBrand?: string;
|
|
77
|
+
cardLast4?: string;
|
|
78
|
+
shippingAddress: ShippingAddress;
|
|
79
|
+
discount?: number;
|
|
80
|
+
couponCode?: string;
|
|
81
|
+
promotionId?: string;
|
|
82
|
+
discountType?: PromotionType;
|
|
83
|
+
/** Present for guest (unauthenticated) checkout. */
|
|
84
|
+
guestInfo?: GuestInfo;
|
|
85
|
+
createdAt: FirestoreTimestamp;
|
|
86
|
+
updatedAt: FirestoreTimestamp;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface ProofInput {
|
|
90
|
+
orderRef: string;
|
|
91
|
+
url: string;
|
|
92
|
+
mimeType: string;
|
|
93
|
+
sizeBytes: number;
|
|
94
|
+
/** Número de transacción/documento del banco. */
|
|
95
|
+
referenceNumber: string;
|
|
96
|
+
declaredAmount: number;
|
|
97
|
+
depositedAt?: string;
|
|
98
|
+
}
|
|
99
|
+
type ProofRejection = "order-not-found" | "order-not-awaiting-proof" | "unsupported-file" | "amount-below-total" | "duplicate-reference";
|
|
100
|
+
type ProofCheck = {
|
|
101
|
+
ok: true;
|
|
102
|
+
} | {
|
|
103
|
+
ok: false;
|
|
104
|
+
reason: ProofRejection;
|
|
105
|
+
message: string;
|
|
106
|
+
};
|
|
107
|
+
interface ProofCheckContext {
|
|
108
|
+
order?: Order;
|
|
109
|
+
referenceAlreadyUsed: boolean;
|
|
110
|
+
config: BankTransferConfig;
|
|
111
|
+
}
|
|
112
|
+
declare function validateProof(input: ProofInput, ctx: ProofCheckContext): ProofCheck;
|
|
113
|
+
|
|
114
|
+
interface ConfirmResult {
|
|
115
|
+
status: "paid" | "pending" | "failed" | "challenge";
|
|
116
|
+
transactionId?: string;
|
|
117
|
+
redirectUrl?: string;
|
|
118
|
+
error?: string;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Contrato de handleWebhook (§4 del brief): "esta orden se pagó, finalízala" solo cuando
|
|
122
|
+
* handled === true. Un rechazo NO es un fallo silencioso: viaja como
|
|
123
|
+
* { handled: false, orderId, decision: "rejected" }, no como { handled: false } a secas.
|
|
124
|
+
*/
|
|
125
|
+
interface WebhookResult {
|
|
126
|
+
handled: boolean;
|
|
127
|
+
orderId?: string;
|
|
128
|
+
decision?: "approved" | "rejected";
|
|
129
|
+
}
|
|
130
|
+
interface PaymentMethod {
|
|
131
|
+
id: string;
|
|
132
|
+
label: string;
|
|
133
|
+
prepare(order: Order): Promise<Record<string, unknown>>;
|
|
134
|
+
confirm(ref: string): Promise<ConfirmResult>;
|
|
135
|
+
handleWebhook(payload: unknown): Promise<WebhookResult>;
|
|
136
|
+
}
|
|
137
|
+
interface BankTransferPaymentMethod extends PaymentMethod {
|
|
138
|
+
/** Valida y registra el comprobante subido por el cliente para una orden. */
|
|
139
|
+
submitProof(input: ProofInput): Promise<ProofCheck>;
|
|
140
|
+
}
|
|
141
|
+
interface BankTransferWebhookPayload {
|
|
142
|
+
orderRef?: string;
|
|
143
|
+
decision?: "approved" | "rejected";
|
|
144
|
+
reviewerId?: string;
|
|
145
|
+
reviewedAt?: string;
|
|
146
|
+
}
|
|
147
|
+
interface BankTransferPorts {
|
|
148
|
+
findOrder(orderRef: string): Promise<Order | undefined>;
|
|
149
|
+
isReferenceUsed(referenceNumber: string): Promise<boolean>;
|
|
150
|
+
onProofAccepted(input: ProofInput, order: Order): Promise<void>;
|
|
151
|
+
/** Decisión del admin para una orden; ausente/undefined mientras esté en revisión. */
|
|
152
|
+
getDecision?(orderRef: string): Promise<"approved" | "rejected" | undefined>;
|
|
153
|
+
}
|
|
154
|
+
declare function toBankTransferPaymentMethod(config: BankTransferConfig, ports: BankTransferPorts): BankTransferPaymentMethod;
|
|
155
|
+
|
|
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 };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// src/config.ts
|
|
2
|
+
var DEFAULT_MAX_PROOF_BYTES = 5 * 1024 * 1024;
|
|
3
|
+
var DEFAULT_ALLOWED_MIME_TYPES = [
|
|
4
|
+
"image/jpeg",
|
|
5
|
+
"image/png",
|
|
6
|
+
"image/webp",
|
|
7
|
+
"application/pdf"
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
// src/proof.ts
|
|
11
|
+
var money = (n) => `$${n.toFixed(2)}`;
|
|
12
|
+
var megabytes = (bytes) => `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
13
|
+
function validateProof(input, ctx) {
|
|
14
|
+
const { order, referenceAlreadyUsed, config } = ctx;
|
|
15
|
+
if (!order) {
|
|
16
|
+
return {
|
|
17
|
+
ok: false,
|
|
18
|
+
reason: "order-not-found",
|
|
19
|
+
message: `No existe una orden con referencia "${input.orderRef}". Verifica el c\xF3digo de orden.`
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
if (order.status !== "awaiting_payment_proof") {
|
|
23
|
+
return {
|
|
24
|
+
ok: false,
|
|
25
|
+
reason: "order-not-awaiting-proof",
|
|
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
|
+
};
|
|
28
|
+
}
|
|
29
|
+
const maxBytes = config.maxProofBytes ?? DEFAULT_MAX_PROOF_BYTES;
|
|
30
|
+
const allowedTypes = config.allowedMimeTypes ?? DEFAULT_ALLOWED_MIME_TYPES;
|
|
31
|
+
if (!allowedTypes.includes(input.mimeType) || input.sizeBytes <= 0 || input.sizeBytes > maxBytes) {
|
|
32
|
+
return {
|
|
33
|
+
ok: false,
|
|
34
|
+
reason: "unsupported-file",
|
|
35
|
+
message: `El archivo debe ser ${allowedTypes.join(", ")} y pesar hasta ${megabytes(maxBytes)}. Sube el comprobante en un formato v\xE1lido.`
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
if (input.declaredAmount < order.total) {
|
|
39
|
+
return {
|
|
40
|
+
ok: false,
|
|
41
|
+
reason: "amount-below-total",
|
|
42
|
+
message: `El monto declarado (${money(input.declaredAmount)}) es menor al total de la orden (${money(order.total)}). Sube un comprobante por el total.`
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (referenceAlreadyUsed) {
|
|
46
|
+
return {
|
|
47
|
+
ok: false,
|
|
48
|
+
reason: "duplicate-reference",
|
|
49
|
+
message: `El n\xFAmero de referencia "${input.referenceNumber}" ya fue usado en otra orden. Cada comprobante debe tener una referencia \xFAnica.`
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return { ok: true };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// src/adapter.ts
|
|
56
|
+
function buildWhatsappUrl(whatsapp, orderId) {
|
|
57
|
+
const text = encodeURIComponent(`Hola, env\xEDo el comprobante de transferencia de la orden ${orderId}.`);
|
|
58
|
+
return `https://wa.me/${whatsapp}?text=${text}`;
|
|
59
|
+
}
|
|
60
|
+
function toBankTransferPaymentMethod(config, ports) {
|
|
61
|
+
return {
|
|
62
|
+
id: "bank-transfer",
|
|
63
|
+
label: "Transferencia bancaria",
|
|
64
|
+
async prepare(order) {
|
|
65
|
+
return {
|
|
66
|
+
accounts: config.accounts,
|
|
67
|
+
reference: order.id,
|
|
68
|
+
amount: order.total,
|
|
69
|
+
status: "awaiting_payment_proof",
|
|
70
|
+
...config.whatsapp ? { whatsappUrl: buildWhatsappUrl(config.whatsapp, order.id) } : {}
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
async submitProof(input) {
|
|
74
|
+
const [order, referenceAlreadyUsed] = await Promise.all([
|
|
75
|
+
ports.findOrder(input.orderRef),
|
|
76
|
+
ports.isReferenceUsed(input.referenceNumber)
|
|
77
|
+
]);
|
|
78
|
+
const check = validateProof(input, { order, referenceAlreadyUsed, config });
|
|
79
|
+
if (check.ok && order) {
|
|
80
|
+
await ports.onProofAccepted(input, order);
|
|
81
|
+
}
|
|
82
|
+
return check;
|
|
83
|
+
},
|
|
84
|
+
async confirm(ref) {
|
|
85
|
+
const decision = await ports.getDecision?.(ref);
|
|
86
|
+
if (decision === "approved") return { status: "paid" };
|
|
87
|
+
if (decision === "rejected") return { status: "failed", error: "Comprobante rechazado" };
|
|
88
|
+
return { status: "pending" };
|
|
89
|
+
},
|
|
90
|
+
async handleWebhook(payload) {
|
|
91
|
+
const p = payload;
|
|
92
|
+
if (!p?.orderRef || p.decision !== "approved" && p.decision !== "rejected") {
|
|
93
|
+
return { handled: false };
|
|
94
|
+
}
|
|
95
|
+
const { orderRef, decision } = p;
|
|
96
|
+
if (decision === "rejected") {
|
|
97
|
+
return { handled: false, orderId: orderRef, decision };
|
|
98
|
+
}
|
|
99
|
+
return { handled: true, orderId: orderRef, decision };
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export { DEFAULT_ALLOWED_MIME_TYPES, DEFAULT_MAX_PROOF_BYTES, toBankTransferPaymentMethod, validateProof };
|
|
105
|
+
//# sourceMappingURL=index.js.map
|
|
106
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +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"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pandait.tech/payment-bank-transfer",
|
|
3
|
+
"version": "1.0.0",
|
|
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
|
+
"license": "MIT",
|
|
6
|
+
"private": false,
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=18"
|
|
9
|
+
},
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "https://github.com/PandaITorg/panda-commerce-kit.git",
|
|
16
|
+
"directory": "packages/payment-bank-transfer"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"bank-transfer",
|
|
20
|
+
"transferencia",
|
|
21
|
+
"comprobante",
|
|
22
|
+
"payment-gateway",
|
|
23
|
+
"ecuador",
|
|
24
|
+
"panda-commerce-kit"
|
|
25
|
+
],
|
|
26
|
+
"type": "module",
|
|
27
|
+
"main": "./dist/index.cjs",
|
|
28
|
+
"module": "./dist/index.js",
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"import": "./dist/index.js",
|
|
34
|
+
"require": "./dist/index.cjs"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"dist"
|
|
39
|
+
],
|
|
40
|
+
"dependencies": {},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/node": "^22.7.5",
|
|
43
|
+
"@vitest/coverage-v8": "^4.1.7",
|
|
44
|
+
"tsup": "^8.3.0",
|
|
45
|
+
"typescript": "^5.6.3",
|
|
46
|
+
"vitest": "^4.1.7",
|
|
47
|
+
"@pandait.tech/commerce-core": "0.1.0",
|
|
48
|
+
"@pandait.tech/payment-core": "0.0.1"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"build": "tsup",
|
|
52
|
+
"dev": "tsup --watch",
|
|
53
|
+
"type-check": "tsc --noEmit",
|
|
54
|
+
"test": "vitest run --coverage",
|
|
55
|
+
"test:watch": "vitest",
|
|
56
|
+
"lint": "eslint src",
|
|
57
|
+
"clean": "rm -rf dist .turbo"
|
|
58
|
+
}
|
|
59
|
+
}
|