@nile-squad/nylonpay-ts 1.1.0 → 1.2.1

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 CHANGED
@@ -41,7 +41,7 @@ Use your test keys to work in sandbox, or your production keys to go live. There
41
41
  |---|---|---|---|
42
42
  | `apiKey` | Yes | | Must start with `npk_` |
43
43
  | `apiSecret` | Yes | | Must start with `nps_` |
44
- | `baseUrl` | No | Default is used | Override only if self-hosting |
44
+ | `baseUrl` | No | Default is used | Override for a custom endpoint |
45
45
  | `timeoutMs` | No | `30000` | Request timeout in milliseconds |
46
46
  | `maxRetries` | No | `3` | Retry count for failed requests |
47
47
  | `maxPollIntervalMs` | No | `2000` | Polling interval for async payments |
@@ -231,16 +231,13 @@ if (!result.isOk) {
231
231
 
232
232
  `USD`, `EUR`, `GBP`, `KES`, `UGX`, `TZS`, `RWF`
233
233
 
234
- ## Development
234
+ ## Links
235
235
 
236
- Maintainer notes and pending work live in [`dev-note.md`](./dev-note.md).
237
-
238
- ```sh
239
- pnpm install
240
- pnpm test # vitest
241
- pnpm typecheck # tsc --noEmit
242
- pnpm build # tsup
243
- ```
236
+ - [Documentation](https://docs.nylonpay.nilesquad.com/docs)
237
+ - [SDK Spec](https://github.com/nile-squad/specs/blob/main/nylonpay-sdk-spec/spec.md)
238
+ - [GitHub Repository](https://github.com/nile-squad/nylonpay-ts)
239
+ - [Python SDK](https://github.com/nile-squad/nylonpay-py)
240
+ - [Nylon Pay](https://nylonpay.nilesquad.com)
244
241
 
245
242
  ## License
246
243
 
package/dist/index.cjs CHANGED
@@ -76,6 +76,7 @@ var SDK_ACTIONS = {
76
76
  createInvoice: "sdk-create-invoice"
77
77
  };
78
78
  var RETRYABLE_STATUS_CODES = /* @__PURE__ */ new Set([408, 429, 500, 502, 503, 504]);
79
+ var MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
79
80
  function generateFingerprint() {
80
81
  const components = [
81
82
  `type:${os.type()}`,
@@ -255,13 +256,13 @@ function createTransport({
255
256
  async function send(request) {
256
257
  const envelope = buildEnvelope(request);
257
258
  const signedPayload = envelope.payload;
258
- const headers = buildAuthHeaders({
259
- apiKey,
260
- apiSecret,
261
- payload: signedPayload
262
- });
263
259
  const bodyString = JSON.stringify(envelope);
264
260
  async function attempt(currentAttempt) {
261
+ const headers = buildAuthHeaders({
262
+ apiKey,
263
+ apiSecret,
264
+ payload: signedPayload
265
+ });
265
266
  const { controller, cleanup } = withTimeout(timeoutMs);
266
267
  try {
267
268
  const response = await fetchImpl(baseUrl, {
@@ -270,6 +271,17 @@ function createTransport({
270
271
  body: bodyString,
271
272
  signal: controller.signal
272
273
  });
274
+ const contentLength = response.headers?.get("content-length");
275
+ if (contentLength && Number(contentLength) > MAX_RESPONSE_BYTES) {
276
+ cleanup();
277
+ return slangTs.Err(
278
+ JSON.stringify({
279
+ category: "internal",
280
+ message: "Received an invalid response from the server",
281
+ retryable: false
282
+ })
283
+ );
284
+ }
273
285
  if (!response.ok) {
274
286
  const statusCode = response.status;
275
287
  const retryable = RETRYABLE_STATUS_CODES.has(statusCode);
@@ -300,7 +312,7 @@ function createTransport({
300
312
  return slangTs.Err(
301
313
  JSON.stringify({
302
314
  category: "internal",
303
- message: "Response missing status field",
315
+ message: "Received an invalid response from the server",
304
316
  retryable: false
305
317
  })
306
318
  );
@@ -313,7 +325,7 @@ function createTransport({
313
325
  return slangTs.Err(
314
326
  JSON.stringify({
315
327
  category: "internal",
316
- message: "Response signature missing",
328
+ message: "Could not verify the server response",
317
329
  retryable: false
318
330
  })
319
331
  );
@@ -328,7 +340,7 @@ function createTransport({
328
340
  return slangTs.Err(
329
341
  JSON.stringify({
330
342
  category: "internal",
331
- message: "Response signature verification failed",
343
+ message: "Could not verify the server response",
332
344
  retryable: false
333
345
  })
334
346
  );
@@ -344,7 +356,7 @@ function createTransport({
344
356
  const isAbort = error instanceof DOMException && error.name === "AbortError";
345
357
  const sdkError = {
346
358
  category: isAbort ? "timeout" : "network",
347
- message: isAbort ? `Request timed out after ${timeoutMs}ms` : String(error),
359
+ message: isAbort ? "The request timed out" : "Could not reach the server, check your network connection and try again",
348
360
  retryable: true
349
361
  };
350
362
  if (currentAttempt < maxRetries) {
@@ -410,10 +422,16 @@ function createPaymentInstance(initialResponse, deps) {
410
422
  maxPollDuration: deps.maxPollDuration ?? 3e5,
411
423
  maxPollAttempts: deps.maxPollAttempts ?? 150
412
424
  };
413
- function resolveWithError(error) {
425
+ function resolveWithError(error, category, retryable) {
414
426
  state.resolved = true;
415
427
  stopUpdates();
416
- emitEvent("error", parseError(error).message);
428
+ const parsed = parseError(error);
429
+ emitEvent(
430
+ "error",
431
+ parsed.message,
432
+ category ?? parsed.category,
433
+ parsed.retryable
434
+ );
417
435
  }
418
436
  function emitEvent(event, error, category, retryable) {
419
437
  const data = {
@@ -450,7 +468,8 @@ function createPaymentInstance(initialResponse, deps) {
450
468
  }
451
469
  if (response.reference !== state.reference) {
452
470
  resolveWithError(
453
- `Reference mismatch: expected ${state.reference} but got ${response.reference}`
471
+ "Received a status update for a different transaction",
472
+ "internal"
454
473
  );
455
474
  return;
456
475
  }
@@ -472,7 +491,7 @@ function createPaymentInstance(initialResponse, deps) {
472
491
  if (parsed.category === "not_found") {
473
492
  return;
474
493
  }
475
- emitEvent("error", parsed.message);
494
+ emitEvent("error", parsed.message, parsed.category, parsed.retryable);
476
495
  state.resolved = true;
477
496
  stopUpdates();
478
497
  }
@@ -492,11 +511,17 @@ function createPaymentInstance(initialResponse, deps) {
492
511
  return;
493
512
  }
494
513
  if (state.pollAttempts >= state.maxPollAttempts) {
495
- resolveWithError("Polling timeout: exceeded maximum attempts");
514
+ resolveWithError(
515
+ "Timed out waiting for the transaction status to update",
516
+ "timeout"
517
+ );
496
518
  return;
497
519
  }
498
520
  if (Date.now() - state.pollStartTime >= state.maxPollDuration) {
499
- resolveWithError("Polling timeout: exceeded maximum duration");
521
+ resolveWithError(
522
+ "Timed out waiting for the transaction status to update",
523
+ "timeout"
524
+ );
500
525
  return;
501
526
  }
502
527
  state.pollAttempts += 1;
@@ -616,6 +641,9 @@ function normalizePhone(phone) {
616
641
  }
617
642
  return normalized;
618
643
  }
644
+ function isValidPhoneFormat(normalizedPhone) {
645
+ return /^\d{9,15}$/.test(normalizedPhone);
646
+ }
619
647
  var DEFAULT_TOLERANCE_SECONDS = 300;
620
648
  function decodePayload(payload) {
621
649
  return typeof payload === "string" ? payload : Buffer.from(payload).toString("utf8");
@@ -641,27 +669,34 @@ function extractSignedTimestampMs(payloadString) {
641
669
  return null;
642
670
  }
643
671
  function verifyWebhookSignature(input) {
644
- const payloadString = decodePayload(input.payload);
645
- const payloadBytes = Buffer.from(payloadString, "utf8");
646
- const expectedSignature = crypto.createHmac("sha256", input.secret).update(payloadBytes).digest("hex");
647
- const providedBuffer = Buffer.from(input.signature, "hex");
648
- const expectedBuffer = Buffer.from(expectedSignature, "hex");
649
- if (providedBuffer.length !== expectedBuffer.length) {
650
- return false;
651
- }
652
- if (!crypto.timingSafeEqual(providedBuffer, expectedBuffer)) {
653
- return false;
654
- }
655
- const toleranceSeconds = input.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS;
656
- if (toleranceSeconds <= 0) {
657
- return true;
658
- }
659
- const timestampMs = extractSignedTimestampMs(payloadString);
660
- if (timestampMs === null) {
672
+ try {
673
+ const payloadString = decodePayload(input.payload);
674
+ const payloadBytes = Buffer.from(payloadString, "utf8");
675
+ const expectedSignature = crypto.createHmac("sha256", input.secret).update(payloadBytes).digest("hex");
676
+ const providedBuffer = Buffer.from(input.signature, "hex");
677
+ const expectedBuffer = Buffer.from(expectedSignature, "hex");
678
+ if (providedBuffer.length !== expectedBuffer.length) {
679
+ return false;
680
+ }
681
+ if (!crypto.timingSafeEqual(providedBuffer, expectedBuffer)) {
682
+ return false;
683
+ }
684
+ const toleranceSeconds = input.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS;
685
+ if (toleranceSeconds === 0) {
686
+ return true;
687
+ }
688
+ if (toleranceSeconds < 0) {
689
+ return false;
690
+ }
691
+ const timestampMs = extractSignedTimestampMs(payloadString);
692
+ if (timestampMs === null) {
693
+ return false;
694
+ }
695
+ const ageMs = Math.abs(Date.now() - timestampMs);
696
+ return ageMs <= toleranceSeconds * 1e3;
697
+ } catch {
661
698
  return false;
662
699
  }
663
- const ageMs = Math.abs(Date.now() - timestampMs);
664
- return ageMs <= toleranceSeconds * 1e3;
665
700
  }
666
701
 
667
702
  // src/sdk.ts
@@ -712,6 +747,56 @@ function validateNonEmpty(value, fieldName) {
712
747
  throwValidation(`${fieldName} is required`);
713
748
  }
714
749
  }
750
+ function validatePhoneFormat(normalizedPhone, fieldName) {
751
+ if (!isValidPhoneFormat(normalizedPhone)) {
752
+ throwValidation(`${fieldName} must be a valid phone number`);
753
+ }
754
+ }
755
+ function prepareCollectPayload(input) {
756
+ const reference = resolveReference(input.reference);
757
+ validateCollectionAmount(input.amount);
758
+ validateNonEmpty(input.customer.name, "customer.name");
759
+ validateNonEmpty(input.customer.phoneNumber, "customer.phoneNumber");
760
+ const normalizedPhone = normalizePhone(input.customer.phoneNumber);
761
+ validatePhoneFormat(normalizedPhone, "customer.phoneNumber");
762
+ validateNonEmpty(input.description, "description");
763
+ if (input.method === "bank" && !input.bank) {
764
+ throwValidation('bank details are required when method is "bank"');
765
+ }
766
+ return {
767
+ ...input,
768
+ reference,
769
+ customer: { ...input.customer, phoneNumber: normalizedPhone }
770
+ };
771
+ }
772
+ function preparePayoutPayload(input) {
773
+ const reference = resolveReference(input.reference);
774
+ validatePayoutAmount(input.amount);
775
+ validateNonEmpty(input.customer.name, "customer.name");
776
+ validateNonEmpty(input.customer.phoneNumber, "customer.phoneNumber");
777
+ const normalizedPhone = normalizePhone(input.customer.phoneNumber);
778
+ validatePhoneFormat(normalizedPhone, "customer.phoneNumber");
779
+ validateNonEmpty(input.description, "description");
780
+ validateNonEmpty(
781
+ input.destination.accountHolderName,
782
+ "destination.accountHolderName"
783
+ );
784
+ validateNonEmpty(
785
+ input.destination.accountNumber,
786
+ "destination.accountNumber"
787
+ );
788
+ return {
789
+ ...input,
790
+ reference,
791
+ customer: { ...input.customer, phoneNumber: normalizedPhone }
792
+ };
793
+ }
794
+ function applyBeforeHookMutation(mutated, current, prepare) {
795
+ return prepare({
796
+ ...mutated,
797
+ reference: mutated.reference ?? current.reference
798
+ });
799
+ }
715
800
  function createSdkInstance(config) {
716
801
  const transport = createTransport({
717
802
  apiKey: config.apiKey,
@@ -735,23 +820,15 @@ function createSdkInstance(config) {
735
820
  maxPollAttempts: config.maxPollAttempts
736
821
  };
737
822
  async function collectPayment(input) {
738
- const reference = resolveReference(input.reference);
739
- validateCollectionAmount(input.amount);
740
- validateNonEmpty(input.customer.name, "customer.name");
741
- validateNonEmpty(input.customer.phoneNumber, "customer.phoneNumber");
742
- const normalizedPhone = normalizePhone(input.customer.phoneNumber);
743
- validateNonEmpty(input.description, "description");
744
- if (input.method === "bank" && !input.bank) {
745
- throwValidation('bank details are required when method is "bank"');
746
- }
747
- let payload = {
748
- ...input,
749
- reference,
750
- customer: { ...input.customer, phoneNumber: normalizedPhone }
751
- };
823
+ let payload = prepareCollectPayload(input);
752
824
  const mutated = await runHook(config.hooks?.beforeCollect, payload);
753
- if (mutated != null)
754
- payload = { ...mutated, reference: mutated.reference ?? reference };
825
+ if (mutated != null) {
826
+ payload = applyBeforeHookMutation(
827
+ mutated,
828
+ payload,
829
+ prepareCollectPayload
830
+ );
831
+ }
755
832
  const result = await transport.send({
756
833
  action: SDK_ACTIONS.collectPayment,
757
834
  payload
@@ -759,35 +836,27 @@ function createSdkInstance(config) {
759
836
  await runHook(
760
837
  config.hooks?.afterCollect,
761
838
  result.isOk ? slangTs.Ok({ reference: result.value.reference, status: result.value.status }) : slangTs.Err(result.error),
762
- payload
839
+ { ...payload, raw: input }
763
840
  );
764
841
  if (result.isErr) {
765
842
  const sdkErr = parseError(result.error);
766
843
  return createPaymentInstance(
767
- { reference, status: "pending" },
844
+ { reference: payload.reference, status: "pending" },
768
845
  { ...commonDeps, initialError: sdkErr }
769
846
  );
770
847
  }
771
848
  return createPaymentInstance(result.value, commonDeps);
772
849
  }
773
850
  async function collectPaymentAndResolve(input) {
774
- const reference = resolveReference(input.reference);
775
- validateCollectionAmount(input.amount);
776
- validateNonEmpty(input.customer.name, "customer.name");
777
- validateNonEmpty(input.customer.phoneNumber, "customer.phoneNumber");
778
- const normalizedPhone = normalizePhone(input.customer.phoneNumber);
779
- validateNonEmpty(input.description, "description");
780
- if (input.method === "bank" && !input.bank) {
781
- throwValidation('bank details are required when method is "bank"');
782
- }
783
- let payload = {
784
- ...input,
785
- reference,
786
- customer: { ...input.customer, phoneNumber: normalizedPhone }
787
- };
851
+ let payload = prepareCollectPayload(input);
788
852
  const mutated = await runHook(config.hooks?.beforeCollect, payload);
789
- if (mutated != null)
790
- payload = { ...mutated, reference: mutated.reference ?? reference };
853
+ if (mutated != null) {
854
+ payload = applyBeforeHookMutation(
855
+ mutated,
856
+ payload,
857
+ prepareCollectPayload
858
+ );
859
+ }
791
860
  const result = await transport.send({
792
861
  action: SDK_ACTIONS.collectPaymentAndResolve,
793
862
  payload
@@ -795,7 +864,7 @@ function createSdkInstance(config) {
795
864
  await runHook(
796
865
  config.hooks?.afterCollect,
797
866
  result.isOk ? slangTs.Ok({ reference: result.value.reference, status: result.value.status }) : slangTs.Err(result.error),
798
- payload
867
+ { ...payload, raw: input }
799
868
  );
800
869
  if (result.isOk) {
801
870
  return slangTs.Ok(result.value);
@@ -803,28 +872,11 @@ function createSdkInstance(config) {
803
872
  return slangTs.Err(result.error);
804
873
  }
805
874
  async function makePayout(input) {
806
- const reference = resolveReference(input.reference);
807
- validatePayoutAmount(input.amount);
808
- validateNonEmpty(input.customer.name, "customer.name");
809
- validateNonEmpty(input.customer.phoneNumber, "customer.phoneNumber");
810
- const normalizedPhone = normalizePhone(input.customer.phoneNumber);
811
- validateNonEmpty(input.description, "description");
812
- validateNonEmpty(
813
- input.destination.accountHolderName,
814
- "destination.accountHolderName"
815
- );
816
- validateNonEmpty(
817
- input.destination.accountNumber,
818
- "destination.accountNumber"
819
- );
820
- let payload = {
821
- ...input,
822
- reference,
823
- customer: { ...input.customer, phoneNumber: normalizedPhone }
824
- };
875
+ let payload = preparePayoutPayload(input);
825
876
  const mutated = await runHook(config.hooks?.beforePayout, payload);
826
- if (mutated != null)
827
- payload = { ...mutated, reference: mutated.reference ?? reference };
877
+ if (mutated != null) {
878
+ payload = applyBeforeHookMutation(mutated, payload, preparePayoutPayload);
879
+ }
828
880
  const result = await transport.send({
829
881
  action: SDK_ACTIONS.makePayout,
830
882
  payload
@@ -832,40 +884,23 @@ function createSdkInstance(config) {
832
884
  await runHook(
833
885
  config.hooks?.afterPayout,
834
886
  result.isOk ? slangTs.Ok({ reference: result.value.reference, status: result.value.status }) : slangTs.Err(result.error),
835
- payload
887
+ { ...payload, raw: input }
836
888
  );
837
889
  if (result.isErr) {
838
890
  const sdkErr = parseError(result.error);
839
891
  return createPaymentInstance(
840
- { reference, status: "pending" },
892
+ { reference: payload.reference, status: "pending" },
841
893
  { ...commonDeps, initialError: sdkErr }
842
894
  );
843
895
  }
844
896
  return createPaymentInstance(result.value, commonDeps);
845
897
  }
846
898
  async function makePayoutAndResolve(input) {
847
- const reference = resolveReference(input.reference);
848
- validatePayoutAmount(input.amount);
849
- validateNonEmpty(input.customer.name, "customer.name");
850
- validateNonEmpty(input.customer.phoneNumber, "customer.phoneNumber");
851
- const normalizedPhone = normalizePhone(input.customer.phoneNumber);
852
- validateNonEmpty(input.description, "description");
853
- validateNonEmpty(
854
- input.destination.accountHolderName,
855
- "destination.accountHolderName"
856
- );
857
- validateNonEmpty(
858
- input.destination.accountNumber,
859
- "destination.accountNumber"
860
- );
861
- let payload = {
862
- ...input,
863
- reference,
864
- customer: { ...input.customer, phoneNumber: normalizedPhone }
865
- };
899
+ let payload = preparePayoutPayload(input);
866
900
  const mutated = await runHook(config.hooks?.beforePayout, payload);
867
- if (mutated != null)
868
- payload = { ...mutated, reference: mutated.reference ?? reference };
901
+ if (mutated != null) {
902
+ payload = applyBeforeHookMutation(mutated, payload, preparePayoutPayload);
903
+ }
869
904
  const result = await transport.send({
870
905
  action: SDK_ACTIONS.makePayoutAndResolve,
871
906
  payload
@@ -873,7 +908,7 @@ function createSdkInstance(config) {
873
908
  await runHook(
874
909
  config.hooks?.afterPayout,
875
910
  result.isOk ? slangTs.Ok({ reference: result.value.reference, status: result.value.status }) : slangTs.Err(result.error),
876
- payload
911
+ { ...payload, raw: input }
877
912
  );
878
913
  if (result.isOk) {
879
914
  return slangTs.Ok(result.value);
@@ -907,6 +942,7 @@ function createSdkInstance(config) {
907
942
  async function verifyPhone(input) {
908
943
  validateNonEmpty(input.phoneNumber, "phoneNumber");
909
944
  const normalizedPhone = normalizePhone(input.phoneNumber);
945
+ validatePhoneFormat(normalizedPhone, "phoneNumber");
910
946
  const result = await transport.send({
911
947
  action: SDK_ACTIONS.verifyPhone,
912
948
  payload: { ...input, phoneNumber: normalizedPhone }