@inno-isw/react-native-rtp 0.0.0-stage → 0.2.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 (55) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +385 -2
  3. package/lib/module/client.js +391 -0
  4. package/lib/module/client.js.map +1 -0
  5. package/lib/module/constants.js +49 -0
  6. package/lib/module/constants.js.map +1 -0
  7. package/lib/module/emitter.js +46 -0
  8. package/lib/module/emitter.js.map +1 -0
  9. package/lib/module/index.js +16 -0
  10. package/lib/module/index.js.map +1 -0
  11. package/lib/module/mock/makeRtpPayload.js +54 -0
  12. package/lib/module/mock/makeRtpPayload.js.map +1 -0
  13. package/lib/module/mock/mockTransport.js +89 -0
  14. package/lib/module/mock/mockTransport.js.map +1 -0
  15. package/lib/module/package.json +1 -0
  16. package/lib/module/push.js +96 -0
  17. package/lib/module/push.js.map +1 -0
  18. package/lib/module/react/RtpProvider.js +152 -0
  19. package/lib/module/react/RtpProvider.js.map +1 -0
  20. package/lib/module/types.js +4 -0
  21. package/lib/module/types.js.map +1 -0
  22. package/lib/module/validate.js +122 -0
  23. package/lib/module/validate.js.map +1 -0
  24. package/lib/typescript/package.json +1 -0
  25. package/lib/typescript/src/client.d.ts +20 -0
  26. package/lib/typescript/src/client.d.ts.map +1 -0
  27. package/lib/typescript/src/constants.d.ts +50 -0
  28. package/lib/typescript/src/constants.d.ts.map +1 -0
  29. package/lib/typescript/src/emitter.d.ts +15 -0
  30. package/lib/typescript/src/emitter.d.ts.map +1 -0
  31. package/lib/typescript/src/index.d.ts +14 -0
  32. package/lib/typescript/src/index.d.ts.map +1 -0
  33. package/lib/typescript/src/mock/makeRtpPayload.d.ts +17 -0
  34. package/lib/typescript/src/mock/makeRtpPayload.d.ts.map +1 -0
  35. package/lib/typescript/src/mock/mockTransport.d.ts +35 -0
  36. package/lib/typescript/src/mock/mockTransport.d.ts.map +1 -0
  37. package/lib/typescript/src/push.d.ts +41 -0
  38. package/lib/typescript/src/push.d.ts.map +1 -0
  39. package/lib/typescript/src/react/RtpProvider.d.ts +60 -0
  40. package/lib/typescript/src/react/RtpProvider.d.ts.map +1 -0
  41. package/lib/typescript/src/types.d.ts +153 -0
  42. package/lib/typescript/src/types.d.ts.map +1 -0
  43. package/lib/typescript/src/validate.d.ts +33 -0
  44. package/lib/typescript/src/validate.d.ts.map +1 -0
  45. package/package.json +141 -4
  46. package/src/client.ts +428 -0
  47. package/src/constants.ts +54 -0
  48. package/src/emitter.ts +53 -0
  49. package/src/index.tsx +45 -0
  50. package/src/mock/makeRtpPayload.ts +69 -0
  51. package/src/mock/mockTransport.ts +119 -0
  52. package/src/push.ts +109 -0
  53. package/src/react/RtpProvider.tsx +235 -0
  54. package/src/types.ts +172 -0
  55. package/src/validate.ts +141 -0
package/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Quickteller
4
+ Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ of this software and associated documentation files (the "Software"), to deal
6
+ in the Software without restriction, including without limitation the rights
7
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ copies of the Software, and to permit persons to whom the Software is
9
+ furnished to do so, subject to the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all
12
+ copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,386 @@
1
- # Temporary Holding Version
1
+ # @inno-isw/react-native-rtp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Request to Pay (RTP) SDK for React Native.
4
+
5
+ `@inno-isw/react-native-rtp` owns the **business logic** of a Request to Pay flow — payload
6
+ validation, de-duplication, expiry, recipient matching, normalized status, and
7
+ the accept / decline / status calls. The **host app owns all UI**: it renders the
8
+ bottom sheet, collects the PIN, and decides navigation.
9
+
10
+ It is pure JS/TS with **zero native code** and **zero runtime dependencies**
11
+ (only `react` / `react-native` peer deps). Every external effect is injected, so
12
+ the same contract can be re-implemented on Kotlin or Swift.
13
+
14
+ ---
15
+
16
+ ## Installation
17
+
18
+ ```sh
19
+ yarn add @inno-isw/react-native-rtp
20
+ # or: npm install @inno-isw/react-native-rtp
21
+ ```
22
+
23
+ Peer dependencies: `react`, `react-native`.
24
+
25
+ ### Publishing (maintainers)
26
+
27
+ The package is consumed as a normal npm dependency — do **not** link it locally.
28
+
29
+ ```sh
30
+ yarn install
31
+ yarn prepare # builds lib/ (bob build) via the prepare script
32
+ npm publish # publishes @inno-isw/react-native-rtp to the npm registry
33
+ ```
34
+
35
+ Consumer (e.g. QTM) then installs it like any other package:
36
+
37
+ ```sh
38
+ yarn add @inno-isw/react-native-rtp
39
+ # or: npm install @inno-isw/react-native-rtp
40
+ ```
41
+
42
+ ---
43
+
44
+ ## The portable contract
45
+
46
+ The SDK never sees a push notification. It sees a **decoded RTP payload**. Each
47
+ platform is responsible for mapping *its* push shape (RTCP, raw FCM, APNs,
48
+ in-house PN) into this structure.
49
+
50
+ ```ts
51
+ interface RtpPayload {
52
+ version?: string; // schema version; unknown values are ignored
53
+ type: 'RTP'; // discriminator
54
+ rtpId: string; // stable id — the idempotency key
55
+ amount: number; // minor unit (e.g. kobo); integer, no floats
56
+ currency: string; // ISO 4217, e.g. "NGN"
57
+ payer: { name: string; accountNumber?: string; bank?: string; logoUrl?: string };
58
+ payee: { name: string; accountNumber?: string; bank?: string; logoUrl?: string };
59
+ narration?: string;
60
+ transactionRef?: string;
61
+ orderNumber?: string; // merchant/issuer order no. (≠ transactionRef)
62
+ deepLink?: string; // secure transaction / deep-link reference
63
+ actionMessage?: string; // instruction shown to the customer
64
+ expiresAt: string; // ISO 8601
65
+ recipient?: string; // alias the request is addressed to
66
+ [key: string]: unknown; // forward-compatible passthrough
67
+ }
68
+ ```
69
+
70
+ > `payer` is the party being asked to pay (your customer); `payee` is the party
71
+ > that receives the money (the merchant).
72
+
73
+ > Unknown fields are **ignored, not rejected**, so older SDKs keep working when
74
+ > the backend adds fields.
75
+
76
+ ### `isRtp(payload)`
77
+
78
+ The type guard operates on the **decoded payload**, not the notification
79
+ envelope:
80
+
81
+ ```ts
82
+ payload.type === 'RTP' && typeof payload.rtpId === 'string';
83
+ ```
84
+
85
+ ---
86
+
87
+ ## Quick start
88
+
89
+ ### 1. Create the client (injected seams)
90
+
91
+ ```ts
92
+ import { createRtpClient } from '@inno-isw/react-native-rtp';
93
+ import { api } from './api'; // your existing HTTP client / ky / axios instance
94
+
95
+ export const rtpClient = createRtpClient({
96
+ // (required) minimal HTTP contract — the SDK never imports a network lib
97
+ transport: {
98
+ post: (path, body, headers) => api.post(path, { json: body, headers }),
99
+ get: (path, headers) => api.get(path, { headers }),
100
+ },
101
+
102
+ // (optional) auth headers, called per request so tokens/MACs stay fresh
103
+ getAuthHeaders: async () => ({ Authorization: `Bearer ${await getToken()}` }),
104
+
105
+ // (optional) drives the auth gate + recipient matching
106
+ getCurrentUser: () => authStore.user?.email ?? null,
107
+
108
+ // (optional) injectable clock for deterministic expiry
109
+ now: () => Date.now(),
110
+
111
+ // (optional) diagnostics sink
112
+ logger: (...args) => console.log('[rtp]', ...args),
113
+
114
+ // (optional) REST base path, default "/rtp"
115
+ basePath: '/rtp',
116
+ });
117
+ ```
118
+
119
+ ### 2. Normalize your push notification → payload
120
+
121
+ This is the **only** platform-specific input seam. Use the reference helper, or
122
+ write your own:
123
+
124
+ ```ts
125
+ import { mapPushToRtp } from '@inno-isw/react-native-rtp';
126
+
127
+ // Works with RTCP/FCM shapes, including app_data JSON unwrapping:
128
+ const payload = mapPushToRtp(notification);
129
+ if (payload) {
130
+ rtpClient.receive(payload);
131
+ }
132
+ ```
133
+
134
+ For a bespoke PN system, pass your own data accessor:
135
+
136
+ ```ts
137
+ const payload = mapPushToRtp(envelope, (e) => e.customKeys);
138
+ ```
139
+
140
+ ### 3. Mount the provider and build your UI
141
+
142
+ ```tsx
143
+ import { RtpProvider, useRtp, RTP_STATUS } from '@inno-isw/react-native-rtp';
144
+ import { rtpClient } from './rtpClient';
145
+
146
+ export default function App() {
147
+ const isAuthenticated = useAuthStore((s) => Boolean(s.user));
148
+
149
+ return (
150
+ <RtpProvider
151
+ client={rtpClient}
152
+ isAuthenticated={isAuthenticated}
153
+ storage={persistence} // optional — see "Cold start" below
154
+ >
155
+ <Dashboard />
156
+ </RtpProvider>
157
+ );
158
+ }
159
+ ```
160
+
161
+ Inside your screen, render your own sheet (e.g. QTM's `QTBottomSheet`):
162
+
163
+ ```tsx
164
+ function Dashboard() {
165
+ const { request, status, isReady, isLoading, accept, decline, dismiss, lastResult } =
166
+ useRtp();
167
+
168
+ return (
169
+ <>
170
+ <YourDashboard />
171
+
172
+ <QTBottomSheet isOpen={status !== null} onClose={dismiss}>
173
+ {isLoading && <ActivityIndicator />}
174
+
175
+ {request && (
176
+ <>
177
+ <Text>{request.payee.name}</Text>
178
+ <Text>{request.currency} {request.amount}</Text>
179
+ <Text>{request.transactionRef}</Text>
180
+ </>
181
+ )}
182
+
183
+ {isReady && (
184
+ <>
185
+ {/* collect PIN yourself, produce an opaque credential, then: */}
186
+ <Button title="Accept" onPress={() => accept(credential)} />
187
+ <Button title="Decline" onPress={() => decline('USER_DECLINED')} />
188
+ </>
189
+ )}
190
+
191
+ {status === RTP_STATUS.ACCEPTED && <Text>Payment accepted</Text>}
192
+ {status === RTP_STATUS.FAILED && <Text>Something went wrong</Text>}
193
+ {lastResult?.error && <Text>{lastResult.error}</Text>}
194
+ </QTBottomSheet>
195
+ </>
196
+ );
197
+ }
198
+ ```
199
+
200
+ ---
201
+
202
+ ## Status catalog
203
+
204
+ The SDK computes status; the app only renders it.
205
+
206
+ | Status | Meaning |
207
+ | --- | --- |
208
+ | `RECEIVED` | Payload arrived and passed validation. |
209
+ | `RETRIEVING` | Details are being fetched from the backend (`retrieve`). |
210
+ | `AWAITING_AUTH` | Valid, but the user is logged out (auth gate). |
211
+ | `READY` | Valid, authenticated, actionable — show Accept / Decline. |
212
+ | `PROCESSING` | Accept/decline in flight. |
213
+ | `PENDING` | Backend accepted the action but a final decision is still pending. |
214
+ | `ACCEPTED` | Backend confirmed acceptance. |
215
+ | `DECLINED` | Backend confirmed (or user initiated) a decline. |
216
+ | `EXPIRED` | Past `expiresAt`; no longer actionable. |
217
+ | `FAILED` | Terminal failure. See `RtpError` for the reason. |
218
+
219
+ `ACCEPTED`, `DECLINED`, `EXPIRED` and `FAILED` are terminal; `PENDING` is not.
220
+
221
+ `RtpError` values: `INVALID_PAYLOAD`, `RECIPIENT_MISMATCH`, `RETRIEVE_FAILED`,
222
+ `NETWORK_ERROR`, `BACKEND_REJECTED`, `UNKNOWN`.
223
+
224
+ ---
225
+
226
+ ## Events
227
+
228
+ Subscribe via `client.on(event, handler)`. Every call returns an unsubscribe
229
+ function.
230
+
231
+ | Event | Payload | Fires when |
232
+ | --- | --- | --- |
233
+ | `request` | `RtpRequest` | A request becomes actionable (`READY`), or its details were refreshed. |
234
+ | `state` | `{ id, status }` | Any status transition, including the in-place `EXPIRED` below. |
235
+ | `result` | `RtpResult` | A terminal outcome (`ACCEPTED`/`DECLINED`/`EXPIRED`/`FAILED`). |
236
+
237
+ ```ts
238
+ const off = rtpClient.on('result', (r) => {
239
+ if (r.status === 'ACCEPTED') showToast('Paid');
240
+ });
241
+ // later
242
+ off();
243
+ ```
244
+
245
+ ---
246
+
247
+ ## Client API
248
+
249
+ | Method | Description |
250
+ | --- | --- |
251
+ | `isRtp(payload)` | Type guard for decoded RTP payloads. |
252
+ | `receive(payload)` | Validate, de-dupe, apply the auth gate/expiry, and emit. Returns an outcome. |
253
+ | `retrieve(id)` | Fetch/refresh authorization details and reconcile the backend status. |
254
+ | `accept({ id, credential })` | Authorize a request. `credential` is **opaque** — the SDK forwards it untouched. |
255
+ | `decline({ id, reason })` | Decline a request. |
256
+ | `getStatus(id)` | Current normalized status. |
257
+ | `getRequest(id)` | The normalized request. |
258
+ | `acknowledge(id)` | Forget a request (after you dismiss the UI). |
259
+ | `resumeAuth()` | Release requests waiting on the auth gate. Usually called for you by the provider. |
260
+ | `on(event, cb)` | Subscribe; returns an unsubscribe fn. |
261
+
262
+ ### The opaque credential
263
+
264
+ `accept()` takes a `credential` that the SDK **never inspects** — no PIN, no
265
+ crypto, no card-service output crosses the boundary as structured data:
266
+
267
+ ```ts
268
+ const credential = await cardService.generateSecureData(pin); // stays in the app
269
+ await accept(credential);
270
+ ```
271
+
272
+ This is what makes the portability promise hold: a Kotlin or Swift host produces
273
+ its own credential type and posts it opaquely.
274
+
275
+ ---
276
+
277
+ ## Edge cases handled for you
278
+
279
+ - **Duplicate delivery** — the same `rtpId` arriving via foreground *and*
280
+ background (or a double tap) emits `request` exactly once.
281
+ - **Expiry fires on its own** — a timer is armed at `expiresAt`, so a request
282
+ that lapses while the sheet is open transitions to `EXPIRED` in place (a
283
+ `state` + `result` event) instead of waiting for the next user action.
284
+ - **Expiry re-checked on every transition** — `accept`/`decline`/`retrieve`
285
+ refuse an already-lapsed request and report `EXPIRED`.
286
+ - **Backend status is never assumed** — an unrecognised status from
287
+ `accept`/`retrieve` fails closed (`FAILED` / `BACKEND_REJECTED`) rather than
288
+ being reported as an approved payment.
289
+ - **Retrieve failure is contained** — a failed `retrieve` emits `FAILED` with
290
+ `RETRIEVE_FAILED`; the request is not silently dropped.
291
+ - **Recipient mismatch** — if `payload.recipient` doesn't match `getCurrentUser()`,
292
+ the request fails with `RECIPIENT_MISMATCH` instead of being shown.
293
+ - **Logged-out → login resume** — a request received while logged out sits in
294
+ `AWAITING_AUTH`; when `isAuthenticated` flips true the provider calls
295
+ `resumeAuth()` and emits `request`.
296
+
297
+ ### Cold start (killed app)
298
+
299
+ Register your PN handler early (before React mounts) and persist the payload:
300
+
301
+ ```ts
302
+ // index.js — before App renders
303
+ rtcp.onNotificationTapped(async (notification) => {
304
+ const payload = mapPushToRtp(notification);
305
+ if (payload) await persistence.setItem('rtp.pendingPayload', JSON.stringify(payload));
306
+ });
307
+ ```
308
+
309
+ `RtpProvider` drains that key on mount. The storage key is
310
+ `rtp.pendingPayload` (exported as `STORAGE_KEY` in the example).
311
+
312
+ ```ts
313
+ export const persistence = {
314
+ getItem: (k) => AsyncStorage.getItem(k),
315
+ setItem: (k, v) => AsyncStorage.setItem(k, v),
316
+ removeItem: (k) => AsyncStorage.removeItem(k),
317
+ };
318
+ ```
319
+
320
+ ---
321
+
322
+ ## Security notes
323
+
324
+ - **Never trust the push body for money movement.** It carries an opaque
325
+ `rtpId`/token; `accept` is verified server-side.
326
+ - **Recipient matching is enforced by the SDK** via `getCurrentUser()`, which
327
+ matters most in the logged-out flow where you have no session to lean on.
328
+ - **Idempotency is keyed by `rtpId`**, so replays can't double-action a request.
329
+
330
+ ---
331
+
332
+ ## Cross-platform conformance
333
+
334
+ This package is one of several implementations of the same spec. To keep them
335
+ honest, `fixtures/conformance.json` holds platform-neutral test vectors with a
336
+ **fixed clock**: valid, logged-out, expired, malformed, wrong-recipient,
337
+ non-RTP, duplicate, and unknown-field cases.
338
+
339
+ Every implementation (React Native, Kotlin, Swift) must produce the same
340
+ outcome for each case. The vectors are the contract; the code is just one
341
+ realization of it.
342
+
343
+ > If you ever add signed payloads (HMAC), lock the canonical byte string
344
+ > (field order, encoding, key/digest encoding, MAC placement) in these vectors
345
+ > first — otherwise signatures silently diverge between platforms.
346
+
347
+ ---
348
+
349
+ ## Example app
350
+
351
+ A runnable host app lives in [`example/`](./example). It ships a mock transport
352
+ and a control panel to simulate incoming PNs and every edge case:
353
+
354
+ ```sh
355
+ yarn example start # then press i / a / w
356
+ yarn example web # or run it in a browser
357
+ ```
358
+
359
+ Buttons: deliver valid / expired / duplicate requests, simulate a cold start
360
+ (persist + remount provider), toggle authentication, and force accept failures.
361
+ The event log shows the `request` / `state` / `result` stream live.
362
+
363
+ ---
364
+
365
+ ## Development
366
+
367
+ ```sh
368
+ yarn typecheck
369
+ yarn lint
370
+ yarn test
371
+ yarn prepare # build lib/
372
+ ```
373
+
374
+ ## Contributing
375
+
376
+ - [Development workflow](CONTRIBUTING.md#development-workflow)
377
+ - [Sending a pull request](CONTRIBUTING.md#sending-a-pull-request)
378
+ - [Code of conduct](./CODE_OF_CONDUCT.md)
379
+
380
+ ## License
381
+
382
+ MIT
383
+
384
+ ---
385
+
386
+ Made with [create-react-native-library](https://github.com/callstack/react-native-builder-bob)