@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.
Files changed (39) hide show
  1. package/README.md +57 -23
  2. package/lib/module/client.js +218 -17
  3. package/lib/module/client.js.map +1 -1
  4. package/lib/module/constants.js +5 -0
  5. package/lib/module/constants.js.map +1 -1
  6. package/lib/module/index.js +2 -2
  7. package/lib/module/index.js.map +1 -1
  8. package/lib/module/mock/makeRtpPayload.js +3 -0
  9. package/lib/module/mock/makeRtpPayload.js.map +1 -1
  10. package/lib/module/mock/mockTransport.js +9 -2
  11. package/lib/module/mock/mockTransport.js.map +1 -1
  12. package/lib/module/react/RtpProvider.js +13 -3
  13. package/lib/module/react/RtpProvider.js.map +1 -1
  14. package/lib/module/validate.js +42 -0
  15. package/lib/module/validate.js.map +1 -1
  16. package/lib/typescript/src/client.d.ts +12 -3
  17. package/lib/typescript/src/client.d.ts.map +1 -1
  18. package/lib/typescript/src/constants.d.ts +5 -0
  19. package/lib/typescript/src/constants.d.ts.map +1 -1
  20. package/lib/typescript/src/index.d.ts +2 -2
  21. package/lib/typescript/src/index.d.ts.map +1 -1
  22. package/lib/typescript/src/mock/makeRtpPayload.d.ts.map +1 -1
  23. package/lib/typescript/src/mock/mockTransport.d.ts +7 -1
  24. package/lib/typescript/src/mock/mockTransport.d.ts.map +1 -1
  25. package/lib/typescript/src/react/RtpProvider.d.ts +6 -2
  26. package/lib/typescript/src/react/RtpProvider.d.ts.map +1 -1
  27. package/lib/typescript/src/types.d.ts +22 -5
  28. package/lib/typescript/src/types.d.ts.map +1 -1
  29. package/lib/typescript/src/validate.d.ts +11 -0
  30. package/lib/typescript/src/validate.d.ts.map +1 -1
  31. package/package.json +1 -1
  32. package/src/client.ts +227 -20
  33. package/src/constants.ts +5 -0
  34. package/src/index.tsx +3 -1
  35. package/src/mock/makeRtpPayload.ts +3 -0
  36. package/src/mock/mockTransport.ts +18 -2
  37. package/src/react/RtpProvider.tsx +40 -5
  38. package/src/types.ts +22 -6
  39. 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/status calls. It owns no UI, no navigation and no crypto.
29
- * Every external effect is injected so the same contract can be re-implemented
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
- entries.set(id, { request, status: RTP_STATUS.RECEIVED });
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 = await getAuthHeaders();
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 backendStatus = res?.data?.status;
180
- const status: RtpStatus =
181
- backendStatus === 'DECLINED'
182
- ? RTP_STATUS.DECLINED
183
- : backendStatus === 'FAILED'
184
- ? RTP_STATUS.FAILED
185
- : RTP_STATUS.ACCEPTED;
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: res?.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 = await getAuthHeaders();
402
+ const headers = {};
214
403
  const res = await transport.post(
215
404
  `${basePath}/${id}/decline`,
216
405
  { reason },
217
406
  headers
218
407
  );
219
- return emitResult({ id, status: RTP_STATUS.DECLINED, data: res?.data });
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: { id: extractId(path), status: 'PENDING' } as T,
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 { RtpClient, RtpCredential, RtpRequest, RtpResult } from '../types';
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) => void;
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
- [request, status, isReady, accept, decline, dismiss, receive, lastResult]
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
- * Returns auth headers for an outgoing request. Called per request so the
120
- * host can refresh tokens / compute MACs.
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
- getAuthHeaders?: () =>
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();