@fire-spark/payments-redeban-pinpad 0.0.0-stage → 0.13.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/dist/index.d.ts +80 -0
- package/dist/index.js +1189 -0
- package/package.json +58 -3
- package/README.md +0 -3
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { DeviceField, PaymentAdapter, PaymentErrorEntry } from "@fire-spark/payments";
|
|
2
|
+
//#region src/parse.d.ts
|
|
3
|
+
type SipResponse = {
|
|
4
|
+
code: string;
|
|
5
|
+
message: string;
|
|
6
|
+
};
|
|
7
|
+
//#endregion
|
|
8
|
+
//#region src/client.d.ts
|
|
9
|
+
type TokenInput = {
|
|
10
|
+
baseUrl: string;
|
|
11
|
+
codigoUnico: string;
|
|
12
|
+
usuario: string;
|
|
13
|
+
clave: string;
|
|
14
|
+
};
|
|
15
|
+
type SendInput = {
|
|
16
|
+
baseUrl: string;
|
|
17
|
+
codigoUnico: string;
|
|
18
|
+
id: string;
|
|
19
|
+
data: string;
|
|
20
|
+
token: string;
|
|
21
|
+
};
|
|
22
|
+
type LookupInput = {
|
|
23
|
+
baseUrl: string;
|
|
24
|
+
codigoUnico: string;
|
|
25
|
+
id: string;
|
|
26
|
+
token: string;
|
|
27
|
+
};
|
|
28
|
+
type RedebanClient = {
|
|
29
|
+
token(input: TokenInput): Promise<SipResponse>;
|
|
30
|
+
send(input: SendInput): Promise<SipResponse>;
|
|
31
|
+
response(input: LookupInput): Promise<SipResponse>;
|
|
32
|
+
erase(input: LookupInput): Promise<SipResponse>;
|
|
33
|
+
};
|
|
34
|
+
/** REST client. Every call is POST with an empty body and query params. */
|
|
35
|
+
declare const createRedebanClient: (fetchImpl?: typeof fetch) => RedebanClient;
|
|
36
|
+
//#endregion
|
|
37
|
+
//#region src/errors.d.ts
|
|
38
|
+
/** Anexo 4. Keys are the technical `Cod`, not the financial response. */
|
|
39
|
+
declare const redebanPinPadErrors: Readonly<Record<string, PaymentErrorEntry>> & {
|
|
40
|
+
"03": PaymentErrorEntry;
|
|
41
|
+
"04": PaymentErrorEntry;
|
|
42
|
+
"05": PaymentErrorEntry;
|
|
43
|
+
"06": PaymentErrorEntry;
|
|
44
|
+
"07": PaymentErrorEntry;
|
|
45
|
+
"08": PaymentErrorEntry;
|
|
46
|
+
"09": PaymentErrorEntry;
|
|
47
|
+
"10": PaymentErrorEntry;
|
|
48
|
+
"11": PaymentErrorEntry;
|
|
49
|
+
"12": PaymentErrorEntry;
|
|
50
|
+
"14": PaymentErrorEntry;
|
|
51
|
+
"15": PaymentErrorEntry;
|
|
52
|
+
"16": PaymentErrorEntry;
|
|
53
|
+
"17": PaymentErrorEntry;
|
|
54
|
+
"18": PaymentErrorEntry;
|
|
55
|
+
"99": PaymentErrorEntry;
|
|
56
|
+
};
|
|
57
|
+
//#endregion
|
|
58
|
+
//#region src/settings.d.ts
|
|
59
|
+
declare const redebanPinPadDeviceFields: DeviceField[];
|
|
60
|
+
//#endregion
|
|
61
|
+
//#region src/poll.d.ts
|
|
62
|
+
type PollClock = {
|
|
63
|
+
now: () => number;
|
|
64
|
+
wait: (ms: number, signal: AbortSignal) => Promise<void>;
|
|
65
|
+
};
|
|
66
|
+
//#endregion
|
|
67
|
+
//#region src/adapter.d.ts
|
|
68
|
+
type RedebanPinPadOptions = {
|
|
69
|
+
client?: RedebanClient;
|
|
70
|
+
clock?: PollClock;
|
|
71
|
+
/** Console traces. Off unless this is true. */
|
|
72
|
+
debug?: boolean;
|
|
73
|
+
/** Backend info traces. Errors are sent either way. Default on. */
|
|
74
|
+
traces?: boolean;
|
|
75
|
+
/** Clock for the trace stamps. The poll clock stays separate. */
|
|
76
|
+
now?: () => Date;
|
|
77
|
+
};
|
|
78
|
+
declare const redebanPinPadPayments: (options?: RedebanPinPadOptions) => PaymentAdapter;
|
|
79
|
+
//#endregion
|
|
80
|
+
export { type RedebanClient, type RedebanPinPadOptions, createRedebanClient, redebanPinPadDeviceFields, redebanPinPadErrors, redebanPinPadPayments };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,1189 @@
|
|
|
1
|
+
import { PaymentError, createPaymentError, definePaymentErrors, isPaymentError, toPaymentError } from "@fire-spark/payments";
|
|
2
|
+
import { log, logError } from "@fire-spark/core";
|
|
3
|
+
//#region src/constants.ts
|
|
4
|
+
/** First four polls, then a shorter gap. Matches the KIOSCOS client. */
|
|
5
|
+
const REDEBAN_POLL_INTERVALS_MS = [
|
|
6
|
+
1e4,
|
|
7
|
+
1e4,
|
|
8
|
+
1e4,
|
|
9
|
+
1e4
|
|
10
|
+
];
|
|
11
|
+
/** Covers both poll phases so the orchestrator does not cut at 30s. */
|
|
12
|
+
const REDEBAN_TIMEOUT_MS = 37e4;
|
|
13
|
+
const REDEBAN_ERROR_SOURCE = "docs/payments/redeban/pinpad.md#11-errores";
|
|
14
|
+
//#endregion
|
|
15
|
+
//#region src/parse.ts
|
|
16
|
+
const CODE = /^Cod:\s*([^,]*)\s*,\s*Msj:\s*([\s\S]*)$/;
|
|
17
|
+
/** `Cod:00,Msj:...`. The message keeps its commas. */
|
|
18
|
+
const parseSipBody = (body) => {
|
|
19
|
+
const match = CODE.exec(body.trim());
|
|
20
|
+
if (!match) return null;
|
|
21
|
+
const code = match[1]?.trim() ?? "";
|
|
22
|
+
return {
|
|
23
|
+
code: /^\d{1,2}$/.test(code) ? code.padStart(2, "0") : code,
|
|
24
|
+
message: (match[2] ?? "").trim()
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
/** EnviarDatos success. A financial reply never starts with OK. */
|
|
28
|
+
const isTechnicalOk = (message) => {
|
|
29
|
+
return message.trim().toUpperCase().startsWith("OK");
|
|
30
|
+
};
|
|
31
|
+
/** Respuesta finished: Cod 00 and a financial message, not a bare OK. */
|
|
32
|
+
const isFinancialMessage = (message) => {
|
|
33
|
+
const text = message.trim();
|
|
34
|
+
if (!text) return false;
|
|
35
|
+
if (isTechnicalOk(text)) return false;
|
|
36
|
+
if (text.toUpperCase().startsWith("ERROR")) return false;
|
|
37
|
+
return true;
|
|
38
|
+
};
|
|
39
|
+
const at = (fields, index) => fields[index]?.trim() ?? "";
|
|
40
|
+
/** Anexo 2, in order, taken from the `Msj` of a finished Respuesta. */
|
|
41
|
+
const parseFinancial = (message) => {
|
|
42
|
+
const fields = message.split(",").map((field) => field.trim());
|
|
43
|
+
return {
|
|
44
|
+
responseCode: at(fields, 0),
|
|
45
|
+
approvalCode: at(fields, 1),
|
|
46
|
+
total: at(fields, 2),
|
|
47
|
+
iva: at(fields, 3),
|
|
48
|
+
receipt: at(fields, 4),
|
|
49
|
+
rrn: at(fields, 5),
|
|
50
|
+
terminal: at(fields, 6),
|
|
51
|
+
date: at(fields, 7),
|
|
52
|
+
time: at(fields, 8),
|
|
53
|
+
franchise: at(fields, 9),
|
|
54
|
+
accountType: at(fields, 10),
|
|
55
|
+
installments: at(fields, 11),
|
|
56
|
+
bin: at(fields, 12),
|
|
57
|
+
establishment: at(fields, 13),
|
|
58
|
+
address: at(fields, 14),
|
|
59
|
+
register: at(fields, 15),
|
|
60
|
+
cardName: at(fields, 16),
|
|
61
|
+
expiry: at(fields, 28),
|
|
62
|
+
fields
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
const isApprovedCode = (code) => {
|
|
66
|
+
return code === "00" || code === "0";
|
|
67
|
+
};
|
|
68
|
+
/** Pinpad receipt is 6 characters. */
|
|
69
|
+
const padReceipt = (value) => {
|
|
70
|
+
const clean = value.trim();
|
|
71
|
+
if (!clean) return "000000";
|
|
72
|
+
return clean.padStart(6, "0").slice(-6);
|
|
73
|
+
};
|
|
74
|
+
const integerField = (value) => {
|
|
75
|
+
const digits = value.replace(/\D/g, "");
|
|
76
|
+
if (!digits) return null;
|
|
77
|
+
return Number(digits);
|
|
78
|
+
};
|
|
79
|
+
/** Last four from a masked PAN, else from the BIN field. */
|
|
80
|
+
const lastFour = (financial) => {
|
|
81
|
+
const digits = (financial.fields.find((field) => field.includes("*")) || financial.bin || financial.cardName).replace(/\D/g, "");
|
|
82
|
+
if (digits.length < 4) return null;
|
|
83
|
+
return digits.slice(-4);
|
|
84
|
+
};
|
|
85
|
+
//#endregion
|
|
86
|
+
//#region src/client.ts
|
|
87
|
+
const joinUrl = (baseUrl, path) => {
|
|
88
|
+
const base = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
|
89
|
+
return new URL(path.replace(/^\//, ""), base);
|
|
90
|
+
};
|
|
91
|
+
const post = async (fetchImpl, url) => {
|
|
92
|
+
const response = await fetchImpl(url, { method: "POST" });
|
|
93
|
+
const body = await response.text();
|
|
94
|
+
if (!response.ok) throw new Error(body || `SIPConnector HTTP ${response.status}`);
|
|
95
|
+
const parsed = parseSipBody(body);
|
|
96
|
+
if (!parsed) throw new Error("SIPConnector response has no Cod/Msj");
|
|
97
|
+
return parsed;
|
|
98
|
+
};
|
|
99
|
+
/** REST client. Every call is POST with an empty body and query params. */
|
|
100
|
+
const createRedebanClient = (fetchImpl = fetch) => {
|
|
101
|
+
return {
|
|
102
|
+
token: (input) => {
|
|
103
|
+
const url = joinUrl(input.baseUrl, "api/Token");
|
|
104
|
+
url.searchParams.set("CodigoUnico", input.codigoUnico);
|
|
105
|
+
url.searchParams.set("Usuario", input.usuario);
|
|
106
|
+
url.searchParams.set("Clave", input.clave);
|
|
107
|
+
return post(fetchImpl, url);
|
|
108
|
+
},
|
|
109
|
+
send: (input) => {
|
|
110
|
+
const url = joinUrl(input.baseUrl, "api/EnviarDatos");
|
|
111
|
+
url.searchParams.set("CodigoUnico", input.codigoUnico);
|
|
112
|
+
url.searchParams.set("IdTransaccion", input.id);
|
|
113
|
+
url.searchParams.set("Data", input.data);
|
|
114
|
+
url.searchParams.set("token", input.token);
|
|
115
|
+
url.searchParams.set("Sobreescribir", "S");
|
|
116
|
+
url.searchParams.set("Red", "0");
|
|
117
|
+
return post(fetchImpl, url);
|
|
118
|
+
},
|
|
119
|
+
response: (input) => {
|
|
120
|
+
const url = joinUrl(input.baseUrl, "api/Respuesta");
|
|
121
|
+
url.searchParams.set("CodigoUnico", input.codigoUnico);
|
|
122
|
+
url.searchParams.set("IdTransaccion", input.id);
|
|
123
|
+
url.searchParams.set("token", input.token);
|
|
124
|
+
url.searchParams.set("Red", "0");
|
|
125
|
+
return post(fetchImpl, url);
|
|
126
|
+
},
|
|
127
|
+
erase: (input) => {
|
|
128
|
+
const url = joinUrl(input.baseUrl, "api/Borrar");
|
|
129
|
+
url.searchParams.set("CodigoUnico", input.codigoUnico);
|
|
130
|
+
url.searchParams.set("IdTransaccion", input.id);
|
|
131
|
+
url.searchParams.set("Token", input.token);
|
|
132
|
+
url.searchParams.set("Red", "0");
|
|
133
|
+
return post(fetchImpl, url);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
};
|
|
137
|
+
//#endregion
|
|
138
|
+
//#region src/errors.ts
|
|
139
|
+
const entry = (code, message, retryable = false) => {
|
|
140
|
+
return {
|
|
141
|
+
code,
|
|
142
|
+
message,
|
|
143
|
+
retryable,
|
|
144
|
+
source: REDEBAN_ERROR_SOURCE,
|
|
145
|
+
since: "1.5"
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
/** Anexo 4. Keys are the technical `Cod`, not the financial response. */
|
|
149
|
+
const redebanPinPadErrors = definePaymentErrors({
|
|
150
|
+
"03": entry("INVALID_REQUEST", "Incorrect parameters"),
|
|
151
|
+
"04": entry("INVALID_CREDENTIALS", "Token is not valid", true),
|
|
152
|
+
"05": entry("INVALID_REQUEST", "Transaction id is incorrect"),
|
|
153
|
+
"06": entry("TERMINAL_BUSY", "Terminal already has a transaction", true),
|
|
154
|
+
"07": entry("INVALID_CREDENTIALS", "Unique code is invalid"),
|
|
155
|
+
"08": entry("INVALID_REQUEST", "Incomplete data structure"),
|
|
156
|
+
"09": entry("INVALID_REQUEST", "Transaction id already exists"),
|
|
157
|
+
"10": entry("PROVIDER_UNAVAILABLE", "Could not insert the transaction", true),
|
|
158
|
+
"11": entry("PROVIDER_UNAVAILABLE", "Could not insert the transaction", true),
|
|
159
|
+
"12": entry("INVALID_REQUEST", "Structure or LRC error"),
|
|
160
|
+
"14": entry("PROVIDER_UNAVAILABLE", "Internal process error", true),
|
|
161
|
+
"15": entry("INVALID_CREDENTIALS", "Token data is invalid", true),
|
|
162
|
+
"16": entry("INVALID_CREDENTIALS", "Authentication failed"),
|
|
163
|
+
"17": entry("UNKNOWN", "Record to delete was not found"),
|
|
164
|
+
"18": entry("INVALID_REQUEST", "Transaction is already resolved"),
|
|
165
|
+
"99": entry("PROVIDER_UNAVAILABLE", "Technical error", true)
|
|
166
|
+
});
|
|
167
|
+
//#endregion
|
|
168
|
+
//#region src/settings.ts
|
|
169
|
+
const redebanPinPadDeviceFields = [
|
|
170
|
+
{
|
|
171
|
+
key: "terminal_id",
|
|
172
|
+
label: "Terminal code",
|
|
173
|
+
type: "text",
|
|
174
|
+
required: true
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
key: "register_number",
|
|
178
|
+
label: "Register number",
|
|
179
|
+
type: "text",
|
|
180
|
+
required: true
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
key: "cashier_code",
|
|
184
|
+
label: "Cashier code",
|
|
185
|
+
type: "text",
|
|
186
|
+
required: true
|
|
187
|
+
}
|
|
188
|
+
];
|
|
189
|
+
const text = (value) => {
|
|
190
|
+
return typeof value === "string" ? value.trim() : "";
|
|
191
|
+
};
|
|
192
|
+
const clip$1 = (value, max) => value.slice(0, max);
|
|
193
|
+
/** Merchant keys live on `config`. Terminal keys live on the device. */
|
|
194
|
+
const readRedebanSettings = (config, device) => {
|
|
195
|
+
const baseUrl = text(config.base_url);
|
|
196
|
+
const codigoUnico = text(config.codigo_unico);
|
|
197
|
+
const usuario = text(config.usuario);
|
|
198
|
+
const clave = text(config.secret_clave);
|
|
199
|
+
const supervisorPassword = text(config.secret_supervisor_password);
|
|
200
|
+
const terminalId = text(device.terminal_id);
|
|
201
|
+
const registerNumber = text(device.register_number);
|
|
202
|
+
const cashierCode = text(device.cashier_code);
|
|
203
|
+
const missing = [
|
|
204
|
+
["base_url", baseUrl],
|
|
205
|
+
["codigo_unico", codigoUnico],
|
|
206
|
+
["usuario", usuario],
|
|
207
|
+
["secret_clave", clave],
|
|
208
|
+
["secret_supervisor_password", supervisorPassword],
|
|
209
|
+
["terminal_id", terminalId],
|
|
210
|
+
["register_number", registerNumber],
|
|
211
|
+
["cashier_code", cashierCode]
|
|
212
|
+
].filter(([, value]) => !value).map(([key]) => key);
|
|
213
|
+
if (missing.length) return {
|
|
214
|
+
ok: false,
|
|
215
|
+
missing
|
|
216
|
+
};
|
|
217
|
+
return {
|
|
218
|
+
ok: true,
|
|
219
|
+
settings: {
|
|
220
|
+
baseUrl,
|
|
221
|
+
codigoUnico,
|
|
222
|
+
usuario,
|
|
223
|
+
clave,
|
|
224
|
+
supervisorPassword,
|
|
225
|
+
terminalId: clip$1(terminalId, 10),
|
|
226
|
+
registerNumber: clip$1(registerNumber, 10),
|
|
227
|
+
cashierCode: clip$1(cashierCode, 10)
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
};
|
|
231
|
+
//#endregion
|
|
232
|
+
//#region src/trace.ts
|
|
233
|
+
/** One in-call trace. Discarded when the operation is not reported. */
|
|
234
|
+
const DETAIL_MAX = 2e3;
|
|
235
|
+
const MARK = "\n…\n";
|
|
236
|
+
/** 14–19 digits. Amounts, dates and short ids stay as-is. */
|
|
237
|
+
const PAN = /\d{14,19}/g;
|
|
238
|
+
/** Track 1/2 sentinels. The payload is not a field we record on purpose. */
|
|
239
|
+
const TRACK = /[%;][\dA-Za-z=^]{12,}/g;
|
|
240
|
+
/** Query values that can carry the service key, the token or the frame. */
|
|
241
|
+
const SECRET_QUERY = /([?&](?:Clave|token|Token|Data)=)[^&\s"')]+/gi;
|
|
242
|
+
const pad = (value, width) => {
|
|
243
|
+
return String(value).padStart(width, "0");
|
|
244
|
+
};
|
|
245
|
+
/** Local kiosk time of the step, not the time the log is posted. */
|
|
246
|
+
const redebanStamp = (date) => {
|
|
247
|
+
return `${pad(date.getHours(), 2)}:${pad(date.getMinutes(), 2)}:${pad(date.getSeconds(), 2)}.${pad(date.getMilliseconds(), 3)}`;
|
|
248
|
+
};
|
|
249
|
+
/** PAN and track data only. Amounts and short ids stay. */
|
|
250
|
+
const maskCardData = (value) => {
|
|
251
|
+
return value.replace(TRACK, "<track>").replace(PAN, (digits) => {
|
|
252
|
+
return `************${digits.slice(-4)}`;
|
|
253
|
+
});
|
|
254
|
+
};
|
|
255
|
+
/** PAN, track data, and secrets that arrived inside a URL. */
|
|
256
|
+
const redactSecrets = (value) => {
|
|
257
|
+
return maskCardData(value).replace(SECRET_QUERY, "$1<redacted>");
|
|
258
|
+
};
|
|
259
|
+
/** Keeps the start and the end when `detail` would pass 2000 characters. */
|
|
260
|
+
const fitDetail = (text) => {
|
|
261
|
+
if (text.length <= DETAIL_MAX) return text;
|
|
262
|
+
const head = Math.ceil((DETAIL_MAX - 3) / 2);
|
|
263
|
+
return `${text.slice(0, head)}${MARK}${text.slice(-998)}`;
|
|
264
|
+
};
|
|
265
|
+
const createRedebanTrace = (now = () => /* @__PURE__ */ new Date()) => {
|
|
266
|
+
const lines = [];
|
|
267
|
+
const push = (body) => {
|
|
268
|
+
const stamp = redebanStamp(now());
|
|
269
|
+
lines.push(`${stamp} ${redactSecrets(body)}`);
|
|
270
|
+
};
|
|
271
|
+
return {
|
|
272
|
+
call: (body) => push(`→ ${body}`),
|
|
273
|
+
result: (body) => push(`← ${body}`),
|
|
274
|
+
note: (body) => push(body),
|
|
275
|
+
detail: () => fitDetail(lines.join("\n"))
|
|
276
|
+
};
|
|
277
|
+
};
|
|
278
|
+
//#endregion
|
|
279
|
+
//#region src/debug.ts
|
|
280
|
+
const TAG = "[REDEBAN_DEBUG]";
|
|
281
|
+
/** Keys whose values are a token, a password, or the raw SIP message. */
|
|
282
|
+
const HIDDEN = /* @__PURE__ */ new Set([
|
|
283
|
+
"message",
|
|
284
|
+
"token",
|
|
285
|
+
"clave",
|
|
286
|
+
"data",
|
|
287
|
+
"password",
|
|
288
|
+
"supervisorPassword",
|
|
289
|
+
"secret",
|
|
290
|
+
"secret_clave",
|
|
291
|
+
"secret_supervisor_password",
|
|
292
|
+
"Msj",
|
|
293
|
+
"Clave",
|
|
294
|
+
"Token",
|
|
295
|
+
"Data"
|
|
296
|
+
]);
|
|
297
|
+
let enabled = false;
|
|
298
|
+
const elapsed = (startedAt) => Date.now() - startedAt;
|
|
299
|
+
const safe = (value) => {
|
|
300
|
+
if (typeof value === "string") return redactSecrets(value);
|
|
301
|
+
if (value instanceof Map) return safe(Object.fromEntries(value));
|
|
302
|
+
if (value instanceof Error) {
|
|
303
|
+
const code = value.code;
|
|
304
|
+
const message = redactSecrets(value.message);
|
|
305
|
+
return {
|
|
306
|
+
name: value.name,
|
|
307
|
+
message,
|
|
308
|
+
code
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
if (Array.isArray(value)) return value.map((item) => safe(item));
|
|
312
|
+
if (!value || typeof value !== "object") return value;
|
|
313
|
+
const out = {};
|
|
314
|
+
for (const [name, item] of Object.entries(value)) {
|
|
315
|
+
if (HIDDEN.has(name)) continue;
|
|
316
|
+
out[name] = safe(item);
|
|
317
|
+
}
|
|
318
|
+
return out;
|
|
319
|
+
};
|
|
320
|
+
/** Turns console traces on or off. Default is off. */
|
|
321
|
+
const setRedebanLogs = (value) => {
|
|
322
|
+
enabled = value;
|
|
323
|
+
};
|
|
324
|
+
/** Logs one step: `[REDEBAN_DEBUG] scope › event { data }`. */
|
|
325
|
+
const redebanDebug = (scope, event, data) => {
|
|
326
|
+
if (!enabled) return;
|
|
327
|
+
const time = (/* @__PURE__ */ new Date()).toISOString();
|
|
328
|
+
const line = `${scope} › ${event}`;
|
|
329
|
+
if (data === void 0) {
|
|
330
|
+
console.log(TAG, time, line);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
console.log(TAG, time, line, safe(data));
|
|
334
|
+
};
|
|
335
|
+
/** Logs the input, the output or the error, and the duration of a call. */
|
|
336
|
+
const redebanDebugCall = async (scope, input, call) => {
|
|
337
|
+
const startedAt = Date.now();
|
|
338
|
+
redebanDebug(scope, "→ in", input);
|
|
339
|
+
try {
|
|
340
|
+
const output = await call();
|
|
341
|
+
const ms = elapsed(startedAt);
|
|
342
|
+
redebanDebug(scope, "← out", {
|
|
343
|
+
output,
|
|
344
|
+
ms
|
|
345
|
+
});
|
|
346
|
+
return output;
|
|
347
|
+
} catch (error) {
|
|
348
|
+
const ms = elapsed(startedAt);
|
|
349
|
+
redebanDebug(scope, "✕ error", {
|
|
350
|
+
error,
|
|
351
|
+
ms
|
|
352
|
+
});
|
|
353
|
+
throw error;
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
//#endregion
|
|
357
|
+
//#region src/frame.ts
|
|
358
|
+
const clip = (value, max) => value.trim().slice(0, max);
|
|
359
|
+
/** Integer pesos. SIPConnector rejects separators and decimals. */
|
|
360
|
+
const pesos = (value) => {
|
|
361
|
+
if (!Number.isFinite(value)) return "0";
|
|
362
|
+
return String(Math.round(value));
|
|
363
|
+
};
|
|
364
|
+
const shared = (input) => {
|
|
365
|
+
const amount = pesos(input.amount);
|
|
366
|
+
const iva = pesos(input.iva);
|
|
367
|
+
const invoice = clip(input.invoice, 10);
|
|
368
|
+
const head = [
|
|
369
|
+
amount,
|
|
370
|
+
iva,
|
|
371
|
+
"0",
|
|
372
|
+
"0",
|
|
373
|
+
amount,
|
|
374
|
+
invoice
|
|
375
|
+
];
|
|
376
|
+
const tail = [
|
|
377
|
+
clip(input.cashierCode, 10),
|
|
378
|
+
"0",
|
|
379
|
+
"0",
|
|
380
|
+
"0",
|
|
381
|
+
clip(input.terminalId, 10),
|
|
382
|
+
clip(input.registerNumber, 10)
|
|
383
|
+
];
|
|
384
|
+
return {
|
|
385
|
+
amount,
|
|
386
|
+
iva,
|
|
387
|
+
invoice,
|
|
388
|
+
fields: [...head, ...tail]
|
|
389
|
+
};
|
|
390
|
+
};
|
|
391
|
+
/** Compra, Anexo 1.1. Twenty fields. Multicommerce repeats the unique code. */
|
|
392
|
+
const saleFrame = (input) => {
|
|
393
|
+
const { invoice, fields } = shared(input);
|
|
394
|
+
const codigo = clip(input.codigoUnico, 10);
|
|
395
|
+
const rest = [
|
|
396
|
+
"0",
|
|
397
|
+
"0",
|
|
398
|
+
invoice,
|
|
399
|
+
"3"
|
|
400
|
+
];
|
|
401
|
+
const end = [
|
|
402
|
+
"N",
|
|
403
|
+
codigo,
|
|
404
|
+
" "
|
|
405
|
+
];
|
|
406
|
+
return [
|
|
407
|
+
"0",
|
|
408
|
+
...fields,
|
|
409
|
+
...rest,
|
|
410
|
+
...end
|
|
411
|
+
].join(",");
|
|
412
|
+
};
|
|
413
|
+
/**
|
|
414
|
+
* Anulación total, Anexo 1.2. Eighteen fields.
|
|
415
|
+
* Without multicommerce the last field stays empty.
|
|
416
|
+
*/
|
|
417
|
+
const voidFrame = (input) => {
|
|
418
|
+
const { fields } = shared(input);
|
|
419
|
+
const rest = [
|
|
420
|
+
padReceipt(input.receipt),
|
|
421
|
+
clip(input.supervisorPassword, 4),
|
|
422
|
+
"3"
|
|
423
|
+
];
|
|
424
|
+
const end = ["N", ""];
|
|
425
|
+
return [
|
|
426
|
+
"1",
|
|
427
|
+
...fields,
|
|
428
|
+
...rest,
|
|
429
|
+
...end
|
|
430
|
+
].join(",");
|
|
431
|
+
};
|
|
432
|
+
//#endregion
|
|
433
|
+
//#region src/metadata.ts
|
|
434
|
+
const ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
435
|
+
/** SIPConnector ids are 1 to 10 alphanumeric characters. */
|
|
436
|
+
const transactionId = (attemptReference) => {
|
|
437
|
+
const clean = attemptReference.replace(/[^0-9A-Za-z]/g, "");
|
|
438
|
+
if (clean.length >= 1 && clean.length <= 10) return clean;
|
|
439
|
+
let hash = 2166136261;
|
|
440
|
+
for (let i = 0; i < attemptReference.length; i += 1) {
|
|
441
|
+
hash ^= attemptReference.charCodeAt(i);
|
|
442
|
+
hash = Math.imul(hash, 16777619);
|
|
443
|
+
}
|
|
444
|
+
let value = hash >>> 0;
|
|
445
|
+
let out = "";
|
|
446
|
+
for (let i = 0; i < 10; i += 1) {
|
|
447
|
+
out += ALPHABET[value % 62] ?? "0";
|
|
448
|
+
value = Math.floor(value / 62);
|
|
449
|
+
value += hash >>> i % 16;
|
|
450
|
+
}
|
|
451
|
+
return out.slice(0, 10);
|
|
452
|
+
};
|
|
453
|
+
const ivaOf = (taxes) => {
|
|
454
|
+
const sum = taxes.reduce((total, tax) => total + tax.tax, 0);
|
|
455
|
+
return Number(pesos(sum));
|
|
456
|
+
};
|
|
457
|
+
const cardTypeOf = (account) => {
|
|
458
|
+
if (account === "CR") return "CREDIT";
|
|
459
|
+
if (account === "AH" || account === "CC") return "DEBIT";
|
|
460
|
+
return null;
|
|
461
|
+
};
|
|
462
|
+
const providerOf = (snapshot, financial) => {
|
|
463
|
+
const base = {
|
|
464
|
+
id_transaccion: snapshot.id,
|
|
465
|
+
valor_venta: snapshot.amount,
|
|
466
|
+
valor_iva: snapshot.iva,
|
|
467
|
+
valor_total: snapshot.total,
|
|
468
|
+
numero_recibo: snapshot.receipt
|
|
469
|
+
};
|
|
470
|
+
if (!financial) return base;
|
|
471
|
+
return {
|
|
472
|
+
...base,
|
|
473
|
+
codigo_respuesta: financial.responseCode,
|
|
474
|
+
codigo_aprobacion: financial.approvalCode,
|
|
475
|
+
rrn: financial.rrn,
|
|
476
|
+
numero_recibo: financial.receipt || snapshot.receipt,
|
|
477
|
+
raw_message: financial.fields.join(",")
|
|
478
|
+
};
|
|
479
|
+
};
|
|
480
|
+
const paymentOf = (financial) => {
|
|
481
|
+
const count = integerField(financial.installments) ?? 0;
|
|
482
|
+
const installments = count > 0 ? {
|
|
483
|
+
count,
|
|
484
|
+
type: null
|
|
485
|
+
} : null;
|
|
486
|
+
const date = financial.date ? `20${financial.date}` : null;
|
|
487
|
+
return {
|
|
488
|
+
provider_transaction_id: financial.approvalCode || null,
|
|
489
|
+
authorization_code: financial.approvalCode || null,
|
|
490
|
+
reference: financial.rrn || null,
|
|
491
|
+
card_type: cardTypeOf(financial.accountType),
|
|
492
|
+
brand: financial.franchise || null,
|
|
493
|
+
last4: lastFour(financial),
|
|
494
|
+
installments,
|
|
495
|
+
terminal_id: financial.terminal || null,
|
|
496
|
+
merchant_id: financial.establishment || null,
|
|
497
|
+
ticket_number: financial.receipt || null,
|
|
498
|
+
transaction_date: date,
|
|
499
|
+
account_type: financial.accountType || null
|
|
500
|
+
};
|
|
501
|
+
};
|
|
502
|
+
const snapshotOf = (result) => {
|
|
503
|
+
const provider = result.metadata.provider;
|
|
504
|
+
if (!provider) return null;
|
|
505
|
+
const id = provider.id_transaccion;
|
|
506
|
+
const amount = provider.valor_venta;
|
|
507
|
+
const iva = provider.valor_iva;
|
|
508
|
+
const total = provider.valor_total;
|
|
509
|
+
const receipt = provider.numero_recibo;
|
|
510
|
+
if (typeof id !== "string" || !id) return null;
|
|
511
|
+
if (typeof amount !== "number" || typeof iva !== "number") return null;
|
|
512
|
+
if (typeof total !== "number") return null;
|
|
513
|
+
return {
|
|
514
|
+
id,
|
|
515
|
+
amount,
|
|
516
|
+
iva,
|
|
517
|
+
total,
|
|
518
|
+
receipt: typeof receipt === "string" ? receipt : ""
|
|
519
|
+
};
|
|
520
|
+
};
|
|
521
|
+
const metadataOf = (attemptReference, snapshot, financial) => {
|
|
522
|
+
const provider = providerOf(snapshot, financial);
|
|
523
|
+
return {
|
|
524
|
+
attempt_reference: attemptReference,
|
|
525
|
+
payment: financial ? paymentOf(financial) : void 0,
|
|
526
|
+
provider
|
|
527
|
+
};
|
|
528
|
+
};
|
|
529
|
+
//#endregion
|
|
530
|
+
//#region src/poll.ts
|
|
531
|
+
const defaultPollClock = () => {
|
|
532
|
+
return {
|
|
533
|
+
now: () => Date.now(),
|
|
534
|
+
wait: (ms, signal) => {
|
|
535
|
+
return new Promise((resolve) => {
|
|
536
|
+
if (signal.aborted) {
|
|
537
|
+
resolve();
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
const onAbort = () => {
|
|
541
|
+
clearTimeout(timer);
|
|
542
|
+
resolve();
|
|
543
|
+
};
|
|
544
|
+
const timer = setTimeout(() => {
|
|
545
|
+
signal.removeEventListener("abort", onAbort);
|
|
546
|
+
resolve();
|
|
547
|
+
}, ms);
|
|
548
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
};
|
|
552
|
+
};
|
|
553
|
+
const intervalAt = (index) => {
|
|
554
|
+
return REDEBAN_POLL_INTERVALS_MS[index] ?? 5e3;
|
|
555
|
+
};
|
|
556
|
+
/**
|
|
557
|
+
* Polls Respuesta. Cod 01 or 02 refreshes the token and resets the
|
|
558
|
+
* deadline once. Cod 02 ignores abort: the card is already in the pinpad.
|
|
559
|
+
*/
|
|
560
|
+
const pollRespuesta = async (input) => {
|
|
561
|
+
const { client, settings, id, signal, clock, refreshToken, trace } = input;
|
|
562
|
+
let token = input.token;
|
|
563
|
+
let start = clock.now();
|
|
564
|
+
let reset = false;
|
|
565
|
+
let last = "";
|
|
566
|
+
let seen = "";
|
|
567
|
+
let polls = 0;
|
|
568
|
+
const lookup = () => {
|
|
569
|
+
const baseUrl = settings.baseUrl;
|
|
570
|
+
const codigoUnico = settings.codigoUnico;
|
|
571
|
+
return client.response({
|
|
572
|
+
baseUrl,
|
|
573
|
+
codigoUnico,
|
|
574
|
+
id,
|
|
575
|
+
token
|
|
576
|
+
});
|
|
577
|
+
};
|
|
578
|
+
while (true) {
|
|
579
|
+
const waiting = last === "" || last === "00" || last === "01";
|
|
580
|
+
if (signal.aborted && waiting) return {
|
|
581
|
+
kind: "cancelled",
|
|
582
|
+
token
|
|
583
|
+
};
|
|
584
|
+
const response = await redebanDebugCall("redeban.poll", {
|
|
585
|
+
id,
|
|
586
|
+
index: polls
|
|
587
|
+
}, async () => {
|
|
588
|
+
const body = await lookup();
|
|
589
|
+
const done = body.code === "00" && isFinancialMessage(body.message);
|
|
590
|
+
const length = body.message.length;
|
|
591
|
+
return {
|
|
592
|
+
...body,
|
|
593
|
+
financial: done,
|
|
594
|
+
length
|
|
595
|
+
};
|
|
596
|
+
});
|
|
597
|
+
if (response.code !== seen) {
|
|
598
|
+
trace?.result(`respuesta ${response.code}`);
|
|
599
|
+
seen = response.code;
|
|
600
|
+
}
|
|
601
|
+
last = response.code;
|
|
602
|
+
if (response.code === "04") {
|
|
603
|
+
trace?.note("token refresh");
|
|
604
|
+
token = await refreshToken(true);
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
if (response.code === "01" || response.code === "02") {
|
|
608
|
+
token = await refreshToken(false);
|
|
609
|
+
if (!reset) {
|
|
610
|
+
reset = true;
|
|
611
|
+
start = clock.now();
|
|
612
|
+
}
|
|
613
|
+
} else if (response.code === "00" && isFinancialMessage(response.message)) return {
|
|
614
|
+
kind: "done",
|
|
615
|
+
message: response.message,
|
|
616
|
+
token
|
|
617
|
+
};
|
|
618
|
+
else if (response.code !== "00") return {
|
|
619
|
+
kind: "technical",
|
|
620
|
+
response,
|
|
621
|
+
token
|
|
622
|
+
};
|
|
623
|
+
if (clock.now() - start >= 184e3) return {
|
|
624
|
+
kind: "timeout",
|
|
625
|
+
started: reset,
|
|
626
|
+
token,
|
|
627
|
+
last
|
|
628
|
+
};
|
|
629
|
+
await clock.wait(intervalAt(polls), signal);
|
|
630
|
+
polls += 1;
|
|
631
|
+
}
|
|
632
|
+
};
|
|
633
|
+
//#endregion
|
|
634
|
+
//#region src/report.ts
|
|
635
|
+
/** Reports one pinpad step to the merchant dashboard. */
|
|
636
|
+
const CHARGE_DID_NOT_START = "Redeban charge did not start";
|
|
637
|
+
const CHARGE_DECLINED = "Redeban charge declined";
|
|
638
|
+
const CHARGE_DID_NOT_FINISH = "Redeban charge did not finish";
|
|
639
|
+
const VOID_DECLINED = "Redeban void declined";
|
|
640
|
+
const VOID_DID_NOT_FINISH = "Redeban void did not finish";
|
|
641
|
+
const redactedCause = (error) => {
|
|
642
|
+
if (error instanceof Error) {
|
|
643
|
+
const cause = new Error(redactSecrets(error.message));
|
|
644
|
+
cause.name = error.name;
|
|
645
|
+
if (error.stack) cause.stack = redactSecrets(error.stack);
|
|
646
|
+
return cause;
|
|
647
|
+
}
|
|
648
|
+
if (typeof error === "string") return redactSecrets(error);
|
|
649
|
+
return error;
|
|
650
|
+
};
|
|
651
|
+
/** Fixed phrase as the message. The thrown value keeps its name and stack. */
|
|
652
|
+
const reportedError = (message, error) => {
|
|
653
|
+
const wrapped = new Error(message);
|
|
654
|
+
const cause = redactedCause(error);
|
|
655
|
+
if (cause instanceof Error) {
|
|
656
|
+
wrapped.name = cause.name;
|
|
657
|
+
if (cause.stack) wrapped.stack = cause.stack;
|
|
658
|
+
}
|
|
659
|
+
wrapped.cause = cause;
|
|
660
|
+
return wrapped;
|
|
661
|
+
};
|
|
662
|
+
/** One backend log. `info` is skipped when `traces` is off. Never throws. */
|
|
663
|
+
const reportRedeban = (input) => {
|
|
664
|
+
if (input.level === "info" && !input.traces) return;
|
|
665
|
+
const options = {
|
|
666
|
+
alias: input.alias,
|
|
667
|
+
status: input.status,
|
|
668
|
+
detail: input.detail
|
|
669
|
+
};
|
|
670
|
+
if (!(input.level === "error" && input.error != null)) {
|
|
671
|
+
log(input.level, input.message, options);
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
logError(reportedError(input.message, input.error), options);
|
|
675
|
+
};
|
|
676
|
+
const statusOf = (result) => {
|
|
677
|
+
const code = result.error?.code;
|
|
678
|
+
if (!code) return "failed";
|
|
679
|
+
return code.toLowerCase();
|
|
680
|
+
};
|
|
681
|
+
const send = (input, level, alias, status, message) => {
|
|
682
|
+
const thrown = level === "error" ? input.thrown : void 0;
|
|
683
|
+
reportRedeban({
|
|
684
|
+
level,
|
|
685
|
+
alias,
|
|
686
|
+
status,
|
|
687
|
+
message,
|
|
688
|
+
detail: input.trace.detail(),
|
|
689
|
+
traces: input.traces,
|
|
690
|
+
error: thrown
|
|
691
|
+
});
|
|
692
|
+
};
|
|
693
|
+
const reportCharge = (input) => {
|
|
694
|
+
const { result, opened } = input;
|
|
695
|
+
if (result.status === "PAID") return;
|
|
696
|
+
const cancelled = result.status === "CANCELLED";
|
|
697
|
+
const declined = result.error?.code === "CARD_DECLINED";
|
|
698
|
+
if (cancelled || declined) {
|
|
699
|
+
send(input, "info", "redeban.charge", "declined", CHARGE_DECLINED);
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
const message = opened ? CHARGE_DID_NOT_FINISH : CHARGE_DID_NOT_START;
|
|
703
|
+
send(input, "error", "redeban.charge", statusOf(result), message);
|
|
704
|
+
};
|
|
705
|
+
const reportQuery = (input) => {
|
|
706
|
+
const { result } = input;
|
|
707
|
+
if (result.status === "PAID") return;
|
|
708
|
+
if (result.error?.code === "AMBIGUOUS") return;
|
|
709
|
+
if (result.error?.code === "CARD_DECLINED") {
|
|
710
|
+
send(input, "info", "redeban.charge", "declined", CHARGE_DECLINED);
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
const status = statusOf(result);
|
|
714
|
+
send(input, "error", "redeban.charge", status, CHARGE_DID_NOT_FINISH);
|
|
715
|
+
};
|
|
716
|
+
const reportVoid = (input) => {
|
|
717
|
+
const { result, wasPaid } = input;
|
|
718
|
+
if (result.status === "CANCELLED") return;
|
|
719
|
+
if (wasPaid && result.error?.code === "CARD_DECLINED") {
|
|
720
|
+
send(input, "info", "redeban.void", "declined", VOID_DECLINED);
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
const status = statusOf(result);
|
|
724
|
+
send(input, "error", "redeban.void", status, VOID_DID_NOT_FINISH);
|
|
725
|
+
};
|
|
726
|
+
/** Decides the single backend log for one adapter call. Success stays quiet. */
|
|
727
|
+
const reportOperation = (input) => {
|
|
728
|
+
if (input.kind === "charge") {
|
|
729
|
+
reportCharge(input);
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
if (input.kind === "query") {
|
|
733
|
+
reportQuery(input);
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
reportVoid(input);
|
|
737
|
+
};
|
|
738
|
+
//#endregion
|
|
739
|
+
//#region src/adapter.ts
|
|
740
|
+
const baseResult = (ctx, amount, snapshot, extra = {}) => {
|
|
741
|
+
const reference = ctx.attemptReference;
|
|
742
|
+
return {
|
|
743
|
+
status: "FAILED",
|
|
744
|
+
amount,
|
|
745
|
+
metadata: snapshot ? metadataOf(reference, snapshot) : { attempt_reference: reference },
|
|
746
|
+
...extra
|
|
747
|
+
};
|
|
748
|
+
};
|
|
749
|
+
const withError = (ctx, amount, snapshot, error, needsReview = false) => {
|
|
750
|
+
const result = baseResult(ctx, amount, snapshot);
|
|
751
|
+
const review = needsReview ? { needs_review: true } : {};
|
|
752
|
+
const metadata = {
|
|
753
|
+
...result.metadata,
|
|
754
|
+
error,
|
|
755
|
+
...review
|
|
756
|
+
};
|
|
757
|
+
return {
|
|
758
|
+
...result,
|
|
759
|
+
metadata,
|
|
760
|
+
error
|
|
761
|
+
};
|
|
762
|
+
};
|
|
763
|
+
const technicalError = (ctx, response) => {
|
|
764
|
+
return toPaymentError(redebanPinPadErrors, {
|
|
765
|
+
code: response.code,
|
|
766
|
+
message: response.message,
|
|
767
|
+
attempt: ctx.attempt,
|
|
768
|
+
fallback: "UNKNOWN"
|
|
769
|
+
});
|
|
770
|
+
};
|
|
771
|
+
const loggedSettings = (settings) => {
|
|
772
|
+
return {
|
|
773
|
+
baseUrl: settings.baseUrl,
|
|
774
|
+
codigoUnico: settings.codigoUnico,
|
|
775
|
+
usuario: settings.usuario,
|
|
776
|
+
terminalId: settings.terminalId,
|
|
777
|
+
registerNumber: settings.registerNumber,
|
|
778
|
+
cashierCode: settings.cashierCode
|
|
779
|
+
};
|
|
780
|
+
};
|
|
781
|
+
const issueToken = async (client, settings, ctx, trace) => {
|
|
782
|
+
const baseUrl = settings.baseUrl;
|
|
783
|
+
const codigoUnico = settings.codigoUnico;
|
|
784
|
+
const usuario = settings.usuario;
|
|
785
|
+
const clave = settings.clave;
|
|
786
|
+
const shown = {
|
|
787
|
+
codigoUnico,
|
|
788
|
+
usuario
|
|
789
|
+
};
|
|
790
|
+
trace?.call("token");
|
|
791
|
+
try {
|
|
792
|
+
const response = await redebanDebugCall("redeban.token", shown, () => {
|
|
793
|
+
return client.token({
|
|
794
|
+
baseUrl,
|
|
795
|
+
codigoUnico,
|
|
796
|
+
usuario,
|
|
797
|
+
clave
|
|
798
|
+
});
|
|
799
|
+
});
|
|
800
|
+
if (response.code === "00" && response.message) {
|
|
801
|
+
trace?.result("token 00");
|
|
802
|
+
return {
|
|
803
|
+
ok: true,
|
|
804
|
+
token: response.message
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
trace?.result(`token ${response.code}`);
|
|
808
|
+
return {
|
|
809
|
+
ok: false,
|
|
810
|
+
error: technicalError(ctx, response)
|
|
811
|
+
};
|
|
812
|
+
} catch (error) {
|
|
813
|
+
trace?.result("token error");
|
|
814
|
+
throw error;
|
|
815
|
+
}
|
|
816
|
+
};
|
|
817
|
+
const erase = async (client, settings, id, token, trace) => {
|
|
818
|
+
const baseUrl = settings.baseUrl;
|
|
819
|
+
const codigoUnico = settings.codigoUnico;
|
|
820
|
+
trace.call(`borrar id=${id}`);
|
|
821
|
+
try {
|
|
822
|
+
const response = await redebanDebugCall("redeban.erase", { id }, () => {
|
|
823
|
+
return client.erase({
|
|
824
|
+
baseUrl,
|
|
825
|
+
codigoUnico,
|
|
826
|
+
id,
|
|
827
|
+
token
|
|
828
|
+
});
|
|
829
|
+
});
|
|
830
|
+
trace.result(`borrar ${response.code}`);
|
|
831
|
+
} catch {
|
|
832
|
+
trace.result("borrar error");
|
|
833
|
+
}
|
|
834
|
+
};
|
|
835
|
+
const noteFinancial = (trace, message) => {
|
|
836
|
+
const financial = parseFinancial(message);
|
|
837
|
+
const code = `code=${financial.responseCode}`;
|
|
838
|
+
const total = `total=${financial.total}`;
|
|
839
|
+
const receipt = `receipt=${financial.receipt}`;
|
|
840
|
+
const rrn = `rrn=${financial.rrn}`;
|
|
841
|
+
trace.note(`financial ${code} ${total} ${receipt} ${rrn}`);
|
|
842
|
+
return financial;
|
|
843
|
+
};
|
|
844
|
+
const finishFinancial = (ctx, amount, snapshot, message, trace) => {
|
|
845
|
+
const financial = noteFinancial(trace, message);
|
|
846
|
+
const next = {
|
|
847
|
+
...snapshot,
|
|
848
|
+
receipt: financial.receipt || snapshot.receipt
|
|
849
|
+
};
|
|
850
|
+
const metadata = metadataOf(ctx.attemptReference, next, financial);
|
|
851
|
+
if (!isApprovedCode(financial.responseCode)) {
|
|
852
|
+
const error = toPaymentError(redebanPinPadErrors, {
|
|
853
|
+
code: financial.responseCode,
|
|
854
|
+
message: "Card declined",
|
|
855
|
+
attempt: ctx.attempt,
|
|
856
|
+
fallback: "CARD_DECLINED"
|
|
857
|
+
});
|
|
858
|
+
return {
|
|
859
|
+
status: "FAILED",
|
|
860
|
+
amount,
|
|
861
|
+
metadata: {
|
|
862
|
+
...metadata,
|
|
863
|
+
error
|
|
864
|
+
},
|
|
865
|
+
error
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
const approved = integerField(financial.total);
|
|
869
|
+
if (approved !== amount) {
|
|
870
|
+
trace.note(`amount order=${amount} approved=${approved}`);
|
|
871
|
+
const error = createPaymentError("INVALID_REQUEST", {
|
|
872
|
+
message: "Approved amount does not match the order",
|
|
873
|
+
attempt: ctx.attempt
|
|
874
|
+
});
|
|
875
|
+
return {
|
|
876
|
+
status: "FAILED",
|
|
877
|
+
amount,
|
|
878
|
+
metadata: {
|
|
879
|
+
...metadata,
|
|
880
|
+
error,
|
|
881
|
+
needs_review: true
|
|
882
|
+
},
|
|
883
|
+
error
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
const payment = metadata.payment;
|
|
887
|
+
return {
|
|
888
|
+
status: "PAID",
|
|
889
|
+
amount,
|
|
890
|
+
metadata: {
|
|
891
|
+
...metadata,
|
|
892
|
+
payment
|
|
893
|
+
}
|
|
894
|
+
};
|
|
895
|
+
};
|
|
896
|
+
const finishPoll = async (ctx, client, settings, amount, snapshot, polled, trace) => {
|
|
897
|
+
if (polled.kind === "cancelled") {
|
|
898
|
+
await erase(client, settings, snapshot.id, polled.token, trace);
|
|
899
|
+
const error = createPaymentError("CANCELLED_BY_USER", { attempt: ctx.attempt });
|
|
900
|
+
return {
|
|
901
|
+
...withError(ctx, amount, snapshot, error),
|
|
902
|
+
status: "CANCELLED"
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
if (polled.kind === "timeout") {
|
|
906
|
+
const started = polled.started ? "yes" : "no";
|
|
907
|
+
trace.note(`timeout started=${started} last=${polled.last}`);
|
|
908
|
+
if (!polled.started) await erase(client, settings, snapshot.id, polled.token, trace);
|
|
909
|
+
const error = createPaymentError(polled.started ? "AMBIGUOUS" : "TIMEOUT", { attempt: ctx.attempt });
|
|
910
|
+
const result = withError(ctx, amount, snapshot, error, polled.started);
|
|
911
|
+
const provider = {
|
|
912
|
+
...result.metadata.provider,
|
|
913
|
+
last_code: polled.last
|
|
914
|
+
};
|
|
915
|
+
const metadata = {
|
|
916
|
+
...result.metadata,
|
|
917
|
+
provider
|
|
918
|
+
};
|
|
919
|
+
return {
|
|
920
|
+
...result,
|
|
921
|
+
metadata
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
if (polled.kind === "technical") {
|
|
925
|
+
const error = technicalError(ctx, polled.response);
|
|
926
|
+
return withError(ctx, amount, snapshot, error);
|
|
927
|
+
}
|
|
928
|
+
return finishFinancial(ctx, amount, snapshot, polled.message, trace);
|
|
929
|
+
};
|
|
930
|
+
const redebanPinPadPayments = (options = {}) => {
|
|
931
|
+
setRedebanLogs(options.debug === true);
|
|
932
|
+
const traces = options.traces !== false;
|
|
933
|
+
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
934
|
+
const client = options.client ?? createRedebanClient();
|
|
935
|
+
const clock = options.clock ?? defaultPollClock();
|
|
936
|
+
const refresh = async (settings, ctx, trace) => {
|
|
937
|
+
const issued = await issueToken(client, settings, ctx, trace);
|
|
938
|
+
if (!issued.ok) throw new PaymentError(issued.error);
|
|
939
|
+
return issued.token;
|
|
940
|
+
};
|
|
941
|
+
const poll = (ctx, settings, id, token, trace) => {
|
|
942
|
+
const signal = ctx.signal;
|
|
943
|
+
const refreshToken = (record) => {
|
|
944
|
+
return refresh(settings, ctx, record ? trace : void 0);
|
|
945
|
+
};
|
|
946
|
+
return pollRespuesta({
|
|
947
|
+
client,
|
|
948
|
+
settings,
|
|
949
|
+
id,
|
|
950
|
+
token,
|
|
951
|
+
signal,
|
|
952
|
+
clock,
|
|
953
|
+
refreshToken,
|
|
954
|
+
trace
|
|
955
|
+
});
|
|
956
|
+
};
|
|
957
|
+
const runLogged = async (scope, ctx, amount, snapshot, trace, kind, opened, wasPaid, run) => {
|
|
958
|
+
let thrown;
|
|
959
|
+
let result;
|
|
960
|
+
try {
|
|
961
|
+
result = await run();
|
|
962
|
+
} catch (error) {
|
|
963
|
+
thrown = error;
|
|
964
|
+
trace.result("error");
|
|
965
|
+
if (isPaymentError(error)) result = withError(ctx, amount, snapshot, error.error);
|
|
966
|
+
else {
|
|
967
|
+
const network = createPaymentError("NETWORK_ERROR", {
|
|
968
|
+
message: error instanceof Error ? error.message : "Network error",
|
|
969
|
+
attempt: ctx.attempt
|
|
970
|
+
});
|
|
971
|
+
result = withError(ctx, amount, snapshot, network);
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
const code = result.error?.code ?? null;
|
|
975
|
+
redebanDebug(scope, "outcome", {
|
|
976
|
+
status: result.status,
|
|
977
|
+
code
|
|
978
|
+
});
|
|
979
|
+
reportOperation({
|
|
980
|
+
kind,
|
|
981
|
+
traces,
|
|
982
|
+
trace,
|
|
983
|
+
result,
|
|
984
|
+
opened: opened(),
|
|
985
|
+
wasPaid,
|
|
986
|
+
thrown
|
|
987
|
+
});
|
|
988
|
+
return result;
|
|
989
|
+
};
|
|
990
|
+
const readSettings = (ctx, trace) => {
|
|
991
|
+
const read = readRedebanSettings(ctx.config, ctx.deviceSettings);
|
|
992
|
+
if (!read.ok) {
|
|
993
|
+
const message = read.missing.join(", ");
|
|
994
|
+
trace.note(`missing ${message}`);
|
|
995
|
+
redebanDebug("redeban.settings", "missing", { missing: read.missing });
|
|
996
|
+
return {
|
|
997
|
+
ok: false,
|
|
998
|
+
error: createPaymentError("TERMINAL_NOT_CONFIGURED", {
|
|
999
|
+
message,
|
|
1000
|
+
attempt: ctx.attempt
|
|
1001
|
+
})
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
redebanDebug("redeban.settings", "read", loggedSettings(read.settings));
|
|
1005
|
+
return {
|
|
1006
|
+
ok: true,
|
|
1007
|
+
settings: read.settings
|
|
1008
|
+
};
|
|
1009
|
+
};
|
|
1010
|
+
const start = (ctx) => {
|
|
1011
|
+
const amount = Math.round(ctx.amount.value);
|
|
1012
|
+
const trace = createRedebanTrace(now);
|
|
1013
|
+
const state = { opened: false };
|
|
1014
|
+
trace.note(`reference=${ctx.attemptReference}`);
|
|
1015
|
+
return runLogged("redeban.start", ctx, amount, null, trace, "charge", () => state.opened, false, async () => {
|
|
1016
|
+
const read = readSettings(ctx, trace);
|
|
1017
|
+
if (!read.ok) return withError(ctx, amount, null, read.error);
|
|
1018
|
+
const { settings } = read;
|
|
1019
|
+
ctx.ui.message({
|
|
1020
|
+
text: "Present the card on the pinpad",
|
|
1021
|
+
kind: "waiting"
|
|
1022
|
+
});
|
|
1023
|
+
const issued = await issueToken(client, settings, ctx, trace);
|
|
1024
|
+
if (!issued.ok) return withError(ctx, amount, null, issued.error);
|
|
1025
|
+
const id = transactionId(ctx.attemptReference);
|
|
1026
|
+
const iva = ivaOf(ctx.taxes);
|
|
1027
|
+
const invoice = id;
|
|
1028
|
+
const cashierCode = settings.cashierCode;
|
|
1029
|
+
const terminalId = settings.terminalId;
|
|
1030
|
+
const registerNumber = settings.registerNumber;
|
|
1031
|
+
const codigoUnico = settings.codigoUnico;
|
|
1032
|
+
const frame = {
|
|
1033
|
+
amount,
|
|
1034
|
+
iva,
|
|
1035
|
+
invoice,
|
|
1036
|
+
cashierCode,
|
|
1037
|
+
terminalId,
|
|
1038
|
+
registerNumber,
|
|
1039
|
+
codigoUnico
|
|
1040
|
+
};
|
|
1041
|
+
const snapshot = {
|
|
1042
|
+
id,
|
|
1043
|
+
amount,
|
|
1044
|
+
iva,
|
|
1045
|
+
total: amount,
|
|
1046
|
+
receipt: id
|
|
1047
|
+
};
|
|
1048
|
+
const reference = ctx.attemptReference;
|
|
1049
|
+
const sale = `id=${id} amount=${amount}`;
|
|
1050
|
+
const who = `reference=${reference} terminal=${terminalId}`;
|
|
1051
|
+
trace.call(`enviar ${sale} ${who}`);
|
|
1052
|
+
const sent = await redebanDebugCall("redeban.send", { id }, () => {
|
|
1053
|
+
return client.send({
|
|
1054
|
+
baseUrl: settings.baseUrl,
|
|
1055
|
+
codigoUnico,
|
|
1056
|
+
id,
|
|
1057
|
+
data: saleFrame(frame),
|
|
1058
|
+
token: issued.token
|
|
1059
|
+
});
|
|
1060
|
+
});
|
|
1061
|
+
trace.result(`enviar ${sent.code}`);
|
|
1062
|
+
if (sent.code !== "00") return withError(ctx, amount, snapshot, technicalError(ctx, sent));
|
|
1063
|
+
state.opened = true;
|
|
1064
|
+
const polled = await poll(ctx, settings, id, issued.token, trace);
|
|
1065
|
+
return finishPoll(ctx, client, settings, amount, snapshot, polled, trace);
|
|
1066
|
+
});
|
|
1067
|
+
};
|
|
1068
|
+
const query = (ctx, result) => {
|
|
1069
|
+
const amount = result.amount;
|
|
1070
|
+
const snapshot = snapshotOf(result);
|
|
1071
|
+
const trace = createRedebanTrace(now);
|
|
1072
|
+
trace.note(`reference=${ctx.attemptReference}`);
|
|
1073
|
+
return runLogged("redeban.query", ctx, amount, snapshot, trace, "query", () => false, false, async () => {
|
|
1074
|
+
if (!snapshot) {
|
|
1075
|
+
trace.note("missing id");
|
|
1076
|
+
const error = createPaymentError("INVALID_REQUEST", {
|
|
1077
|
+
attempt: ctx.attempt,
|
|
1078
|
+
message: "Missing transaction id"
|
|
1079
|
+
});
|
|
1080
|
+
return withError(ctx, amount, null, error);
|
|
1081
|
+
}
|
|
1082
|
+
const read = readSettings(ctx, trace);
|
|
1083
|
+
if (!read.ok) return withError(ctx, amount, snapshot, read.error);
|
|
1084
|
+
const token = await refresh(read.settings, ctx, trace);
|
|
1085
|
+
const id = snapshot.id;
|
|
1086
|
+
trace.call(`query id=${id}`);
|
|
1087
|
+
const response = await redebanDebugCall("redeban.query", { id }, () => {
|
|
1088
|
+
return client.response({
|
|
1089
|
+
baseUrl: read.settings.baseUrl,
|
|
1090
|
+
codigoUnico: read.settings.codigoUnico,
|
|
1091
|
+
id,
|
|
1092
|
+
token
|
|
1093
|
+
});
|
|
1094
|
+
});
|
|
1095
|
+
trace.result(`respuesta ${response.code}`);
|
|
1096
|
+
const moving = response.code === "00" || response.code === "01" || response.code === "02";
|
|
1097
|
+
const financial = isFinancialMessage(response.message);
|
|
1098
|
+
if (response.code === "00" && financial) return finishFinancial(ctx, amount, snapshot, response.message, trace);
|
|
1099
|
+
if (moving) {
|
|
1100
|
+
trace.note(`pending ${response.code}`);
|
|
1101
|
+
const error = createPaymentError("AMBIGUOUS", { attempt: ctx.attempt });
|
|
1102
|
+
return withError(ctx, amount, snapshot, error, true);
|
|
1103
|
+
}
|
|
1104
|
+
return withError(ctx, amount, snapshot, technicalError(ctx, response));
|
|
1105
|
+
});
|
|
1106
|
+
};
|
|
1107
|
+
const cancel = (ctx, result) => {
|
|
1108
|
+
const amount = result.amount;
|
|
1109
|
+
const snapshot = snapshotOf(result);
|
|
1110
|
+
const wasPaid = result.status === "PAID";
|
|
1111
|
+
const trace = createRedebanTrace(now);
|
|
1112
|
+
trace.note(`reference=${ctx.attemptReference}`);
|
|
1113
|
+
return runLogged("redeban.cancel", ctx, amount, snapshot, trace, "void", () => false, wasPaid, async () => {
|
|
1114
|
+
const read = readSettings(ctx, trace);
|
|
1115
|
+
if (!read.ok || !snapshot) {
|
|
1116
|
+
trace.note("reversal not allowed");
|
|
1117
|
+
const error = createPaymentError("REVERSAL_NOT_ALLOWED", { attempt: ctx.attempt });
|
|
1118
|
+
return withError(ctx, amount, snapshot, error);
|
|
1119
|
+
}
|
|
1120
|
+
const { settings } = read;
|
|
1121
|
+
if (result.status !== "PAID") {
|
|
1122
|
+
if (result.metadata.provider?.last_code === "02") {
|
|
1123
|
+
trace.note("last=02");
|
|
1124
|
+
const error = createPaymentError("AMBIGUOUS", { attempt: ctx.attempt });
|
|
1125
|
+
return withError(ctx, amount, snapshot, error, true);
|
|
1126
|
+
}
|
|
1127
|
+
const token = await refresh(settings, ctx, trace);
|
|
1128
|
+
await erase(client, settings, snapshot.id, token, trace);
|
|
1129
|
+
const error = createPaymentError("CANCELLED_BY_USER", { attempt: ctx.attempt });
|
|
1130
|
+
return {
|
|
1131
|
+
...withError(ctx, amount, snapshot, error),
|
|
1132
|
+
status: "CANCELLED"
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
const id = transactionId(`${snapshot.id}V`);
|
|
1136
|
+
const receipt = snapshot.receipt;
|
|
1137
|
+
const voidAmount = snapshot.amount;
|
|
1138
|
+
const frame = {
|
|
1139
|
+
amount: snapshot.amount,
|
|
1140
|
+
iva: snapshot.iva,
|
|
1141
|
+
invoice: id,
|
|
1142
|
+
cashierCode: settings.cashierCode,
|
|
1143
|
+
terminalId: settings.terminalId,
|
|
1144
|
+
registerNumber: settings.registerNumber,
|
|
1145
|
+
codigoUnico: settings.codigoUnico,
|
|
1146
|
+
receipt: snapshot.receipt,
|
|
1147
|
+
supervisorPassword: settings.supervisorPassword
|
|
1148
|
+
};
|
|
1149
|
+
const token = await refresh(settings, ctx, trace);
|
|
1150
|
+
const voidLine = `id=${id} receipt=${receipt} amount=${voidAmount}`;
|
|
1151
|
+
trace.call(`void ${voidLine}`);
|
|
1152
|
+
const sent = await redebanDebugCall("redeban.send", { id }, () => {
|
|
1153
|
+
return client.send({
|
|
1154
|
+
baseUrl: settings.baseUrl,
|
|
1155
|
+
codigoUnico: settings.codigoUnico,
|
|
1156
|
+
id,
|
|
1157
|
+
data: voidFrame(frame),
|
|
1158
|
+
token
|
|
1159
|
+
});
|
|
1160
|
+
});
|
|
1161
|
+
trace.result(`enviar ${sent.code}`);
|
|
1162
|
+
if (sent.code !== "00") return withError(ctx, amount, snapshot, technicalError(ctx, sent));
|
|
1163
|
+
const polled = await poll(ctx, settings, id, token, trace);
|
|
1164
|
+
const done = await finishPoll(ctx, client, settings, amount, snapshot, polled, trace);
|
|
1165
|
+
if (done.status !== "PAID") return done;
|
|
1166
|
+
return {
|
|
1167
|
+
...done,
|
|
1168
|
+
status: "CANCELLED"
|
|
1169
|
+
};
|
|
1170
|
+
});
|
|
1171
|
+
};
|
|
1172
|
+
return {
|
|
1173
|
+
provider: "redeban",
|
|
1174
|
+
method: "PIN_PAD",
|
|
1175
|
+
deviceFields: redebanPinPadDeviceFields,
|
|
1176
|
+
errors: redebanPinPadErrors,
|
|
1177
|
+
capabilities: {
|
|
1178
|
+
deferredCapture: false,
|
|
1179
|
+
inPerson: true
|
|
1180
|
+
},
|
|
1181
|
+
policy: { timeout_ms: REDEBAN_TIMEOUT_MS },
|
|
1182
|
+
reversalFor: () => "cancel",
|
|
1183
|
+
start,
|
|
1184
|
+
query,
|
|
1185
|
+
cancel
|
|
1186
|
+
};
|
|
1187
|
+
};
|
|
1188
|
+
//#endregion
|
|
1189
|
+
export { createRedebanClient, redebanPinPadDeviceFields, redebanPinPadErrors, redebanPinPadPayments };
|
package/package.json
CHANGED
|
@@ -1,6 +1,61 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fire-spark/payments-redeban-pinpad",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.13.0",
|
|
4
|
+
"description": "Fire Spark Redeban pinpad adapter. SIPConnector V1.5 sale, full void, polling and delete.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"files": [
|
|
8
|
+
"dist"
|
|
9
|
+
],
|
|
10
|
+
"main": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js",
|
|
16
|
+
"default": "./dist/index.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@fire-spark/core": "^0.13.0",
|
|
21
|
+
"@fire-spark/payments": "^0.13.0",
|
|
22
|
+
"@fire-spark/types": "^0.13.0"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"tsdown": "^0.22.14",
|
|
26
|
+
"typescript": "^7.0.2",
|
|
27
|
+
"vitest": "^2.1.8"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"tsdown": {
|
|
33
|
+
"entry": [
|
|
34
|
+
"src/index.ts"
|
|
35
|
+
],
|
|
36
|
+
"format": [
|
|
37
|
+
"esm"
|
|
38
|
+
],
|
|
39
|
+
"platform": "neutral",
|
|
40
|
+
"dts": {
|
|
41
|
+
"tsconfig": "tsconfig.build.json"
|
|
42
|
+
},
|
|
43
|
+
"clean": true,
|
|
44
|
+
"deps": {
|
|
45
|
+
"neverBundle": [
|
|
46
|
+
"@fire-spark/core",
|
|
47
|
+
"@fire-spark/payments",
|
|
48
|
+
"@fire-spark/types"
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"build": "tsdown",
|
|
54
|
+
"dev": "tsdown --watch",
|
|
55
|
+
"check-types": "tsc --noEmit",
|
|
56
|
+
"test:utils": "vitest run",
|
|
57
|
+
"lint": "oxlint ./src --max-warnings=0",
|
|
58
|
+
"format": "oxfmt ./src",
|
|
59
|
+
"check-format": "oxfmt --check ./src"
|
|
60
|
+
}
|
|
6
61
|
}
|
package/README.md
DELETED