@inno-isw/react-native-rtp 0.0.0-stage → 0.1.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/LICENSE +20 -0
- package/README.md +362 -2
- package/lib/module/client.js +218 -0
- package/lib/module/client.js.map +1 -0
- package/lib/module/constants.js +44 -0
- package/lib/module/constants.js.map +1 -0
- package/lib/module/emitter.js +46 -0
- package/lib/module/emitter.js.map +1 -0
- package/lib/module/index.js +16 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/mock/makeRtpPayload.js +51 -0
- package/lib/module/mock/makeRtpPayload.js.map +1 -0
- package/lib/module/mock/mockTransport.js +82 -0
- package/lib/module/mock/mockTransport.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/push.js +96 -0
- package/lib/module/push.js.map +1 -0
- package/lib/module/react/RtpProvider.js +142 -0
- package/lib/module/react/RtpProvider.js.map +1 -0
- package/lib/module/types.js +4 -0
- package/lib/module/types.js.map +1 -0
- package/lib/module/validate.js +80 -0
- package/lib/module/validate.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/client.d.ts +11 -0
- package/lib/typescript/src/client.d.ts.map +1 -0
- package/lib/typescript/src/constants.d.ts +45 -0
- package/lib/typescript/src/constants.d.ts.map +1 -0
- package/lib/typescript/src/emitter.d.ts +15 -0
- package/lib/typescript/src/emitter.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +14 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/mock/makeRtpPayload.d.ts +17 -0
- package/lib/typescript/src/mock/makeRtpPayload.d.ts.map +1 -0
- package/lib/typescript/src/mock/mockTransport.d.ts +29 -0
- package/lib/typescript/src/mock/mockTransport.d.ts.map +1 -0
- package/lib/typescript/src/push.d.ts +41 -0
- package/lib/typescript/src/push.d.ts.map +1 -0
- package/lib/typescript/src/react/RtpProvider.d.ts +56 -0
- package/lib/typescript/src/react/RtpProvider.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +142 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/lib/typescript/src/validate.d.ts +22 -0
- package/lib/typescript/src/validate.d.ts.map +1 -0
- package/package.json +141 -4
- package/src/client.ts +239 -0
- package/src/constants.ts +49 -0
- package/src/emitter.ts +53 -0
- package/src/index.tsx +43 -0
- package/src/mock/makeRtpPayload.ts +66 -0
- package/src/mock/mockTransport.ts +103 -0
- package/src/push.ts +109 -0
- package/src/react/RtpProvider.tsx +200 -0
- package/src/types.ts +161 -0
- package/src/validate.ts +93 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { RTP_ERROR, RTP_EVENT, RTP_STATUS } from './constants';
|
|
2
|
+
import type { RtpError, RtpStatus } from './constants';
|
|
3
|
+
import { createEmitter, defaultNow, noopLogger } from './emitter';
|
|
4
|
+
import {
|
|
5
|
+
findValidationErrors,
|
|
6
|
+
isExpired,
|
|
7
|
+
isRtpPayload,
|
|
8
|
+
toRtpRequest,
|
|
9
|
+
} from './validate';
|
|
10
|
+
import type {
|
|
11
|
+
RtpClient,
|
|
12
|
+
RtpClientOptions,
|
|
13
|
+
RtpCredential,
|
|
14
|
+
RtpReceiveOutcome,
|
|
15
|
+
RtpRequest,
|
|
16
|
+
RtpResult,
|
|
17
|
+
} from './types';
|
|
18
|
+
|
|
19
|
+
interface Entry {
|
|
20
|
+
request: RtpRequest;
|
|
21
|
+
status: RtpStatus;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Create a portable Request-to-Pay client.
|
|
26
|
+
*
|
|
27
|
+
* 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.
|
|
31
|
+
*/
|
|
32
|
+
export function createRtpClient(options: RtpClientOptions): RtpClient {
|
|
33
|
+
const {
|
|
34
|
+
transport,
|
|
35
|
+
getAuthHeaders = async () => ({}),
|
|
36
|
+
getCurrentUser = () => null,
|
|
37
|
+
now = defaultNow,
|
|
38
|
+
logger = noopLogger,
|
|
39
|
+
basePath = '/rtp',
|
|
40
|
+
} = options;
|
|
41
|
+
|
|
42
|
+
if (!transport) {
|
|
43
|
+
throw new Error('[react-native-rtp] `transport` is required');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const emitter = createEmitter();
|
|
47
|
+
const entries = new Map<string, Entry>();
|
|
48
|
+
|
|
49
|
+
function setStatus(id: string, status: RtpStatus): void {
|
|
50
|
+
const entry = entries.get(id);
|
|
51
|
+
if (entry) {
|
|
52
|
+
entry.status = status;
|
|
53
|
+
}
|
|
54
|
+
emitter.emit(RTP_EVENT.STATE, { id, status });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function emitResult(result: RtpResult): RtpResult {
|
|
58
|
+
setStatus(result.id, result.status);
|
|
59
|
+
emitter.emit(RTP_EVENT.RESULT, result);
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function fail(id: string, error: RtpError | string): RtpResult {
|
|
64
|
+
return emitResult({ id, status: RTP_STATUS.FAILED, error });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function receive(payload: unknown): Promise<RtpReceiveOutcome> {
|
|
68
|
+
if (!isRtpPayload(payload)) {
|
|
69
|
+
return { handled: false };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const id = payload.rtpId;
|
|
73
|
+
|
|
74
|
+
// Idempotency: the same request can arrive twice (foreground + background,
|
|
75
|
+
// or a double tap). Never re-emit for an id we have already seen.
|
|
76
|
+
const existing = entries.get(id);
|
|
77
|
+
if (existing) {
|
|
78
|
+
return {
|
|
79
|
+
handled: true,
|
|
80
|
+
request: existing.request,
|
|
81
|
+
status: existing.status,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const errors = findValidationErrors(payload);
|
|
86
|
+
if (errors.length > 0) {
|
|
87
|
+
logger('[react-native-rtp] invalid payload, missing:', errors.join(', '));
|
|
88
|
+
return { handled: true, error: RTP_ERROR.INVALID_PAYLOAD };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const request = toRtpRequest(payload);
|
|
92
|
+
entries.set(id, { request, status: RTP_STATUS.RECEIVED });
|
|
93
|
+
|
|
94
|
+
if (isExpired(request, now())) {
|
|
95
|
+
const result = emitResult({ id, status: RTP_STATUS.EXPIRED });
|
|
96
|
+
return { handled: true, request, status: result.status };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const currentUser = getCurrentUser();
|
|
100
|
+
|
|
101
|
+
if (!currentUser) {
|
|
102
|
+
setStatus(id, RTP_STATUS.AWAITING_AUTH);
|
|
103
|
+
return { handled: true, request, status: RTP_STATUS.AWAITING_AUTH };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (request.recipient && currentUser !== request.recipient) {
|
|
107
|
+
const result = fail(id, RTP_ERROR.RECIPIENT_MISMATCH);
|
|
108
|
+
return {
|
|
109
|
+
handled: true,
|
|
110
|
+
request,
|
|
111
|
+
status: result.status,
|
|
112
|
+
error: result.error,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
setStatus(id, RTP_STATUS.READY);
|
|
117
|
+
emitter.emit(RTP_EVENT.REQUEST, request);
|
|
118
|
+
return { handled: true, request, status: RTP_STATUS.READY };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Called by the host after a successful login to release any requests that
|
|
123
|
+
* were waiting on the auth gate.
|
|
124
|
+
*/
|
|
125
|
+
function resumeAuth(): void {
|
|
126
|
+
for (const [id, entry] of entries) {
|
|
127
|
+
if (entry.status !== RTP_STATUS.AWAITING_AUTH) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (isExpired(entry.request, now())) {
|
|
132
|
+
const result = emitResult({ id, status: RTP_STATUS.EXPIRED });
|
|
133
|
+
logger(
|
|
134
|
+
'[react-native-rtp] request expired while awaiting auth:',
|
|
135
|
+
id,
|
|
136
|
+
result.status
|
|
137
|
+
);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const currentUser = getCurrentUser();
|
|
142
|
+
if (
|
|
143
|
+
entry.request.recipient &&
|
|
144
|
+
currentUser &&
|
|
145
|
+
currentUser !== entry.request.recipient
|
|
146
|
+
) {
|
|
147
|
+
fail(id, RTP_ERROR.RECIPIENT_MISMATCH);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
setStatus(id, RTP_STATUS.READY);
|
|
152
|
+
emitter.emit(RTP_EVENT.REQUEST, entry.request);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function accept(params: {
|
|
157
|
+
id: string;
|
|
158
|
+
credential?: RtpCredential;
|
|
159
|
+
}): Promise<RtpResult> {
|
|
160
|
+
const { id, credential } = params;
|
|
161
|
+
const entry = entries.get(id);
|
|
162
|
+
if (!entry) {
|
|
163
|
+
return fail(id, RTP_ERROR.UNKNOWN);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (isExpired(entry.request, now())) {
|
|
167
|
+
return emitResult({ id, status: RTP_STATUS.EXPIRED });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
setStatus(id, RTP_STATUS.PROCESSING);
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
const headers = await getAuthHeaders();
|
|
174
|
+
const res = await transport.post(
|
|
175
|
+
`${basePath}/${id}/accept`,
|
|
176
|
+
{ credential },
|
|
177
|
+
headers
|
|
178
|
+
);
|
|
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;
|
|
186
|
+
|
|
187
|
+
return emitResult({
|
|
188
|
+
id,
|
|
189
|
+
status,
|
|
190
|
+
data: res?.data,
|
|
191
|
+
error:
|
|
192
|
+
status === RTP_STATUS.FAILED ? RTP_ERROR.BACKEND_REJECTED : undefined,
|
|
193
|
+
});
|
|
194
|
+
} catch (err) {
|
|
195
|
+
logger('[react-native-rtp] accept failed:', err);
|
|
196
|
+
return fail(id, RTP_ERROR.NETWORK_ERROR);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function decline(params: {
|
|
201
|
+
id: string;
|
|
202
|
+
reason?: string;
|
|
203
|
+
}): Promise<RtpResult> {
|
|
204
|
+
const { id, reason } = params;
|
|
205
|
+
const entry = entries.get(id);
|
|
206
|
+
if (!entry) {
|
|
207
|
+
return fail(id, RTP_ERROR.UNKNOWN);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
setStatus(id, RTP_STATUS.PROCESSING);
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
const headers = await getAuthHeaders();
|
|
214
|
+
const res = await transport.post(
|
|
215
|
+
`${basePath}/${id}/decline`,
|
|
216
|
+
{ reason },
|
|
217
|
+
headers
|
|
218
|
+
);
|
|
219
|
+
return emitResult({ id, status: RTP_STATUS.DECLINED, data: res?.data });
|
|
220
|
+
} catch (err) {
|
|
221
|
+
logger('[react-native-rtp] decline failed:', err);
|
|
222
|
+
return fail(id, RTP_ERROR.NETWORK_ERROR);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
isRtp: isRtpPayload,
|
|
228
|
+
receive,
|
|
229
|
+
resumeAuth,
|
|
230
|
+
accept,
|
|
231
|
+
decline,
|
|
232
|
+
getStatus: (id: string) => entries.get(id)?.status,
|
|
233
|
+
getRequest: (id: string) => entries.get(id)?.request,
|
|
234
|
+
acknowledge: (id: string) => {
|
|
235
|
+
entries.delete(id);
|
|
236
|
+
},
|
|
237
|
+
on: emitter.on,
|
|
238
|
+
};
|
|
239
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalized RTP status.
|
|
3
|
+
*
|
|
4
|
+
* The SDK is the single source of truth for these values so that every host
|
|
5
|
+
* platform (React Native, Kotlin, Swift) renders the same UI states.
|
|
6
|
+
*/
|
|
7
|
+
export const RTP_STATUS = {
|
|
8
|
+
/** Payload arrived and passed validation, but is not yet actionable. */
|
|
9
|
+
RECEIVED: 'RECEIVED',
|
|
10
|
+
/** Expired before it could be actioned. */
|
|
11
|
+
EXPIRED: 'EXPIRED',
|
|
12
|
+
/** Valid, but the user is not authenticated yet (login gate). */
|
|
13
|
+
AWAITING_AUTH: 'AWAITING_AUTH',
|
|
14
|
+
/** Valid, authenticated, and ready for the user to accept/decline. */
|
|
15
|
+
READY: 'READY',
|
|
16
|
+
/** Accept/decline in flight. */
|
|
17
|
+
PROCESSING: 'PROCESSING',
|
|
18
|
+
/** Backend confirmed acceptance. */
|
|
19
|
+
ACCEPTED: 'ACCEPTED',
|
|
20
|
+
/** Backend confirmed (or user initiated) a decline. */
|
|
21
|
+
DECLINED: 'DECLINED',
|
|
22
|
+
/** Terminal failure (network, validation, recipient mismatch, etc.). */
|
|
23
|
+
FAILED: 'FAILED',
|
|
24
|
+
} as const;
|
|
25
|
+
|
|
26
|
+
export type RtpStatus = (typeof RTP_STATUS)[keyof typeof RTP_STATUS];
|
|
27
|
+
|
|
28
|
+
/** Events emitted by the client. */
|
|
29
|
+
export const RTP_EVENT = {
|
|
30
|
+
/** A new, actionable request is available. */
|
|
31
|
+
REQUEST: 'request',
|
|
32
|
+
/** Status transition for a request. */
|
|
33
|
+
STATE: 'state',
|
|
34
|
+
/** Terminal outcome for a request. */
|
|
35
|
+
RESULT: 'result',
|
|
36
|
+
} as const;
|
|
37
|
+
|
|
38
|
+
export type RtpEvent = (typeof RTP_EVENT)[keyof typeof RTP_EVENT];
|
|
39
|
+
|
|
40
|
+
/** Reasons a request can end in FAILED, exposed for UI/diagnostics. */
|
|
41
|
+
export const RTP_ERROR = {
|
|
42
|
+
INVALID_PAYLOAD: 'INVALID_PAYLOAD',
|
|
43
|
+
RECIPIENT_MISMATCH: 'RECIPIENT_MISMATCH',
|
|
44
|
+
NETWORK_ERROR: 'NETWORK_ERROR',
|
|
45
|
+
BACKEND_REJECTED: 'BACKEND_REJECTED',
|
|
46
|
+
UNKNOWN: 'UNKNOWN',
|
|
47
|
+
} as const;
|
|
48
|
+
|
|
49
|
+
export type RtpError = (typeof RTP_ERROR)[keyof typeof RTP_ERROR];
|
package/src/emitter.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { RtpClientOptions, RtpEventMap, RtpUnsubscribe } from './types';
|
|
2
|
+
|
|
3
|
+
type AnyHandler = (payload: any) => void;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Tiny typed event emitter. Deliberately dependency-free so the same contract
|
|
7
|
+
* is trivial to re-implement in Kotlin/Swift.
|
|
8
|
+
*/
|
|
9
|
+
export function createEmitter() {
|
|
10
|
+
const listeners: Record<keyof RtpEventMap, Set<AnyHandler>> = {
|
|
11
|
+
request: new Set(),
|
|
12
|
+
state: new Set(),
|
|
13
|
+
result: new Set(),
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function on<E extends keyof RtpEventMap>(
|
|
17
|
+
event: E,
|
|
18
|
+
handler: RtpEventMap[E]
|
|
19
|
+
): RtpUnsubscribe {
|
|
20
|
+
const set = listeners[event];
|
|
21
|
+
if (!set) {
|
|
22
|
+
throw new Error(`[react-native-rtp] Unknown event: ${String(event)}`);
|
|
23
|
+
}
|
|
24
|
+
set.add(handler as AnyHandler);
|
|
25
|
+
return () => {
|
|
26
|
+
set.delete(handler as AnyHandler);
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function emit<E extends keyof RtpEventMap>(
|
|
31
|
+
event: E,
|
|
32
|
+
payload: Parameters<RtpEventMap[E]>[0]
|
|
33
|
+
): void {
|
|
34
|
+
// Copy to a snapshot so handlers can unsubscribe during emit.
|
|
35
|
+
for (const handler of Array.from(listeners[event])) {
|
|
36
|
+
handler(payload);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function clear(): void {
|
|
41
|
+
listeners.request.clear();
|
|
42
|
+
listeners.state.clear();
|
|
43
|
+
listeners.result.clear();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return { on, emit, clear };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Default logger used when the host does not provide one. */
|
|
50
|
+
export const noopLogger: NonNullable<RtpClientOptions['logger']> = () => {};
|
|
51
|
+
|
|
52
|
+
/** Default clock. */
|
|
53
|
+
export const defaultNow = () => Date.now();
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Core (platform-agnostic)
|
|
2
|
+
export { createRtpClient } from './client';
|
|
3
|
+
export { RTP_STATUS, RTP_EVENT, RTP_ERROR } from './constants';
|
|
4
|
+
export type { RtpStatus, RtpEvent, RtpError } from './constants';
|
|
5
|
+
export {
|
|
6
|
+
isRtpPayload,
|
|
7
|
+
findValidationErrors,
|
|
8
|
+
toRtpRequest,
|
|
9
|
+
isExpired,
|
|
10
|
+
} from './validate';
|
|
11
|
+
export { mapPushToRtp } from './push';
|
|
12
|
+
export type { PushEnvelope } from './push';
|
|
13
|
+
|
|
14
|
+
// Types
|
|
15
|
+
export type {
|
|
16
|
+
RtpPayload,
|
|
17
|
+
RtpParty,
|
|
18
|
+
RtpRequest,
|
|
19
|
+
RtpCredential,
|
|
20
|
+
RtpResult,
|
|
21
|
+
RtpReceiveOutcome,
|
|
22
|
+
RtpTransport,
|
|
23
|
+
RtpStorage,
|
|
24
|
+
RtpHttpResponse,
|
|
25
|
+
RtpClient,
|
|
26
|
+
RtpClientOptions,
|
|
27
|
+
RtpEventMap,
|
|
28
|
+
RtpUnsubscribe,
|
|
29
|
+
} from './types';
|
|
30
|
+
|
|
31
|
+
// React adapter
|
|
32
|
+
export { RtpProvider, useRtp, RtpContext } from './react/RtpProvider';
|
|
33
|
+
export type { RtpProviderProps, RtpContextValue } from './react/RtpProvider';
|
|
34
|
+
|
|
35
|
+
// Mocks (for tests / example app)
|
|
36
|
+
export { createMockTransport } from './mock/mockTransport';
|
|
37
|
+
export type {
|
|
38
|
+
MockTransport,
|
|
39
|
+
MockTransportOptions,
|
|
40
|
+
RecordedCall,
|
|
41
|
+
} from './mock/mockTransport';
|
|
42
|
+
export { makeRtpPayload, makeExpiredPayload } from './mock/makeRtpPayload';
|
|
43
|
+
export type { MakeRtpPayloadOverrides } from './mock/makeRtpPayload';
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { RtpPayload } from '../types';
|
|
2
|
+
|
|
3
|
+
/** Number of ms in a minute, for readable fixture offsets. */
|
|
4
|
+
const MINUTE = 60_000;
|
|
5
|
+
|
|
6
|
+
let counter = 0;
|
|
7
|
+
|
|
8
|
+
export interface MakeRtpPayloadOverrides extends Partial<
|
|
9
|
+
Omit<RtpPayload, 'type' | 'payer' | 'payee' | 'expiresAt'>
|
|
10
|
+
> {
|
|
11
|
+
payer?: Partial<RtpPayload['payer']>;
|
|
12
|
+
payee?: Partial<RtpPayload['payee']>;
|
|
13
|
+
/** Milliseconds from `now` until expiry (negative = already expired). */
|
|
14
|
+
expiresInMs?: number;
|
|
15
|
+
/** Base time to compute expiry from. Defaults to Date.now(). */
|
|
16
|
+
from?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Build a valid RTP payload for tests, fixtures and the example app.
|
|
21
|
+
* Every field is overridable so edge cases are trivial to express.
|
|
22
|
+
*/
|
|
23
|
+
export function makeRtpPayload(
|
|
24
|
+
overrides: MakeRtpPayloadOverrides = {}
|
|
25
|
+
): RtpPayload {
|
|
26
|
+
const {
|
|
27
|
+
payer,
|
|
28
|
+
payee,
|
|
29
|
+
expiresInMs = 15 * MINUTE,
|
|
30
|
+
from = Date.now(),
|
|
31
|
+
...rest
|
|
32
|
+
} = overrides;
|
|
33
|
+
|
|
34
|
+
counter += 1;
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
version: '1.0',
|
|
38
|
+
type: 'RTP',
|
|
39
|
+
rtpId: `RTP-${from}-${counter}`,
|
|
40
|
+
amount: 250000,
|
|
41
|
+
currency: 'NGN',
|
|
42
|
+
narration: 'Payment for invoice 1042',
|
|
43
|
+
transactionRef: `TRX-${from}-${counter}`,
|
|
44
|
+
recipient: 'john.doe@quickteller.com',
|
|
45
|
+
expiresAt: new Date(from + expiresInMs).toISOString(),
|
|
46
|
+
payer: {
|
|
47
|
+
name: 'Ajibola Shadrach',
|
|
48
|
+
accountNumber: '0123456789',
|
|
49
|
+
bank: 'Quickteller MFB',
|
|
50
|
+
...payer,
|
|
51
|
+
},
|
|
52
|
+
payee: {
|
|
53
|
+
name: 'Michael Chinweike',
|
|
54
|
+
accountNumber: '9876543210',
|
|
55
|
+
...payee,
|
|
56
|
+
},
|
|
57
|
+
...rest,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A payload that has already expired. */
|
|
62
|
+
export function makeExpiredPayload(
|
|
63
|
+
overrides: MakeRtpPayloadOverrides = {}
|
|
64
|
+
): RtpPayload {
|
|
65
|
+
return makeRtpPayload({ ...overrides, expiresInMs: -MINUTE });
|
|
66
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { RtpHttpResponse, RtpTransport } from '../types';
|
|
2
|
+
|
|
3
|
+
export interface MockTransportOptions {
|
|
4
|
+
/** Simulated network latency in ms. */
|
|
5
|
+
latencyMs?: number;
|
|
6
|
+
/** Simulate a network failure on accept. */
|
|
7
|
+
failAccept?: boolean;
|
|
8
|
+
/** Simulate a network failure on decline. */
|
|
9
|
+
failDecline?: boolean;
|
|
10
|
+
/** Backend status to return for accept. */
|
|
11
|
+
acceptStatus?: 'ACCEPTED' | 'DECLINED' | 'FAILED';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface RecordedCall {
|
|
15
|
+
method: 'post' | 'get';
|
|
16
|
+
path: string;
|
|
17
|
+
body?: unknown;
|
|
18
|
+
headers?: Record<string, string>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface MockTransport {
|
|
22
|
+
transport: RtpTransport;
|
|
23
|
+
/** Recorded calls, for assertions in tests and the example app. */
|
|
24
|
+
calls: RecordedCall[];
|
|
25
|
+
/** Clear recorded calls. */
|
|
26
|
+
reset: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const extractId = (path: string): string => {
|
|
30
|
+
const parts = path.split('/');
|
|
31
|
+
return parts[parts.length - 2] ?? '';
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Deterministic in-memory transport. No real network calls are made.
|
|
36
|
+
*/
|
|
37
|
+
export function createMockTransport(
|
|
38
|
+
options: MockTransportOptions = {}
|
|
39
|
+
): MockTransport {
|
|
40
|
+
const {
|
|
41
|
+
latencyMs = 250,
|
|
42
|
+
failAccept = false,
|
|
43
|
+
failDecline = false,
|
|
44
|
+
acceptStatus = 'ACCEPTED',
|
|
45
|
+
} = options;
|
|
46
|
+
|
|
47
|
+
const calls: RecordedCall[] = [];
|
|
48
|
+
const delay = () =>
|
|
49
|
+
new Promise<void>((resolve) => setTimeout(resolve, latencyMs));
|
|
50
|
+
|
|
51
|
+
const transport: RtpTransport = {
|
|
52
|
+
async post<T = any>(
|
|
53
|
+
path: string,
|
|
54
|
+
body?: unknown,
|
|
55
|
+
headers?: Record<string, string>
|
|
56
|
+
): Promise<RtpHttpResponse<T>> {
|
|
57
|
+
calls.push({ method: 'post', path, body, headers });
|
|
58
|
+
await delay();
|
|
59
|
+
|
|
60
|
+
if (path.endsWith('/accept')) {
|
|
61
|
+
if (failAccept) {
|
|
62
|
+
throw new Error('Mock network failure on accept');
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
status: 200,
|
|
66
|
+
data: { id: extractId(path), status: acceptStatus } as T,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (path.endsWith('/decline')) {
|
|
71
|
+
if (failDecline) {
|
|
72
|
+
throw new Error('Mock network failure on decline');
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
status: 200,
|
|
76
|
+
data: { id: extractId(path), status: 'DECLINED' } as T,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { status: 200, data: {} as T };
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
async get<T = any>(
|
|
84
|
+
path: string,
|
|
85
|
+
headers?: Record<string, string>
|
|
86
|
+
): Promise<RtpHttpResponse<T>> {
|
|
87
|
+
calls.push({ method: 'get', path, headers });
|
|
88
|
+
await delay();
|
|
89
|
+
return {
|
|
90
|
+
status: 200,
|
|
91
|
+
data: { id: extractId(path), status: 'PENDING' } as T,
|
|
92
|
+
};
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
transport,
|
|
98
|
+
calls,
|
|
99
|
+
reset: () => {
|
|
100
|
+
calls.length = 0;
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
package/src/push.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { isRtpPayload } from './validate';
|
|
2
|
+
import type { RtpPayload } from './types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A generic push notification envelope.
|
|
6
|
+
*
|
|
7
|
+
* The SDK does not know or care what a push looks like — every host platform
|
|
8
|
+
* has a different shape, and this is the single seam where that difference is
|
|
9
|
+
* absorbed. A Kotlin/Swift integration implements the equivalent of
|
|
10
|
+
* `mapPushToRtp` against the same `RtpPayload` contract.
|
|
11
|
+
*/
|
|
12
|
+
export interface PushEnvelope {
|
|
13
|
+
id?: string;
|
|
14
|
+
title?: string;
|
|
15
|
+
message?: string;
|
|
16
|
+
foreground?: boolean;
|
|
17
|
+
/** RTCP `onRemoteNotification` style payload. */
|
|
18
|
+
data?: Record<string, unknown>;
|
|
19
|
+
/** RTCP `onNotificationTapped` flattens the body to the top level. */
|
|
20
|
+
body?: string;
|
|
21
|
+
[key: string]: unknown;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Extract an RTP payload from a push envelope, or return null if the push is
|
|
26
|
+
* not an RTP request.
|
|
27
|
+
*
|
|
28
|
+
* RTCP delivers two distinct shapes for the same notification:
|
|
29
|
+
*
|
|
30
|
+
* 1. `onRemoteNotification` (foreground/background):
|
|
31
|
+
* `{ data: { app_data: { type: 'RTP', ... } } }`
|
|
32
|
+
*
|
|
33
|
+
* 2. `onNotificationTapped`: scalars are stringified and the custom payload
|
|
34
|
+
* arrives as a JSON string:
|
|
35
|
+
* `{ data: { app_data: '{"type":"RTP",...}' } }`
|
|
36
|
+
*
|
|
37
|
+
* Both are normalized here so callers only ever deal with `RtpPayload`.
|
|
38
|
+
*
|
|
39
|
+
* `getData` is injectable so hosts that expose push data differently (e.g. a
|
|
40
|
+
* Map-like object on native) can plug in their accessor without changing the
|
|
41
|
+
* rest of the SDK.
|
|
42
|
+
*/
|
|
43
|
+
export function mapPushToRtp(
|
|
44
|
+
envelope: PushEnvelope | null | undefined,
|
|
45
|
+
getData?: (envelope: PushEnvelope) => Record<string, unknown> | undefined
|
|
46
|
+
): RtpPayload | null {
|
|
47
|
+
if (!envelope) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const data = (getData ? getData(envelope) : envelope.data) ?? {};
|
|
52
|
+
|
|
53
|
+
// Some providers nest the custom payload under `app_data` (RTCP/FCM).
|
|
54
|
+
const candidate = unwrapAppData(data);
|
|
55
|
+
|
|
56
|
+
if (!isRtpPayload(candidate)) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return normalizePayload(candidate);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* RTCP/FCM delivers the custom payload as a JSON string under `app_data`.
|
|
65
|
+
* The tapped shape sits one level deeper, under `data` / `body`.
|
|
66
|
+
*/
|
|
67
|
+
function unwrapAppData(data: Record<string, unknown>): Record<string, unknown> {
|
|
68
|
+
let source = data;
|
|
69
|
+
|
|
70
|
+
// Tapped shape: `{ time, app_id, message, read, push_id, app_data }`
|
|
71
|
+
// Received shape: `{ app_data, message, app_id, push_id, time, read }`
|
|
72
|
+
// In both cases `app_data` holds the custom payload.
|
|
73
|
+
const appData = source.app_data;
|
|
74
|
+
if (appData === undefined) {
|
|
75
|
+
return source;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let parsed: Record<string, unknown> = {};
|
|
79
|
+
if (typeof appData === 'string') {
|
|
80
|
+
try {
|
|
81
|
+
parsed = JSON.parse(appData) as Record<string, unknown>;
|
|
82
|
+
} catch {
|
|
83
|
+
return source;
|
|
84
|
+
}
|
|
85
|
+
} else if (appData && typeof appData === 'object') {
|
|
86
|
+
parsed = appData as Record<string, unknown>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { ...source, ...parsed };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Push transports stringify scalars. Coerce the fields the SDK cares about so
|
|
94
|
+
* the decoded payload always has stable types (this is what makes the payload
|
|
95
|
+
* portable across platforms).
|
|
96
|
+
*/
|
|
97
|
+
function normalizePayload(payload: Record<string, unknown>): RtpPayload {
|
|
98
|
+
const normalized: Record<string, unknown> = { ...payload };
|
|
99
|
+
|
|
100
|
+
// `amount` may arrive as "250000" on tapped notifications.
|
|
101
|
+
if (typeof normalized.amount === 'string') {
|
|
102
|
+
const parsed = Number(normalized.amount);
|
|
103
|
+
if (!Number.isNaN(parsed)) {
|
|
104
|
+
normalized.amount = parsed;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return normalized as unknown as RtpPayload;
|
|
109
|
+
}
|