@nile-squad/nylonpay-ts 1.0.10 → 1.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.
package/dist/index.d.ts CHANGED
@@ -178,6 +178,13 @@ type Transaction = {
178
178
  * payment was initiated; use a fresh reference to start a new one.
179
179
  */
180
180
  duplicate?: boolean;
181
+ /**
182
+ * The underlying operator's (telco's/bank's) own transaction id — what the
183
+ * paying customer sees on their receipt. Use it to cross-validate customer
184
+ * pay claims. Null until the operator reports it (typically at terminal
185
+ * status); may be absent on older backend versions.
186
+ */
187
+ operatorTid?: string | null;
181
188
  phone: string;
182
189
  email: string | null;
183
190
  failureReason: string | null;
@@ -235,16 +242,27 @@ type WebhookPayload = {
235
242
  * Async hooks are awaited before the transport call proceeds.
236
243
  */
237
244
  type BeforeCollectHook = (input: CollectPaymentInput) => CollectPaymentInput | undefined | Promise<CollectPaymentInput | undefined>;
245
+ /**
246
+ * The input handed to an `after*` hook. It is the final wire payload — reference
247
+ * resolved, phone normalized, and any `before*`-hook mutations applied — so a
248
+ * hook observes exactly what was sent. The `raw` property additionally carries
249
+ * the untouched original merchant input (pre-normalization, pre-`before*`-hook),
250
+ * so audit logs can record both what the merchant typed and what hit the wire.
251
+ */
252
+ type AfterHookInput<TInput> = TInput & {
253
+ raw: TInput;
254
+ };
238
255
  /**
239
256
  * Called after every collect call (both fire-and-forget and resolve variants)
240
257
  * regardless of outcome. Use for logging, analytics, or side-effects.
241
258
  * The result is normalized to `{ reference, status }` across both variants.
259
+ * `input` is the sent payload; `input.raw` is the original merchant input.
242
260
  * Return value is ignored.
243
261
  */
244
262
  type AfterCollectHook = (result: Result<{
245
263
  reference: string;
246
264
  status: string;
247
- }, string>, input: CollectPaymentInput) => void | Promise<void>;
265
+ }, string>, input: AfterHookInput<CollectPaymentInput>) => void | Promise<void>;
248
266
  /**
249
267
  * Called before a payout payload is sent to the server.
250
268
  * Same semantics as {@link BeforeCollectHook}.
@@ -258,7 +276,7 @@ type BeforePayoutHook = (input: MakePayoutInput) => MakePayoutInput | undefined
258
276
  type AfterPayoutHook = (result: Result<{
259
277
  reference: string;
260
278
  status: string;
261
- }, string>, input: MakePayoutInput) => void | Promise<void>;
279
+ }, string>, input: AfterHookInput<MakePayoutInput>) => void | Promise<void>;
262
280
  /**
263
281
  * Wrapper applied to every lifecycle hook. The SDK runs `fn` inside `safeTry`,
264
282
  * so a throw or rejection in merchant code never bubbles into the payment flow —
@@ -348,15 +366,17 @@ type SdkError = {
348
366
  retryable?: boolean;
349
367
  };
350
368
  /**
351
- * Data passed to every payment event handler. `transaction` is populated
352
- * for status-change events (`processing`, `success`, `failed`, `cancelled`);
353
- * `error` is populated for the `"error"` event (network failure, timeout,
354
- * reference mismatch).
369
+ * Data passed to every payment event handler. `reference` is always present;
370
+ * `transaction` is populated for terminal status events (`success`, `failed`,
371
+ * `cancelled`) the `processing` event can fire before the full record is
372
+ * fetched, so use `reference` there. `error` is populated for the `"error"`
373
+ * event (network failure, timeout, reference mismatch).
355
374
  *
356
375
  * @example
357
376
  * ```ts
358
377
  * payment.on("success", (data: EventData) => {
359
- * console.log(data.transaction?.reference); // "ORDER-2026-001"
378
+ * console.log(data.reference); // "ORDER-2026-001"
379
+ * console.log(data.transaction?.id);
360
380
  * console.log(data.timestamp); // "2026-05-30T12:00:00.000Z"
361
381
  * });
362
382
  * ```
@@ -364,7 +384,13 @@ type SdkError = {
364
384
  type EventData = {
365
385
  /** The event that triggered this handler. */
366
386
  event: PaymentEvent;
367
- /** Full transaction record. Present for status-change events. */
387
+ /**
388
+ * The transaction reference. Always present — available on every event,
389
+ * including lifecycle events fired before the full transaction record
390
+ * has been fetched.
391
+ */
392
+ reference: string;
393
+ /** Full transaction record. Present for terminal status events. */
368
394
  transaction?: Transaction;
369
395
  /** Error message. Present for the `"error"` event. */
370
396
  error?: string;
@@ -713,4 +739,4 @@ declare function parseError(error: string): SdkError;
713
739
  */
714
740
  declare function verifyWebhookSignature(input: VerifyWebhookInput): boolean;
715
741
 
716
- export { type AfterCollectHook, type AfterPayoutHook, type BankDetails, type BeforeCollectHook, type BeforePayoutHook, type CollectPaymentInput, type CreateInvoiceInput, type Currency, type Customer, type Destination, type EventData, type GetStatusInput, type GetTransactionInput, type InvoiceItem, type InvoiceResponse, type MakePayoutInput, type NylonPayConfig, type NylonPaySdk, type PaymentEvent, type PaymentEventHandler, type PaymentInstance, type PaymentMethod, type PhoneVerification, type SdkError, type SdkErrorCategory, type SdkHooks, type StatusResponse, type Transaction, type TransactionMode, type TransactionStatus, type TransactionType, type VerifyPhoneInput, type VerifyWebhookInput, type WebhookEventType, type WebhookPayload, createNylonPay, createSdkError, parseError, verifyWebhookSignature };
742
+ export { type AfterCollectHook, type AfterHookInput, type AfterPayoutHook, type BankDetails, type BeforeCollectHook, type BeforePayoutHook, type CollectPaymentInput, type CreateInvoiceInput, type Currency, type Customer, type Destination, type EventData, type GetStatusInput, type GetTransactionInput, type InvoiceItem, type InvoiceResponse, type MakePayoutInput, type NylonPayConfig, type NylonPaySdk, type PaymentEvent, type PaymentEventHandler, type PaymentInstance, type PaymentMethod, type PhoneVerification, type SdkError, type SdkErrorCategory, type SdkHooks, type StatusResponse, type Transaction, type TransactionMode, type TransactionStatus, type TransactionType, type VerifyPhoneInput, type VerifyWebhookInput, type WebhookEventType, type WebhookPayload, createNylonPay, createSdkError, parseError, verifyWebhookSignature };
package/dist/index.js CHANGED
@@ -253,13 +253,13 @@ function createTransport({
253
253
  async function send(request) {
254
254
  const envelope = buildEnvelope(request);
255
255
  const signedPayload = envelope.payload;
256
- const headers = buildAuthHeaders({
257
- apiKey,
258
- apiSecret,
259
- payload: signedPayload
260
- });
261
256
  const bodyString = JSON.stringify(envelope);
262
257
  async function attempt(currentAttempt) {
258
+ const headers = buildAuthHeaders({
259
+ apiKey,
260
+ apiSecret,
261
+ payload: signedPayload
262
+ });
263
263
  const { controller, cleanup } = withTimeout(timeoutMs);
264
264
  try {
265
265
  const response = await fetchImpl(baseUrl, {
@@ -298,7 +298,7 @@ function createTransport({
298
298
  return Err(
299
299
  JSON.stringify({
300
300
  category: "internal",
301
- message: "Response missing status field",
301
+ message: "Received an invalid response from the server",
302
302
  retryable: false
303
303
  })
304
304
  );
@@ -311,7 +311,7 @@ function createTransport({
311
311
  return Err(
312
312
  JSON.stringify({
313
313
  category: "internal",
314
- message: "Response signature missing",
314
+ message: "Could not verify the server response",
315
315
  retryable: false
316
316
  })
317
317
  );
@@ -326,7 +326,7 @@ function createTransport({
326
326
  return Err(
327
327
  JSON.stringify({
328
328
  category: "internal",
329
- message: "Response signature verification failed",
329
+ message: "Could not verify the server response",
330
330
  retryable: false
331
331
  })
332
332
  );
@@ -342,7 +342,7 @@ function createTransport({
342
342
  const isAbort = error instanceof DOMException && error.name === "AbortError";
343
343
  const sdkError = {
344
344
  category: isAbort ? "timeout" : "network",
345
- message: isAbort ? `Request timed out after ${timeoutMs}ms` : String(error),
345
+ message: isAbort ? "The request timed out" : "Could not reach the server, check your network connection and try again",
346
346
  retryable: true
347
347
  };
348
348
  if (currentAttempt < maxRetries) {
@@ -373,6 +373,7 @@ function parseError(error) {
373
373
 
374
374
  // src/payment.ts
375
375
  var STATUS_TO_EVENT = {
376
+ pending: "processing",
376
377
  successful: "success",
377
378
  failed: "failed",
378
379
  processing: "processing",
@@ -396,6 +397,7 @@ function createPaymentInstance(initialResponse, deps) {
396
397
  status: normalizeStatus(initialResponse.status),
397
398
  transaction: null,
398
399
  pollingTimer: null,
400
+ lastStatusEvent: null,
399
401
  resolved: false,
400
402
  pollAttempts: 0,
401
403
  pollStartTime: Date.now(),
@@ -406,14 +408,21 @@ function createPaymentInstance(initialResponse, deps) {
406
408
  maxPollDuration: deps.maxPollDuration ?? 3e5,
407
409
  maxPollAttempts: deps.maxPollAttempts ?? 150
408
410
  };
409
- function resolveWithError(error) {
411
+ function resolveWithError(error, category, retryable) {
410
412
  state.resolved = true;
411
413
  stopUpdates();
412
- emitEvent("error", parseError(error).message);
414
+ const parsed = parseError(error);
415
+ emitEvent(
416
+ "error",
417
+ parsed.message,
418
+ category ?? parsed.category,
419
+ parsed.retryable
420
+ );
413
421
  }
414
422
  function emitEvent(event, error, category, retryable) {
415
423
  const data = {
416
424
  event,
425
+ reference: state.reference,
417
426
  transaction: state.transaction ?? void 0,
418
427
  error,
419
428
  category,
@@ -445,30 +454,30 @@ function createPaymentInstance(initialResponse, deps) {
445
454
  }
446
455
  if (response.reference !== state.reference) {
447
456
  resolveWithError(
448
- `Reference mismatch: expected ${state.reference} but got ${response.reference}`
457
+ "Received a status update for a different transaction",
458
+ "internal"
449
459
  );
450
460
  return;
451
461
  }
452
462
  const newStatus = normalizeStatus(response.status);
453
- const oldStatus = state.status;
454
463
  state.status = newStatus;
455
- if (newStatus !== oldStatus) {
456
- const event = statusToEvent(newStatus);
457
- if (event) {
458
- if (TERMINAL_STATES.has(newStatus)) {
459
- await handleTerminalState(newStatus);
460
- return;
461
- }
462
- emitEvent(event);
463
- }
464
+ const event = statusToEvent(newStatus);
465
+ if (!event || event === state.lastStatusEvent) {
466
+ return;
464
467
  }
468
+ state.lastStatusEvent = event;
469
+ if (TERMINAL_STATES.has(newStatus)) {
470
+ await handleTerminalState(newStatus);
471
+ return;
472
+ }
473
+ emitEvent(event);
465
474
  }
466
475
  function handlePollError(error) {
467
476
  const parsed = parseError(error);
468
477
  if (parsed.category === "not_found") {
469
478
  return;
470
479
  }
471
- emitEvent("error", parsed.message);
480
+ emitEvent("error", parsed.message, parsed.category, parsed.retryable);
472
481
  state.resolved = true;
473
482
  stopUpdates();
474
483
  }
@@ -488,11 +497,17 @@ function createPaymentInstance(initialResponse, deps) {
488
497
  return;
489
498
  }
490
499
  if (state.pollAttempts >= state.maxPollAttempts) {
491
- resolveWithError("Polling timeout: exceeded maximum attempts");
500
+ resolveWithError(
501
+ "Timed out waiting for the transaction status to update",
502
+ "timeout"
503
+ );
492
504
  return;
493
505
  }
494
506
  if (Date.now() - state.pollStartTime >= state.maxPollDuration) {
495
- resolveWithError("Polling timeout: exceeded maximum duration");
507
+ resolveWithError(
508
+ "Timed out waiting for the transaction status to update",
509
+ "timeout"
510
+ );
496
511
  return;
497
512
  }
498
513
  state.pollAttempts += 1;
@@ -515,6 +530,15 @@ function createPaymentInstance(initialResponse, deps) {
515
530
  }, 0);
516
531
  return;
517
532
  }
533
+ const initialEvent = statusToEvent(state.status);
534
+ if (initialEvent) {
535
+ state.lastStatusEvent = initialEvent;
536
+ setTimeout(() => {
537
+ if (!state.resolved) {
538
+ emitEvent(initialEvent);
539
+ }
540
+ }, 0);
541
+ }
518
542
  scheduleNextPoll();
519
543
  }
520
544
  function stopUpdates() {
@@ -603,6 +627,9 @@ function normalizePhone(phone) {
603
627
  }
604
628
  return normalized;
605
629
  }
630
+ function isValidPhoneFormat(normalizedPhone) {
631
+ return /^\d{9,15}$/.test(normalizedPhone);
632
+ }
606
633
  var DEFAULT_TOLERANCE_SECONDS = 300;
607
634
  function decodePayload(payload) {
608
635
  return typeof payload === "string" ? payload : Buffer.from(payload).toString("utf8");
@@ -699,6 +726,56 @@ function validateNonEmpty(value, fieldName) {
699
726
  throwValidation(`${fieldName} is required`);
700
727
  }
701
728
  }
729
+ function validatePhoneFormat(normalizedPhone, fieldName) {
730
+ if (!isValidPhoneFormat(normalizedPhone)) {
731
+ throwValidation(`${fieldName} must be a valid phone number`);
732
+ }
733
+ }
734
+ function prepareCollectPayload(input) {
735
+ const reference = resolveReference(input.reference);
736
+ validateCollectionAmount(input.amount);
737
+ validateNonEmpty(input.customer.name, "customer.name");
738
+ validateNonEmpty(input.customer.phoneNumber, "customer.phoneNumber");
739
+ const normalizedPhone = normalizePhone(input.customer.phoneNumber);
740
+ validatePhoneFormat(normalizedPhone, "customer.phoneNumber");
741
+ validateNonEmpty(input.description, "description");
742
+ if (input.method === "bank" && !input.bank) {
743
+ throwValidation('bank details are required when method is "bank"');
744
+ }
745
+ return {
746
+ ...input,
747
+ reference,
748
+ customer: { ...input.customer, phoneNumber: normalizedPhone }
749
+ };
750
+ }
751
+ function preparePayoutPayload(input) {
752
+ const reference = resolveReference(input.reference);
753
+ validatePayoutAmount(input.amount);
754
+ validateNonEmpty(input.customer.name, "customer.name");
755
+ validateNonEmpty(input.customer.phoneNumber, "customer.phoneNumber");
756
+ const normalizedPhone = normalizePhone(input.customer.phoneNumber);
757
+ validatePhoneFormat(normalizedPhone, "customer.phoneNumber");
758
+ validateNonEmpty(input.description, "description");
759
+ validateNonEmpty(
760
+ input.destination.accountHolderName,
761
+ "destination.accountHolderName"
762
+ );
763
+ validateNonEmpty(
764
+ input.destination.accountNumber,
765
+ "destination.accountNumber"
766
+ );
767
+ return {
768
+ ...input,
769
+ reference,
770
+ customer: { ...input.customer, phoneNumber: normalizedPhone }
771
+ };
772
+ }
773
+ function applyBeforeHookMutation(mutated, current, prepare) {
774
+ return prepare({
775
+ ...mutated,
776
+ reference: mutated.reference ?? current.reference
777
+ });
778
+ }
702
779
  function createSdkInstance(config) {
703
780
  const transport = createTransport({
704
781
  apiKey: config.apiKey,
@@ -722,23 +799,15 @@ function createSdkInstance(config) {
722
799
  maxPollAttempts: config.maxPollAttempts
723
800
  };
724
801
  async function collectPayment(input) {
725
- const reference = resolveReference(input.reference);
726
- validateCollectionAmount(input.amount);
727
- validateNonEmpty(input.customer.name, "customer.name");
728
- validateNonEmpty(input.customer.phoneNumber, "customer.phoneNumber");
729
- const normalizedPhone = normalizePhone(input.customer.phoneNumber);
730
- validateNonEmpty(input.description, "description");
731
- if (input.method === "bank" && !input.bank) {
732
- throwValidation('bank details are required when method is "bank"');
733
- }
734
- let payload = {
735
- ...input,
736
- reference,
737
- customer: { ...input.customer, phoneNumber: normalizedPhone }
738
- };
802
+ let payload = prepareCollectPayload(input);
739
803
  const mutated = await runHook(config.hooks?.beforeCollect, payload);
740
- if (mutated != null)
741
- payload = { ...mutated, reference: mutated.reference ?? reference };
804
+ if (mutated != null) {
805
+ payload = applyBeforeHookMutation(
806
+ mutated,
807
+ payload,
808
+ prepareCollectPayload
809
+ );
810
+ }
742
811
  const result = await transport.send({
743
812
  action: SDK_ACTIONS.collectPayment,
744
813
  payload
@@ -746,35 +815,27 @@ function createSdkInstance(config) {
746
815
  await runHook(
747
816
  config.hooks?.afterCollect,
748
817
  result.isOk ? Ok({ reference: result.value.reference, status: result.value.status }) : Err(result.error),
749
- payload
818
+ { ...payload, raw: input }
750
819
  );
751
820
  if (result.isErr) {
752
821
  const sdkErr = parseError(result.error);
753
822
  return createPaymentInstance(
754
- { reference, status: "pending" },
823
+ { reference: payload.reference, status: "pending" },
755
824
  { ...commonDeps, initialError: sdkErr }
756
825
  );
757
826
  }
758
827
  return createPaymentInstance(result.value, commonDeps);
759
828
  }
760
829
  async function collectPaymentAndResolve(input) {
761
- const reference = resolveReference(input.reference);
762
- validateCollectionAmount(input.amount);
763
- validateNonEmpty(input.customer.name, "customer.name");
764
- validateNonEmpty(input.customer.phoneNumber, "customer.phoneNumber");
765
- const normalizedPhone = normalizePhone(input.customer.phoneNumber);
766
- validateNonEmpty(input.description, "description");
767
- if (input.method === "bank" && !input.bank) {
768
- throwValidation('bank details are required when method is "bank"');
769
- }
770
- let payload = {
771
- ...input,
772
- reference,
773
- customer: { ...input.customer, phoneNumber: normalizedPhone }
774
- };
830
+ let payload = prepareCollectPayload(input);
775
831
  const mutated = await runHook(config.hooks?.beforeCollect, payload);
776
- if (mutated != null)
777
- payload = { ...mutated, reference: mutated.reference ?? reference };
832
+ if (mutated != null) {
833
+ payload = applyBeforeHookMutation(
834
+ mutated,
835
+ payload,
836
+ prepareCollectPayload
837
+ );
838
+ }
778
839
  const result = await transport.send({
779
840
  action: SDK_ACTIONS.collectPaymentAndResolve,
780
841
  payload
@@ -782,7 +843,7 @@ function createSdkInstance(config) {
782
843
  await runHook(
783
844
  config.hooks?.afterCollect,
784
845
  result.isOk ? Ok({ reference: result.value.reference, status: result.value.status }) : Err(result.error),
785
- payload
846
+ { ...payload, raw: input }
786
847
  );
787
848
  if (result.isOk) {
788
849
  return Ok(result.value);
@@ -790,28 +851,11 @@ function createSdkInstance(config) {
790
851
  return Err(result.error);
791
852
  }
792
853
  async function makePayout(input) {
793
- const reference = resolveReference(input.reference);
794
- validatePayoutAmount(input.amount);
795
- validateNonEmpty(input.customer.name, "customer.name");
796
- validateNonEmpty(input.customer.phoneNumber, "customer.phoneNumber");
797
- const normalizedPhone = normalizePhone(input.customer.phoneNumber);
798
- validateNonEmpty(input.description, "description");
799
- validateNonEmpty(
800
- input.destination.accountHolderName,
801
- "destination.accountHolderName"
802
- );
803
- validateNonEmpty(
804
- input.destination.accountNumber,
805
- "destination.accountNumber"
806
- );
807
- let payload = {
808
- ...input,
809
- reference,
810
- customer: { ...input.customer, phoneNumber: normalizedPhone }
811
- };
854
+ let payload = preparePayoutPayload(input);
812
855
  const mutated = await runHook(config.hooks?.beforePayout, payload);
813
- if (mutated != null)
814
- payload = { ...mutated, reference: mutated.reference ?? reference };
856
+ if (mutated != null) {
857
+ payload = applyBeforeHookMutation(mutated, payload, preparePayoutPayload);
858
+ }
815
859
  const result = await transport.send({
816
860
  action: SDK_ACTIONS.makePayout,
817
861
  payload
@@ -819,40 +863,23 @@ function createSdkInstance(config) {
819
863
  await runHook(
820
864
  config.hooks?.afterPayout,
821
865
  result.isOk ? Ok({ reference: result.value.reference, status: result.value.status }) : Err(result.error),
822
- payload
866
+ { ...payload, raw: input }
823
867
  );
824
868
  if (result.isErr) {
825
869
  const sdkErr = parseError(result.error);
826
870
  return createPaymentInstance(
827
- { reference, status: "pending" },
871
+ { reference: payload.reference, status: "pending" },
828
872
  { ...commonDeps, initialError: sdkErr }
829
873
  );
830
874
  }
831
875
  return createPaymentInstance(result.value, commonDeps);
832
876
  }
833
877
  async function makePayoutAndResolve(input) {
834
- const reference = resolveReference(input.reference);
835
- validatePayoutAmount(input.amount);
836
- validateNonEmpty(input.customer.name, "customer.name");
837
- validateNonEmpty(input.customer.phoneNumber, "customer.phoneNumber");
838
- const normalizedPhone = normalizePhone(input.customer.phoneNumber);
839
- validateNonEmpty(input.description, "description");
840
- validateNonEmpty(
841
- input.destination.accountHolderName,
842
- "destination.accountHolderName"
843
- );
844
- validateNonEmpty(
845
- input.destination.accountNumber,
846
- "destination.accountNumber"
847
- );
848
- let payload = {
849
- ...input,
850
- reference,
851
- customer: { ...input.customer, phoneNumber: normalizedPhone }
852
- };
878
+ let payload = preparePayoutPayload(input);
853
879
  const mutated = await runHook(config.hooks?.beforePayout, payload);
854
- if (mutated != null)
855
- payload = { ...mutated, reference: mutated.reference ?? reference };
880
+ if (mutated != null) {
881
+ payload = applyBeforeHookMutation(mutated, payload, preparePayoutPayload);
882
+ }
856
883
  const result = await transport.send({
857
884
  action: SDK_ACTIONS.makePayoutAndResolve,
858
885
  payload
@@ -860,7 +887,7 @@ function createSdkInstance(config) {
860
887
  await runHook(
861
888
  config.hooks?.afterPayout,
862
889
  result.isOk ? Ok({ reference: result.value.reference, status: result.value.status }) : Err(result.error),
863
- payload
890
+ { ...payload, raw: input }
864
891
  );
865
892
  if (result.isOk) {
866
893
  return Ok(result.value);
@@ -894,6 +921,7 @@ function createSdkInstance(config) {
894
921
  async function verifyPhone(input) {
895
922
  validateNonEmpty(input.phoneNumber, "phoneNumber");
896
923
  const normalizedPhone = normalizePhone(input.phoneNumber);
924
+ validatePhoneFormat(normalizedPhone, "phoneNumber");
897
925
  const result = await transport.send({
898
926
  action: SDK_ACTIONS.verifyPhone,
899
927
  payload: { ...input, phoneNumber: normalizedPhone }