@inno-isw/react-native-rtp 0.1.0 → 0.3.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 +57 -23
- package/lib/module/client.js +218 -17
- package/lib/module/client.js.map +1 -1
- package/lib/module/constants.js +5 -0
- package/lib/module/constants.js.map +1 -1
- package/lib/module/index.js +2 -2
- package/lib/module/index.js.map +1 -1
- package/lib/module/mock/makeRtpPayload.js +3 -0
- package/lib/module/mock/makeRtpPayload.js.map +1 -1
- package/lib/module/mock/mockTransport.js +9 -2
- package/lib/module/mock/mockTransport.js.map +1 -1
- package/lib/module/react/RtpProvider.js +13 -3
- package/lib/module/react/RtpProvider.js.map +1 -1
- package/lib/module/validate.js +42 -0
- package/lib/module/validate.js.map +1 -1
- package/lib/typescript/src/client.d.ts +12 -3
- package/lib/typescript/src/client.d.ts.map +1 -1
- package/lib/typescript/src/constants.d.ts +5 -0
- package/lib/typescript/src/constants.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +2 -2
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/mock/makeRtpPayload.d.ts.map +1 -1
- package/lib/typescript/src/mock/mockTransport.d.ts +7 -1
- package/lib/typescript/src/mock/mockTransport.d.ts.map +1 -1
- package/lib/typescript/src/react/RtpProvider.d.ts +6 -2
- package/lib/typescript/src/react/RtpProvider.d.ts.map +1 -1
- package/lib/typescript/src/types.d.ts +22 -5
- package/lib/typescript/src/types.d.ts.map +1 -1
- package/lib/typescript/src/validate.d.ts +11 -0
- package/lib/typescript/src/validate.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +227 -20
- package/src/constants.ts +5 -0
- package/src/index.tsx +3 -1
- package/src/mock/makeRtpPayload.ts +3 -0
- package/src/mock/mockTransport.ts +18 -2
- package/src/react/RtpProvider.tsx +40 -5
- package/src/types.ts +22 -6
- package/src/validate.ts +48 -0
package/src/client.ts
CHANGED
|
@@ -5,12 +5,15 @@ import {
|
|
|
5
5
|
findValidationErrors,
|
|
6
6
|
isExpired,
|
|
7
7
|
isRtpPayload,
|
|
8
|
+
mergeRequest,
|
|
9
|
+
toRequestPatch,
|
|
8
10
|
toRtpRequest,
|
|
9
11
|
} from './validate';
|
|
10
12
|
import type {
|
|
11
13
|
RtpClient,
|
|
12
14
|
RtpClientOptions,
|
|
13
15
|
RtpCredential,
|
|
16
|
+
RtpPayload,
|
|
14
17
|
RtpReceiveOutcome,
|
|
15
18
|
RtpRequest,
|
|
16
19
|
RtpResult,
|
|
@@ -21,36 +24,118 @@ interface Entry {
|
|
|
21
24
|
status: RtpStatus;
|
|
22
25
|
}
|
|
23
26
|
|
|
27
|
+
/** Statuses after which a request is finished with. */
|
|
28
|
+
const TERMINAL_STATUSES: ReadonlySet<RtpStatus> = new Set([
|
|
29
|
+
RTP_STATUS.ACCEPTED,
|
|
30
|
+
RTP_STATUS.DECLINED,
|
|
31
|
+
RTP_STATUS.EXPIRED,
|
|
32
|
+
RTP_STATUS.FAILED,
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Map a backend status string onto the normalized SDK status.
|
|
37
|
+
*
|
|
38
|
+
* Unknown values are deliberately **not** treated as success — they map to
|
|
39
|
+
* `undefined` so the caller can decide, rather than silently reporting an
|
|
40
|
+
* approved payment for a status the client does not understand.
|
|
41
|
+
*/
|
|
42
|
+
export function normalizeBackendStatus(raw: unknown): RtpStatus | undefined {
|
|
43
|
+
if (typeof raw !== 'string') {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
switch (raw.toUpperCase()) {
|
|
47
|
+
case 'ACCEPTED':
|
|
48
|
+
case 'APPROVED':
|
|
49
|
+
case 'SUCCESS':
|
|
50
|
+
case 'SUCCESSFUL':
|
|
51
|
+
return RTP_STATUS.ACCEPTED;
|
|
52
|
+
case 'DECLINED':
|
|
53
|
+
case 'USER_DECLINED':
|
|
54
|
+
return RTP_STATUS.DECLINED;
|
|
55
|
+
case 'EXPIRED':
|
|
56
|
+
return RTP_STATUS.EXPIRED;
|
|
57
|
+
case 'PENDING':
|
|
58
|
+
case 'PROCESSING':
|
|
59
|
+
return RTP_STATUS.PENDING;
|
|
60
|
+
case 'FAILED':
|
|
61
|
+
case 'REJECTED':
|
|
62
|
+
return RTP_STATUS.FAILED;
|
|
63
|
+
default:
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
24
68
|
/**
|
|
25
69
|
* Create a portable Request-to-Pay client.
|
|
26
70
|
*
|
|
27
71
|
* The client owns validation, de-duplication, expiry, status computation and
|
|
28
|
-
* the accept/decline/
|
|
29
|
-
* Every external effect is injected so the same contract can be
|
|
30
|
-
* on other platforms.
|
|
72
|
+
* the accept/decline/retrieve calls. It owns no UI, no navigation and no
|
|
73
|
+
* crypto. Every external effect is injected so the same contract can be
|
|
74
|
+
* re-implemented on other platforms.
|
|
31
75
|
*/
|
|
32
76
|
export function createRtpClient(options: RtpClientOptions): RtpClient {
|
|
33
77
|
const {
|
|
34
78
|
transport,
|
|
35
|
-
getAuthHeaders = async () => ({}),
|
|
36
79
|
getCurrentUser = () => null,
|
|
37
80
|
now = defaultNow,
|
|
38
81
|
logger = noopLogger,
|
|
39
82
|
basePath = '/rtp',
|
|
40
83
|
} = options;
|
|
41
84
|
|
|
42
|
-
if (!transport) {
|
|
43
|
-
throw new Error('[react-native-rtp] `transport` is required');
|
|
44
|
-
}
|
|
45
|
-
|
|
46
85
|
const emitter = createEmitter();
|
|
47
86
|
const entries = new Map<string, Entry>();
|
|
48
87
|
|
|
88
|
+
// --- expiry scheduling --------------------------------------------------
|
|
89
|
+
// Expiry is not only evaluated lazily: an inactive timer fires at `expiresAt`
|
|
90
|
+
// so a request cannot remain actionable after it has lapsed (INDV-3807/3808).
|
|
91
|
+
let expiryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
92
|
+
let expiryTimerId: string | undefined;
|
|
93
|
+
|
|
94
|
+
function clearExpiryTimer(): void {
|
|
95
|
+
if (expiryTimer !== undefined) {
|
|
96
|
+
clearTimeout(expiryTimer);
|
|
97
|
+
}
|
|
98
|
+
expiryTimer = undefined;
|
|
99
|
+
expiryTimerId = undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function scheduleExpiry(entry: Entry): void {
|
|
103
|
+
clearExpiryTimer();
|
|
104
|
+
|
|
105
|
+
const expiry = new Date(entry.request.expiresAt).getTime();
|
|
106
|
+
if (Number.isNaN(expiry)) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const id = entry.request.id;
|
|
111
|
+
expiryTimerId = id;
|
|
112
|
+
expiryTimer = setTimeout(
|
|
113
|
+
() => {
|
|
114
|
+
const current = entries.get(id);
|
|
115
|
+
if (!current || TERMINAL_STATUSES.has(current.status)) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
logger('[react-native-rtp] request expired:', id);
|
|
119
|
+
emitResult({ id, status: RTP_STATUS.EXPIRED });
|
|
120
|
+
},
|
|
121
|
+
Math.max(0, expiry - now())
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// Never keep a Node process (e.g. Jest) alive for a pending expiry.
|
|
125
|
+
const handle = expiryTimer as unknown as { unref?: () => void };
|
|
126
|
+
if (typeof handle?.unref === 'function') {
|
|
127
|
+
handle.unref();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
49
131
|
function setStatus(id: string, status: RtpStatus): void {
|
|
50
132
|
const entry = entries.get(id);
|
|
51
133
|
if (entry) {
|
|
52
134
|
entry.status = status;
|
|
53
135
|
}
|
|
136
|
+
if (TERMINAL_STATUSES.has(status) && expiryTimerId === id) {
|
|
137
|
+
clearExpiryTimer();
|
|
138
|
+
}
|
|
54
139
|
emitter.emit(RTP_EVENT.STATE, { id, status });
|
|
55
140
|
}
|
|
56
141
|
|
|
@@ -89,7 +174,8 @@ export function createRtpClient(options: RtpClientOptions): RtpClient {
|
|
|
89
174
|
}
|
|
90
175
|
|
|
91
176
|
const request = toRtpRequest(payload);
|
|
92
|
-
|
|
177
|
+
const entry: Entry = { request, status: RTP_STATUS.RECEIVED };
|
|
178
|
+
entries.set(id, entry);
|
|
93
179
|
|
|
94
180
|
if (isExpired(request, now())) {
|
|
95
181
|
const result = emitResult({ id, status: RTP_STATUS.EXPIRED });
|
|
@@ -100,6 +186,7 @@ export function createRtpClient(options: RtpClientOptions): RtpClient {
|
|
|
100
186
|
|
|
101
187
|
if (!currentUser) {
|
|
102
188
|
setStatus(id, RTP_STATUS.AWAITING_AUTH);
|
|
189
|
+
scheduleExpiry(entry);
|
|
103
190
|
return { handled: true, request, status: RTP_STATUS.AWAITING_AUTH };
|
|
104
191
|
}
|
|
105
192
|
|
|
@@ -114,10 +201,95 @@ export function createRtpClient(options: RtpClientOptions): RtpClient {
|
|
|
114
201
|
}
|
|
115
202
|
|
|
116
203
|
setStatus(id, RTP_STATUS.READY);
|
|
204
|
+
scheduleExpiry(entry);
|
|
117
205
|
emitter.emit(RTP_EVENT.REQUEST, request);
|
|
118
206
|
return { handled: true, request, status: RTP_STATUS.READY };
|
|
119
207
|
}
|
|
120
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Fetch (or refresh) authorization details for a request from the backend,
|
|
211
|
+
* and reconcile the backend status. This is the "retrieve details" step the
|
|
212
|
+
* authorization page needs before it can be trusted (INDV-3804).
|
|
213
|
+
*/
|
|
214
|
+
async function retrieve(id: string): Promise<RtpResult> {
|
|
215
|
+
const entry = entries.get(id);
|
|
216
|
+
if (!entry) {
|
|
217
|
+
return fail(id, RTP_ERROR.UNKNOWN);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (isExpired(entry.request, now())) {
|
|
221
|
+
return emitResult({ id, status: RTP_STATUS.EXPIRED });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (TERMINAL_STATUSES.has(entry.status)) {
|
|
225
|
+
return { id, status: entry.status };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Transport-free mode: there is no backend to fetch from, so the details
|
|
229
|
+
// already held from the payload are authoritative. This is the shape the
|
|
230
|
+
// client runs in when the host settles the payment through its own core
|
|
231
|
+
// banking rather than an RTP backend.
|
|
232
|
+
if (!transport) {
|
|
233
|
+
setStatus(id, RTP_STATUS.READY);
|
|
234
|
+
scheduleExpiry(entry);
|
|
235
|
+
emitter.emit(RTP_EVENT.REQUEST, entry.request);
|
|
236
|
+
return { id, status: RTP_STATUS.READY };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
setStatus(id, RTP_STATUS.RETRIEVING);
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
const headers = {};
|
|
243
|
+
const res = await transport.get(`${basePath}/${id}`, headers);
|
|
244
|
+
const data = (res?.data ?? {}) as Record<string, unknown>;
|
|
245
|
+
|
|
246
|
+
const mapped = normalizeBackendStatus(data.status);
|
|
247
|
+
|
|
248
|
+
if (
|
|
249
|
+
mapped === RTP_STATUS.EXPIRED ||
|
|
250
|
+
mapped === RTP_STATUS.DECLINED ||
|
|
251
|
+
mapped === RTP_STATUS.FAILED
|
|
252
|
+
) {
|
|
253
|
+
return emitResult({
|
|
254
|
+
id,
|
|
255
|
+
status: mapped,
|
|
256
|
+
data,
|
|
257
|
+
error:
|
|
258
|
+
mapped === RTP_STATUS.FAILED
|
|
259
|
+
? RTP_ERROR.BACKEND_REJECTED
|
|
260
|
+
: undefined,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (mapped === RTP_STATUS.ACCEPTED) {
|
|
265
|
+
return emitResult({ id, status: RTP_STATUS.ACCEPTED, data });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (mapped === RTP_STATUS.PENDING) {
|
|
269
|
+
return emitResult({ id, status: RTP_STATUS.PENDING, data });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Merge any returned details (a full payload, or nested under `request`).
|
|
273
|
+
const incoming = (data.request ?? data) as
|
|
274
|
+
Partial<RtpPayload> | undefined;
|
|
275
|
+
if (incoming && typeof incoming === 'object') {
|
|
276
|
+
entry.request = mergeRequest(entry.request, toRequestPatch(incoming));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (isExpired(entry.request, now())) {
|
|
280
|
+
return emitResult({ id, status: RTP_STATUS.EXPIRED });
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
setStatus(id, RTP_STATUS.READY);
|
|
284
|
+
scheduleExpiry(entry);
|
|
285
|
+
emitter.emit(RTP_EVENT.REQUEST, entry.request);
|
|
286
|
+
return { id, status: RTP_STATUS.READY, data };
|
|
287
|
+
} catch (err) {
|
|
288
|
+
logger('[react-native-rtp] retrieve failed:', err);
|
|
289
|
+
return fail(id, RTP_ERROR.RETRIEVE_FAILED);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
121
293
|
/**
|
|
122
294
|
* Called by the host after a successful login to release any requests that
|
|
123
295
|
* were waiting on the auth gate.
|
|
@@ -149,6 +321,7 @@ export function createRtpClient(options: RtpClientOptions): RtpClient {
|
|
|
149
321
|
}
|
|
150
322
|
|
|
151
323
|
setStatus(id, RTP_STATUS.READY);
|
|
324
|
+
scheduleExpiry(entry);
|
|
152
325
|
emitter.emit(RTP_EVENT.REQUEST, entry.request);
|
|
153
326
|
}
|
|
154
327
|
}
|
|
@@ -169,25 +342,35 @@ export function createRtpClient(options: RtpClientOptions): RtpClient {
|
|
|
169
342
|
|
|
170
343
|
setStatus(id, RTP_STATUS.PROCESSING);
|
|
171
344
|
|
|
345
|
+
// Transport-free mode: acceptance is realised by the host's own payment
|
|
346
|
+
// flow; the client only records the decision locally.
|
|
347
|
+
if (!transport) {
|
|
348
|
+
return emitResult({ id, status: RTP_STATUS.ACCEPTED });
|
|
349
|
+
}
|
|
350
|
+
|
|
172
351
|
try {
|
|
173
|
-
const headers =
|
|
352
|
+
const headers = {};
|
|
174
353
|
const res = await transport.post(
|
|
175
354
|
`${basePath}/${id}/accept`,
|
|
176
355
|
{ credential },
|
|
177
356
|
headers
|
|
178
357
|
);
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
358
|
+
const data = (res?.data ?? {}) as Record<string, unknown>;
|
|
359
|
+
|
|
360
|
+
// If the backend returned a status we recognise, honour it. If it
|
|
361
|
+
// returned no status at all, the accept call itself succeeded. If it
|
|
362
|
+
// returned a status we do NOT understand, fail closed rather than
|
|
363
|
+
// showing the customer an approved payment we cannot vouch for.
|
|
364
|
+
const hasStatus = data.status !== undefined && data.status !== null;
|
|
365
|
+
const mapped = normalizeBackendStatus(data.status);
|
|
366
|
+
const status: RtpStatus = hasStatus
|
|
367
|
+
? (mapped ?? RTP_STATUS.FAILED)
|
|
368
|
+
: RTP_STATUS.ACCEPTED;
|
|
186
369
|
|
|
187
370
|
return emitResult({
|
|
188
371
|
id,
|
|
189
372
|
status,
|
|
190
|
-
data
|
|
373
|
+
data,
|
|
191
374
|
error:
|
|
192
375
|
status === RTP_STATUS.FAILED ? RTP_ERROR.BACKEND_REJECTED : undefined,
|
|
193
376
|
});
|
|
@@ -209,14 +392,34 @@ export function createRtpClient(options: RtpClientOptions): RtpClient {
|
|
|
209
392
|
|
|
210
393
|
setStatus(id, RTP_STATUS.PROCESSING);
|
|
211
394
|
|
|
395
|
+
// Transport-free mode: the decline is recorded locally; a host with a
|
|
396
|
+
// backend still posts it through `transport`.
|
|
397
|
+
if (!transport) {
|
|
398
|
+
return emitResult({ id, status: RTP_STATUS.DECLINED });
|
|
399
|
+
}
|
|
400
|
+
|
|
212
401
|
try {
|
|
213
|
-
const headers =
|
|
402
|
+
const headers = {};
|
|
214
403
|
const res = await transport.post(
|
|
215
404
|
`${basePath}/${id}/decline`,
|
|
216
405
|
{ reason },
|
|
217
406
|
headers
|
|
218
407
|
);
|
|
219
|
-
|
|
408
|
+
const data = (res?.data ?? {}) as Record<string, unknown>;
|
|
409
|
+
|
|
410
|
+
const mapped = normalizeBackendStatus(data.status);
|
|
411
|
+
const status: RtpStatus =
|
|
412
|
+
mapped === RTP_STATUS.EXPIRED || mapped === RTP_STATUS.FAILED
|
|
413
|
+
? mapped
|
|
414
|
+
: RTP_STATUS.DECLINED;
|
|
415
|
+
|
|
416
|
+
return emitResult({
|
|
417
|
+
id,
|
|
418
|
+
status,
|
|
419
|
+
data,
|
|
420
|
+
error:
|
|
421
|
+
status === RTP_STATUS.FAILED ? RTP_ERROR.BACKEND_REJECTED : undefined,
|
|
422
|
+
});
|
|
220
423
|
} catch (err) {
|
|
221
424
|
logger('[react-native-rtp] decline failed:', err);
|
|
222
425
|
return fail(id, RTP_ERROR.NETWORK_ERROR);
|
|
@@ -226,12 +429,16 @@ export function createRtpClient(options: RtpClientOptions): RtpClient {
|
|
|
226
429
|
return {
|
|
227
430
|
isRtp: isRtpPayload,
|
|
228
431
|
receive,
|
|
432
|
+
retrieve,
|
|
229
433
|
resumeAuth,
|
|
230
434
|
accept,
|
|
231
435
|
decline,
|
|
232
436
|
getStatus: (id: string) => entries.get(id)?.status,
|
|
233
437
|
getRequest: (id: string) => entries.get(id)?.request,
|
|
234
438
|
acknowledge: (id: string) => {
|
|
439
|
+
if (expiryTimerId === id) {
|
|
440
|
+
clearExpiryTimer();
|
|
441
|
+
}
|
|
235
442
|
entries.delete(id);
|
|
236
443
|
},
|
|
237
444
|
on: emitter.on,
|
package/src/constants.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
export const RTP_STATUS = {
|
|
8
8
|
/** Payload arrived and passed validation, but is not yet actionable. */
|
|
9
9
|
RECEIVED: 'RECEIVED',
|
|
10
|
+
/** Authorization details are being fetched from the backend. */
|
|
11
|
+
RETRIEVING: 'RETRIEVING',
|
|
10
12
|
/** Expired before it could be actioned. */
|
|
11
13
|
EXPIRED: 'EXPIRED',
|
|
12
14
|
/** Valid, but the user is not authenticated yet (login gate). */
|
|
@@ -15,6 +17,8 @@ export const RTP_STATUS = {
|
|
|
15
17
|
READY: 'READY',
|
|
16
18
|
/** Accept/decline in flight. */
|
|
17
19
|
PROCESSING: 'PROCESSING',
|
|
20
|
+
/** Backend accepted the action but a final decision is still pending. */
|
|
21
|
+
PENDING: 'PENDING',
|
|
18
22
|
/** Backend confirmed acceptance. */
|
|
19
23
|
ACCEPTED: 'ACCEPTED',
|
|
20
24
|
/** Backend confirmed (or user initiated) a decline. */
|
|
@@ -41,6 +45,7 @@ export type RtpEvent = (typeof RTP_EVENT)[keyof typeof RTP_EVENT];
|
|
|
41
45
|
export const RTP_ERROR = {
|
|
42
46
|
INVALID_PAYLOAD: 'INVALID_PAYLOAD',
|
|
43
47
|
RECIPIENT_MISMATCH: 'RECIPIENT_MISMATCH',
|
|
48
|
+
RETRIEVE_FAILED: 'RETRIEVE_FAILED',
|
|
44
49
|
NETWORK_ERROR: 'NETWORK_ERROR',
|
|
45
50
|
BACKEND_REJECTED: 'BACKEND_REJECTED',
|
|
46
51
|
UNKNOWN: 'UNKNOWN',
|
package/src/index.tsx
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// Core (platform-agnostic)
|
|
2
|
-
export { createRtpClient } from './client';
|
|
2
|
+
export { createRtpClient, normalizeBackendStatus } from './client';
|
|
3
3
|
export { RTP_STATUS, RTP_EVENT, RTP_ERROR } from './constants';
|
|
4
4
|
export type { RtpStatus, RtpEvent, RtpError } from './constants';
|
|
5
5
|
export {
|
|
6
6
|
isRtpPayload,
|
|
7
7
|
findValidationErrors,
|
|
8
8
|
toRtpRequest,
|
|
9
|
+
toRequestPatch,
|
|
10
|
+
mergeRequest,
|
|
9
11
|
isExpired,
|
|
10
12
|
} from './validate';
|
|
11
13
|
export { mapPushToRtp } from './push';
|
|
@@ -41,6 +41,9 @@ export function makeRtpPayload(
|
|
|
41
41
|
currency: 'NGN',
|
|
42
42
|
narration: 'Payment for invoice 1042',
|
|
43
43
|
transactionRef: `TRX-${from}-${counter}`,
|
|
44
|
+
orderNumber: `ORD-${from}-${counter}`,
|
|
45
|
+
deepLink: `quickteller://rtp/${from}-${counter}`,
|
|
46
|
+
actionMessage: 'Review and approve this payment request',
|
|
44
47
|
recipient: 'john.doe@quickteller.com',
|
|
45
48
|
expiresAt: new Date(from + expiresInMs).toISOString(),
|
|
46
49
|
payer: {
|
|
@@ -7,8 +7,14 @@ export interface MockTransportOptions {
|
|
|
7
7
|
failAccept?: boolean;
|
|
8
8
|
/** Simulate a network failure on decline. */
|
|
9
9
|
failDecline?: boolean;
|
|
10
|
+
/** Simulate a network failure on retrieve (details fetch). */
|
|
11
|
+
failRetrieve?: boolean;
|
|
10
12
|
/** Backend status to return for accept. */
|
|
11
|
-
acceptStatus?: 'ACCEPTED' | 'DECLINED' | 'FAILED';
|
|
13
|
+
acceptStatus?: 'ACCEPTED' | 'DECLINED' | 'FAILED' | 'PENDING' | 'EXPIRED';
|
|
14
|
+
/** Backend status to return for retrieve. Unknown values fall through to READY. */
|
|
15
|
+
retrieveStatus?: string;
|
|
16
|
+
/** Request details to return from retrieve (overlaid onto the held request). */
|
|
17
|
+
retrieveRequest?: Record<string, unknown>;
|
|
12
18
|
}
|
|
13
19
|
|
|
14
20
|
export interface RecordedCall {
|
|
@@ -41,7 +47,10 @@ export function createMockTransport(
|
|
|
41
47
|
latencyMs = 250,
|
|
42
48
|
failAccept = false,
|
|
43
49
|
failDecline = false,
|
|
50
|
+
failRetrieve = false,
|
|
44
51
|
acceptStatus = 'ACCEPTED',
|
|
52
|
+
retrieveStatus = 'READY',
|
|
53
|
+
retrieveRequest,
|
|
45
54
|
} = options;
|
|
46
55
|
|
|
47
56
|
const calls: RecordedCall[] = [];
|
|
@@ -86,9 +95,16 @@ export function createMockTransport(
|
|
|
86
95
|
): Promise<RtpHttpResponse<T>> {
|
|
87
96
|
calls.push({ method: 'get', path, headers });
|
|
88
97
|
await delay();
|
|
98
|
+
if (failRetrieve) {
|
|
99
|
+
throw new Error('Mock network failure on retrieve');
|
|
100
|
+
}
|
|
89
101
|
return {
|
|
90
102
|
status: 200,
|
|
91
|
-
data: {
|
|
103
|
+
data: {
|
|
104
|
+
id: extractId(path),
|
|
105
|
+
status: retrieveStatus,
|
|
106
|
+
request: retrieveRequest,
|
|
107
|
+
} as T,
|
|
92
108
|
};
|
|
93
109
|
},
|
|
94
110
|
};
|
|
@@ -7,7 +7,13 @@ import React, {
|
|
|
7
7
|
useRef,
|
|
8
8
|
useState,
|
|
9
9
|
} from 'react';
|
|
10
|
-
import type {
|
|
10
|
+
import type {
|
|
11
|
+
RtpClient,
|
|
12
|
+
RtpCredential,
|
|
13
|
+
RtpReceiveOutcome,
|
|
14
|
+
RtpRequest,
|
|
15
|
+
RtpResult,
|
|
16
|
+
} from '../types';
|
|
11
17
|
import { RTP_STATUS } from '../constants';
|
|
12
18
|
import type { RtpStatus } from '../constants';
|
|
13
19
|
|
|
@@ -46,14 +52,18 @@ export interface RtpContextValue {
|
|
|
46
52
|
status: RtpStatus | null;
|
|
47
53
|
/** True when a request is ready for the user to act on. */
|
|
48
54
|
isReady: boolean;
|
|
55
|
+
/** True while authorization details are being fetched from the backend. */
|
|
56
|
+
isLoading: boolean;
|
|
49
57
|
/** Accept the active request with an opaque credential. */
|
|
50
58
|
accept: (credential?: RtpCredential) => Promise<RtpResult | undefined>;
|
|
51
59
|
/** Decline the active request. */
|
|
52
60
|
decline: (reason?: string) => Promise<RtpResult | undefined>;
|
|
61
|
+
/** Fetch/refresh authorization details for the active request. */
|
|
62
|
+
retrieve: (id?: string) => Promise<RtpResult | undefined>;
|
|
53
63
|
/** Dismiss the active request from the UI. */
|
|
54
64
|
dismiss: () => void;
|
|
55
65
|
/** Feed a raw decoded payload into the client (for host push adapters). */
|
|
56
|
-
receive: (payload: unknown) =>
|
|
66
|
+
receive: (payload: unknown) => Promise<RtpReceiveOutcome | undefined>;
|
|
57
67
|
/** Last terminal result, for rendering feedback. */
|
|
58
68
|
lastResult: RtpResult | null;
|
|
59
69
|
}
|
|
@@ -79,9 +89,9 @@ export function RtpProvider({
|
|
|
79
89
|
const activeIdRef = useRef<string | null>(null);
|
|
80
90
|
|
|
81
91
|
const receive = useCallback(
|
|
82
|
-
(payload: unknown) => {
|
|
92
|
+
async (payload: unknown) => {
|
|
83
93
|
onIncomingPayload?.(payload);
|
|
84
|
-
client.receive(payload);
|
|
94
|
+
return client.receive(payload);
|
|
85
95
|
},
|
|
86
96
|
[client, onIncomingPayload]
|
|
87
97
|
);
|
|
@@ -160,6 +170,17 @@ export function RtpProvider({
|
|
|
160
170
|
[client, request]
|
|
161
171
|
);
|
|
162
172
|
|
|
173
|
+
const retrieve = useCallback(
|
|
174
|
+
async (id?: string) => {
|
|
175
|
+
const target = id ?? request?.id ?? activeIdRef.current;
|
|
176
|
+
if (!target) {
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
return client.retrieve(target);
|
|
180
|
+
},
|
|
181
|
+
[client, request]
|
|
182
|
+
);
|
|
183
|
+
|
|
163
184
|
const dismiss = useCallback(() => {
|
|
164
185
|
setRequest(null);
|
|
165
186
|
setStatus(null);
|
|
@@ -168,19 +189,33 @@ export function RtpProvider({
|
|
|
168
189
|
}, []);
|
|
169
190
|
|
|
170
191
|
const isReady = status === RTP_STATUS.READY;
|
|
192
|
+
const isLoading = status === RTP_STATUS.RETRIEVING;
|
|
171
193
|
|
|
172
194
|
const value = useMemo<RtpContextValue>(
|
|
173
195
|
() => ({
|
|
174
196
|
request,
|
|
175
197
|
status,
|
|
176
198
|
isReady,
|
|
199
|
+
isLoading,
|
|
177
200
|
accept,
|
|
178
201
|
decline,
|
|
202
|
+
retrieve,
|
|
179
203
|
dismiss,
|
|
180
204
|
receive,
|
|
181
205
|
lastResult,
|
|
182
206
|
}),
|
|
183
|
-
[
|
|
207
|
+
[
|
|
208
|
+
request,
|
|
209
|
+
status,
|
|
210
|
+
isReady,
|
|
211
|
+
isLoading,
|
|
212
|
+
accept,
|
|
213
|
+
decline,
|
|
214
|
+
retrieve,
|
|
215
|
+
dismiss,
|
|
216
|
+
receive,
|
|
217
|
+
lastResult,
|
|
218
|
+
]
|
|
184
219
|
);
|
|
185
220
|
|
|
186
221
|
return <RtpContext.Provider value={value}>{children}</RtpContext.Provider>;
|
package/src/types.ts
CHANGED
|
@@ -27,6 +27,12 @@ export interface RtpPayload {
|
|
|
27
27
|
narration?: string;
|
|
28
28
|
/** Backend reference for the transaction. */
|
|
29
29
|
transactionRef?: string;
|
|
30
|
+
/** Merchant/issuer order number for the request (distinct from transactionRef). */
|
|
31
|
+
orderNumber?: string;
|
|
32
|
+
/** Secure transaction / deep-link reference the host can use to open the flow. */
|
|
33
|
+
deepLink?: string;
|
|
34
|
+
/** Instruction shown to the customer describing the action required. */
|
|
35
|
+
actionMessage?: string;
|
|
30
36
|
/** ISO 8601 timestamp after which the request can no longer be actioned. */
|
|
31
37
|
expiresAt: string;
|
|
32
38
|
/** Alias (phone/email) the request is addressed to, for identity matching. */
|
|
@@ -40,6 +46,12 @@ export interface RtpParty {
|
|
|
40
46
|
name: string;
|
|
41
47
|
accountNumber?: string;
|
|
42
48
|
bank?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Destination institution code (e.g. a NIP bank code) for the `payee`.
|
|
51
|
+
* The host uses it to route the resulting transfer to the right bank; the
|
|
52
|
+
* SDK never interprets it.
|
|
53
|
+
*/
|
|
54
|
+
bankCode?: string;
|
|
43
55
|
/** Optional display URL for a logo/avatar. */
|
|
44
56
|
logoUrl?: string;
|
|
45
57
|
}
|
|
@@ -53,6 +65,9 @@ export interface RtpRequest {
|
|
|
53
65
|
payee: RtpParty;
|
|
54
66
|
narration?: string;
|
|
55
67
|
transactionRef?: string;
|
|
68
|
+
orderNumber?: string;
|
|
69
|
+
deepLink?: string;
|
|
70
|
+
actionMessage?: string;
|
|
56
71
|
expiresAt: string;
|
|
57
72
|
recipient?: string;
|
|
58
73
|
}
|
|
@@ -113,14 +128,13 @@ export interface RtpStorage {
|
|
|
113
128
|
}
|
|
114
129
|
|
|
115
130
|
export interface RtpClientOptions {
|
|
116
|
-
/** Injected HTTP client. */
|
|
117
|
-
transport: RtpTransport;
|
|
118
131
|
/**
|
|
119
|
-
*
|
|
120
|
-
*
|
|
132
|
+
* Injected HTTP client. Optional: when omitted the client runs
|
|
133
|
+
* transport-free — it validates and tracks the request but performs no
|
|
134
|
+
* network calls, which is the shape a host uses when the payment itself is
|
|
135
|
+
* settled through the host's own core banking rather than an RTP backend.
|
|
121
136
|
*/
|
|
122
|
-
|
|
123
|
-
Promise<Record<string, string>> | Record<string, string>;
|
|
137
|
+
transport?: RtpTransport;
|
|
124
138
|
/**
|
|
125
139
|
* Returns the currently authenticated user's alias (phone/email), or null
|
|
126
140
|
* when logged out. Used for the auth gate and recipient matching.
|
|
@@ -145,6 +159,8 @@ export interface RtpEventMap {
|
|
|
145
159
|
export interface RtpClient {
|
|
146
160
|
isRtp: (payload: unknown) => payload is RtpPayload;
|
|
147
161
|
receive: (payload: unknown) => Promise<RtpReceiveOutcome>;
|
|
162
|
+
/** Fetch/refresh authorization details for a request from the backend. */
|
|
163
|
+
retrieve: (id: string) => Promise<RtpResult>;
|
|
148
164
|
resumeAuth: () => void;
|
|
149
165
|
accept: (params: {
|
|
150
166
|
id: string;
|
package/src/validate.ts
CHANGED
|
@@ -78,11 +78,59 @@ export function toRtpRequest(payload: RtpPayload): RtpRequest {
|
|
|
78
78
|
payee: payload.payee,
|
|
79
79
|
narration: payload.narration,
|
|
80
80
|
transactionRef: payload.transactionRef,
|
|
81
|
+
orderNumber: payload.orderNumber,
|
|
82
|
+
deepLink: payload.deepLink,
|
|
83
|
+
actionMessage: payload.actionMessage,
|
|
81
84
|
expiresAt: payload.expiresAt,
|
|
82
85
|
recipient: payload.recipient,
|
|
83
86
|
};
|
|
84
87
|
}
|
|
85
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Build a partial-request patch from an incoming payload, copying only the
|
|
91
|
+
* fields that are actually present. Used to overlay retrieved backend details
|
|
92
|
+
* without a partial response erasing data already held from the push.
|
|
93
|
+
*/
|
|
94
|
+
export function toRequestPatch(
|
|
95
|
+
payload: Partial<RtpPayload>
|
|
96
|
+
): Partial<RtpRequest> {
|
|
97
|
+
const patch: Partial<RtpRequest> = {};
|
|
98
|
+
if (payload.rtpId !== undefined) patch.id = payload.rtpId;
|
|
99
|
+
if (payload.amount !== undefined) patch.amount = toAmount(payload.amount);
|
|
100
|
+
if (payload.currency !== undefined) patch.currency = payload.currency;
|
|
101
|
+
if (payload.payer !== undefined) patch.payer = payload.payer;
|
|
102
|
+
if (payload.payee !== undefined) patch.payee = payload.payee;
|
|
103
|
+
if (payload.narration !== undefined) patch.narration = payload.narration;
|
|
104
|
+
if (payload.transactionRef !== undefined)
|
|
105
|
+
patch.transactionRef = payload.transactionRef;
|
|
106
|
+
if (payload.orderNumber !== undefined)
|
|
107
|
+
patch.orderNumber = payload.orderNumber;
|
|
108
|
+
if (payload.deepLink !== undefined) patch.deepLink = payload.deepLink;
|
|
109
|
+
if (payload.actionMessage !== undefined)
|
|
110
|
+
patch.actionMessage = payload.actionMessage;
|
|
111
|
+
if (payload.expiresAt !== undefined) patch.expiresAt = payload.expiresAt;
|
|
112
|
+
if (payload.recipient !== undefined) patch.recipient = payload.recipient;
|
|
113
|
+
return patch;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Overlay a patch onto an existing request. Only defined, non-null values are
|
|
118
|
+
* copied so a partial response never erases information already held.
|
|
119
|
+
*/
|
|
120
|
+
export function mergeRequest(
|
|
121
|
+
current: RtpRequest,
|
|
122
|
+
patch: Partial<RtpRequest>
|
|
123
|
+
): RtpRequest {
|
|
124
|
+
const next: RtpRequest = { ...current };
|
|
125
|
+
for (const key of Object.keys(patch) as (keyof RtpRequest)[]) {
|
|
126
|
+
const value = patch[key];
|
|
127
|
+
if (value !== undefined && value !== null) {
|
|
128
|
+
(next as any)[key] = value;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return next;
|
|
132
|
+
}
|
|
133
|
+
|
|
86
134
|
/** Pure expiry check. Re-evaluated on every transition, not just on receive. */
|
|
87
135
|
export function isExpired(request: RtpRequest, now: number): boolean {
|
|
88
136
|
const expiry = new Date(request.expiresAt).getTime();
|