@munchi_oy/payments 1.4.9 → 1.5.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.
@@ -0,0 +1,810 @@
1
+ import {
2
+ type CreateTerminalPaymentDto,
3
+ FeeType,
4
+ PaymentFailureCode,
5
+ PaymentMethod,
6
+ PaymentProvider,
7
+ VivaApi,
8
+ type VivaAppToAppInitResponseDto,
9
+ type TransactionDto,
10
+ type TransactionDtoFees,
11
+ PaymentApi,
12
+ } from "@munchi_oy/core";
13
+ import type { AxiosInstance } from "axios";
14
+
15
+ import { PaymentErrorCode, PaymentSDKError } from "../error";
16
+ import {
17
+ PaymentInteractionState,
18
+ type PaymentRequest,
19
+ type PaymentResult,
20
+ type PaymentTerminalConfig,
21
+ type RefundRequest,
22
+ SdkPaymentStatus,
23
+ type VivaOptions,
24
+ } from "../types/payment";
25
+ import type { AppToAppConfig } from "../types/sdk";
26
+ import { normalizeTimestamp, parseInteger } from "../utils/parsing";
27
+ import {
28
+ type IPaymentStrategy,
29
+ StrategyExecutionMode,
30
+ } from "./IPaymentStrategy";
31
+
32
+ const VIVA_APP_TO_APP_BASE_URL = "vivapayclient://pay/v1";
33
+ const VIVA_ACTION_ABORT = "abort";
34
+ const VIVA_ACTION_CANCEL = "cancel";
35
+ const VIVA_ACTION_SALE = "sale";
36
+ const VIVA_ACTION_TRANSACTION_DETAILS = "transactionDetails";
37
+ const VIVA_TRANSACTION_DETAILS_TIMEOUT_MS = 4000;
38
+ const EDENRED_BIN_PREFIX = "534287";
39
+ const WOLT_BENEFIT_BIN_PREFIX = "493591";
40
+ const CREDIT_KEYWORDS = ["CREDIT", "PREPAID", "CORPORATE"];
41
+
42
+ type VivaLaunchParams = Record<string, boolean | number | string | undefined>;
43
+ type VivaCallbackParams = Record<string, string>;
44
+ type PaymentReferenceLookupRequest = {
45
+ orderId: string;
46
+ provider: PaymentProvider;
47
+ transactionId: string;
48
+ };
49
+ type PaymentReferenceResponse = {
50
+ originalTransactionId: string;
51
+ references: object | null;
52
+ };
53
+ type PaymentReferenceContext = {
54
+ originalTransactionId?: string;
55
+ receiptNumber?: string;
56
+ referenceCode?: string;
57
+ shortReferenceCode?: string;
58
+ terminalId?: string;
59
+ };
60
+
61
+ export class VivaAppToAppStrategy implements IPaymentStrategy {
62
+ private api: VivaApi;
63
+ private paymentApi: PaymentApi;
64
+ private abortController: AbortController | null = null;
65
+ private currentClientTransactionId: string | null = null;
66
+ private currentIsvClientTransactionId: string | null = null;
67
+ private currentCurrency: PaymentRequest["currency"] | null = null;
68
+ private currentOrderRef: string | null = null;
69
+ private currentSourceTerminalId: string | null = null;
70
+ private latestResult: PaymentResult | null = null;
71
+ private paymentApiWithReference: PaymentApi & {
72
+ getPaymentReference: (
73
+ request: PaymentReferenceLookupRequest,
74
+ ) => Promise<{ data: PaymentReferenceResponse }>;
75
+ };
76
+ private wasCancelled = false;
77
+
78
+ constructor(
79
+ axios: AxiosInstance,
80
+ _messaging: unknown,
81
+ private config: PaymentTerminalConfig,
82
+ private appToAppConfig: AppToAppConfig,
83
+ ) {
84
+ this.api = new VivaApi(undefined, "", axios);
85
+ this.paymentApi = new PaymentApi(undefined, "", axios);
86
+ this.paymentApiWithReference = this.paymentApi as PaymentApi & {
87
+ getPaymentReference: (
88
+ request: PaymentReferenceLookupRequest,
89
+ ) => Promise<{ data: PaymentReferenceResponse }>;
90
+ };
91
+ }
92
+
93
+ getExecutionMode() {
94
+ return StrategyExecutionMode.CallbackDriven;
95
+ }
96
+
97
+ async processPayment(
98
+ request: PaymentRequest,
99
+ onStateChange: (
100
+ state: PaymentInteractionState,
101
+ detail?: { sessionId?: string },
102
+ ) => void,
103
+ ): Promise<PaymentResult> {
104
+ if (!this.appToAppConfig.enabled) {
105
+ throw new PaymentSDKError(
106
+ PaymentErrorCode.STRATEGY_ERROR,
107
+ "Viva app-to-app is not enabled",
108
+ );
109
+ }
110
+
111
+ onStateChange(PaymentInteractionState.CONNECTING);
112
+
113
+ try {
114
+ const { data } = await this.api.createVivaAppToAppPayment(
115
+ this.buildCreatePaymentPayload(request),
116
+ );
117
+
118
+ return await this.runAction(
119
+ VIVA_ACTION_SALE,
120
+ this.buildLaunchParams(data),
121
+ request,
122
+ onStateChange,
123
+ );
124
+ } catch (error) {
125
+ throw new PaymentSDKError(
126
+ PaymentErrorCode.NETWORK_ERROR,
127
+ "Failed to create Viva app-to-app payment",
128
+ error,
129
+ );
130
+ }
131
+ }
132
+
133
+ async cancelTransaction(
134
+ _onStateChange: (state: PaymentInteractionState) => void,
135
+ ): Promise<boolean> {
136
+ if (!this.currentClientTransactionId) {
137
+ return false;
138
+ }
139
+
140
+ const transactionDetailsResult = await this.lookupTransactionDetails();
141
+ if (transactionDetailsResult) {
142
+ this.latestResult = transactionDetailsResult;
143
+ this.wasCancelled =
144
+ transactionDetailsResult.status === SdkPaymentStatus.CANCELLED;
145
+ this.abortController?.abort();
146
+ return true;
147
+ }
148
+
149
+ try {
150
+ await this.appToAppConfig.adapter.openUrl(
151
+ this.buildActionUrl(VIVA_ACTION_ABORT, {}),
152
+ );
153
+ } catch (error) {
154
+ this.abortController?.abort();
155
+ throw new PaymentSDKError(
156
+ PaymentErrorCode.NETWORK_ERROR,
157
+ "Failed to launch Viva abort flow",
158
+ error,
159
+ );
160
+ }
161
+
162
+ this.wasCancelled = true;
163
+ this.abortController?.abort();
164
+ return true;
165
+ }
166
+
167
+ async refundTransaction(
168
+ request: RefundRequest,
169
+ onStateChange: (
170
+ state: PaymentInteractionState,
171
+ detail?: { sessionId?: string },
172
+ ) => void,
173
+ ): Promise<PaymentResult> {
174
+ onStateChange(PaymentInteractionState.CONNECTING);
175
+
176
+ const reference = await this.fetchPaymentReference(request);
177
+ const launchParams = this.buildRefundLaunchParams(request, reference);
178
+ return this.runAction(
179
+ VIVA_ACTION_CANCEL,
180
+ launchParams,
181
+ request,
182
+ onStateChange,
183
+ );
184
+ }
185
+
186
+ async verifyFinalStatus(
187
+ _request: PaymentRequest,
188
+ sessionId: string,
189
+ ): Promise<PaymentResult> {
190
+ if (this.latestResult) {
191
+ return this.latestResult;
192
+ }
193
+
194
+ if (this.wasCancelled) {
195
+ return {
196
+ success: false,
197
+ status: SdkPaymentStatus.CANCELLED,
198
+ orderId: this.currentOrderRef ?? sessionId,
199
+ errorCode: PaymentFailureCode.PaymentCancelledByUser,
200
+ errorMessage: "Transaction was cancelled",
201
+ transactionId: sessionId,
202
+ };
203
+ }
204
+
205
+ return {
206
+ success: false,
207
+ status: SdkPaymentStatus.PENDING,
208
+ orderId: this.currentOrderRef ?? sessionId,
209
+ transactionId: sessionId,
210
+ };
211
+ }
212
+
213
+ abort(): void {
214
+ this.abortController?.abort();
215
+ }
216
+
217
+ private async runAction(
218
+ action: string,
219
+ launchParams: Record<string, string>,
220
+ request: PaymentRequest | RefundRequest,
221
+ onStateChange: (
222
+ state: PaymentInteractionState,
223
+ detail?: { sessionId?: string },
224
+ ) => void,
225
+ ): Promise<PaymentResult> {
226
+ if (!this.appToAppConfig.enabled) {
227
+ throw new PaymentSDKError(
228
+ PaymentErrorCode.STRATEGY_ERROR,
229
+ "Viva app-to-app is not enabled",
230
+ );
231
+ }
232
+
233
+ this.abortController = new AbortController();
234
+ this.latestResult = null;
235
+ this.wasCancelled = false;
236
+ this.currentCurrency = request.currency;
237
+ this.currentOrderRef = request.orderRef;
238
+ this.currentSourceTerminalId =
239
+ launchParams.sourceTerminalId ?? launchParams.tid ?? null;
240
+
241
+ this.currentClientTransactionId = launchParams.clientTransactionId ?? null;
242
+ this.currentIsvClientTransactionId =
243
+ launchParams.ISV_clientTransactionId ?? null;
244
+ const detail = this.currentClientTransactionId
245
+ ? { sessionId: this.currentClientTransactionId }
246
+ : undefined;
247
+
248
+ return new Promise<PaymentResult>((resolve, reject) => {
249
+ const cleanup = (
250
+ unsubscribe: (() => void) | undefined,
251
+ onAbort: () => void,
252
+ ) => {
253
+ unsubscribe?.();
254
+ this.abortController?.signal.removeEventListener("abort", onAbort);
255
+ };
256
+
257
+ const onAbort = () => {
258
+ cleanup(unsubscribe, onAbort);
259
+ reject(new Error("Aborted"));
260
+ };
261
+
262
+ const unsubscribe = this.appToAppConfig.adapter.subscribe(
263
+ (callbackUrl) => {
264
+ if (this.abortController?.signal.aborted) {
265
+ return;
266
+ }
267
+
268
+ const parsed = this.parseCallbackUrl(callbackUrl);
269
+ if (!parsed) {
270
+ return;
271
+ }
272
+
273
+ if (!this.matchesKnownTransactionId(parsed.rawParams)) {
274
+ return;
275
+ }
276
+
277
+ this.latestResult = parsed.result;
278
+ this.wasCancelled =
279
+ parsed.result.status === SdkPaymentStatus.CANCELLED;
280
+ this.currentClientTransactionId = null;
281
+ this.currentIsvClientTransactionId = null;
282
+ this.currentCurrency = null;
283
+ this.currentOrderRef = null;
284
+ this.currentSourceTerminalId = null;
285
+ cleanup(unsubscribe, onAbort);
286
+ resolve(parsed.result);
287
+ },
288
+ );
289
+
290
+ this.abortController?.signal.addEventListener("abort", onAbort);
291
+ onStateChange(PaymentInteractionState.REQUIRES_INPUT, detail);
292
+
293
+ this.appToAppConfig.adapter
294
+ .openUrl(this.buildActionUrl(action, launchParams))
295
+ .then(() => {
296
+ if (this.abortController?.signal.aborted) {
297
+ return;
298
+ }
299
+ })
300
+ .catch((error) => {
301
+ cleanup(unsubscribe, onAbort);
302
+ this.currentClientTransactionId = null;
303
+ this.currentIsvClientTransactionId = null;
304
+ this.currentCurrency = null;
305
+ this.currentOrderRef = null;
306
+ this.currentSourceTerminalId = null;
307
+ reject(
308
+ new PaymentSDKError(
309
+ PaymentErrorCode.NETWORK_ERROR,
310
+ "Failed to launch Viva app",
311
+ error,
312
+ ),
313
+ );
314
+ });
315
+ });
316
+ }
317
+
318
+ private buildCreatePaymentPayload(
319
+ request: PaymentRequest,
320
+ ): CreateTerminalPaymentDto {
321
+ const vivaOptions = (request.options ?? {}) as VivaOptions;
322
+ const payload: CreateTerminalPaymentDto = {
323
+ amount: request.amountCents,
324
+ referenceId: request.orderRef,
325
+ businessId: Number(this.config.storeId),
326
+ currency: request.currency,
327
+ displayId: request.displayId,
328
+ showReceipt: true,
329
+ showTransactionResult: true,
330
+ };
331
+
332
+ if (vivaOptions.tipAmount !== undefined) {
333
+ payload.tipAmount = vivaOptions.tipAmount;
334
+ }
335
+
336
+ return payload;
337
+ }
338
+
339
+ private buildLaunchParams(
340
+ payload: VivaLaunchParams | VivaAppToAppInitResponseDto,
341
+ ): Record<string, string> {
342
+ const params: Record<string, string> = {};
343
+
344
+ Object.entries(payload).forEach(([key, value]) => {
345
+ if (value !== undefined) {
346
+ params[key] = String(value);
347
+ }
348
+ });
349
+
350
+ return params;
351
+ }
352
+
353
+ private buildRefundLaunchParams(
354
+ request: RefundRequest,
355
+ reference?: PaymentReferenceContext,
356
+ ): Record<string, string> {
357
+ const vivaOptions = (request.options ?? {}) as VivaOptions;
358
+ const originalTransactionId =
359
+ reference?.originalTransactionId?.trim() ||
360
+ request.originalTransactionId.trim();
361
+ const params: Record<string, string> = {
362
+ amount: String(request.amountCents),
363
+ clientTransactionId: originalTransactionId,
364
+ show_receipt: "true",
365
+ show_transaction_result: "true",
366
+ };
367
+
368
+ if (vivaOptions.sourceCode) {
369
+ params.sourceCode = vivaOptions.sourceCode;
370
+ }
371
+
372
+ if (reference?.referenceCode) {
373
+ params.orderCode = reference.referenceCode;
374
+ }
375
+
376
+ if (reference?.shortReferenceCode) {
377
+ params.shortOrderCode = reference.shortReferenceCode;
378
+ }
379
+
380
+ if (reference?.receiptNumber) {
381
+ params.referenceNumber = reference.receiptNumber;
382
+ }
383
+
384
+ if (reference?.terminalId) {
385
+ params.tid = reference.terminalId;
386
+ }
387
+
388
+ if (
389
+ reference?.referenceCode ||
390
+ reference?.shortReferenceCode ||
391
+ reference?.receiptNumber
392
+ ) {
393
+ return params;
394
+ }
395
+
396
+ const originalId = originalTransactionId;
397
+ if (/^\d+$/.test(originalId)) {
398
+ if (originalId.length === 16) {
399
+ params.orderCode = originalId;
400
+ } else if (originalId.length === 10) {
401
+ params.shortOrderCode = originalId;
402
+ } else {
403
+ params.referenceNumber = originalId;
404
+ }
405
+ }
406
+
407
+ return params;
408
+ }
409
+
410
+ private async fetchPaymentReference(
411
+ request: RefundRequest,
412
+ ): Promise<PaymentReferenceContext | undefined> {
413
+ try {
414
+ const { data } = await this.paymentApiWithReference.getPaymentReference({
415
+ orderId: request.orderRef,
416
+ provider: PaymentProvider.Viva,
417
+ transactionId: request.originalTransactionId,
418
+ });
419
+
420
+ return this.extractPaymentReferenceContext(data);
421
+ } catch {
422
+ return undefined;
423
+ }
424
+ }
425
+
426
+ private extractPaymentReferenceContext(
427
+ response?: PaymentReferenceResponse,
428
+ ): PaymentReferenceContext | undefined {
429
+ if (!response) {
430
+ return undefined;
431
+ }
432
+
433
+ const references = response.references;
434
+ if (!references || typeof references !== "object" || Array.isArray(references)) {
435
+ return {
436
+ originalTransactionId: response.originalTransactionId,
437
+ };
438
+ }
439
+
440
+ const context = references as Record<string, unknown>;
441
+ const paymentReferenceContext: PaymentReferenceContext = {
442
+ originalTransactionId:
443
+ this.readReferenceValue(context.originalTransactionId) ??
444
+ this.readReferenceValue(context.clientTransactionId) ??
445
+ response.originalTransactionId,
446
+ };
447
+ const receiptNumber =
448
+ this.readReferenceValue(context.receiptNumber) ??
449
+ this.readReferenceValue(context.referenceNumber);
450
+ const referenceCode =
451
+ this.readReferenceValue(context.referenceCode) ??
452
+ this.readReferenceValue(context.orderCode);
453
+ const shortReferenceCode = this.readReferenceValue(
454
+ context.shortReferenceCode,
455
+ ) ?? this.readReferenceValue(context.shortOrderCode);
456
+ const terminalId =
457
+ this.readReferenceValue(context.terminalId) ??
458
+ this.readReferenceValue(context.tid);
459
+
460
+ if (receiptNumber) {
461
+ paymentReferenceContext.receiptNumber = receiptNumber;
462
+ }
463
+
464
+ if (referenceCode) {
465
+ paymentReferenceContext.referenceCode = referenceCode;
466
+ }
467
+
468
+ if (shortReferenceCode) {
469
+ paymentReferenceContext.shortReferenceCode = shortReferenceCode;
470
+ }
471
+
472
+ if (terminalId) {
473
+ paymentReferenceContext.terminalId = terminalId;
474
+ }
475
+
476
+ return paymentReferenceContext;
477
+ }
478
+
479
+ private readReferenceValue(value: unknown): string | undefined {
480
+ if (typeof value !== "string") {
481
+ return undefined;
482
+ }
483
+
484
+ const trimmed = value.trim();
485
+ return trimmed.length > 0 ? trimmed : undefined;
486
+ }
487
+
488
+ private buildActionUrl(
489
+ action: string,
490
+ params: Record<string, string>,
491
+ ): string {
492
+ const query = new URLSearchParams({
493
+ action,
494
+ appId: this.appToAppConfig.appId,
495
+ callback: this.appToAppConfig.callbackUrl,
496
+ });
497
+
498
+ Object.entries(params).forEach(([key, value]) => {
499
+ query.set(key, value);
500
+ });
501
+
502
+ return `${VIVA_APP_TO_APP_BASE_URL}?${query.toString()}`;
503
+ }
504
+
505
+ private parseCallbackUrl(
506
+ callbackUrl: string,
507
+ ): { rawParams: Record<string, string>; result: PaymentResult } | null {
508
+ if (!this.matchesCallbackBase(callbackUrl)) {
509
+ return null;
510
+ }
511
+
512
+ const parsed = new URL(callbackUrl);
513
+ const rawParams = Object.fromEntries(parsed.searchParams.entries());
514
+ const isSuccess = this.isSuccessfulCallback(rawParams);
515
+ const isCancelled = this.isCancelledCallback(rawParams);
516
+ const errorCode = this.resolveFailureCode(rawParams, isCancelled);
517
+
518
+ const result: PaymentResult = {
519
+ success: isSuccess,
520
+ status: isSuccess
521
+ ? SdkPaymentStatus.SUCCESS
522
+ : isCancelled
523
+ ? SdkPaymentStatus.CANCELLED
524
+ : SdkPaymentStatus.FAILED,
525
+ orderId:
526
+ this.currentOrderRef ?? rawParams.clientTransactionId ?? "unknown",
527
+ errorCode: isSuccess ? "" : errorCode,
528
+ errorMessage: isSuccess
529
+ ? ""
530
+ : (rawParams.message ?? "Transaction failed without error details"),
531
+ };
532
+
533
+ if (isSuccess) {
534
+ result.transaction = this.mapCallbackToTransaction(rawParams);
535
+ }
536
+
537
+ const transactionId =
538
+ rawParams.transactionId ??
539
+ rawParams.orderCode ??
540
+ rawParams.clientTransactionId;
541
+ const errorReference =
542
+ rawParams.errorCode ??
543
+ this.extractErrorReferenceFromMessage(rawParams.message) ??
544
+ rawParams.referenceNumber ??
545
+ rawParams.tid ??
546
+ rawParams.rrn;
547
+
548
+ if (transactionId) {
549
+ result.transactionId = transactionId;
550
+ }
551
+
552
+ if (errorReference) {
553
+ result.errorReference = errorReference;
554
+ }
555
+
556
+ return {
557
+ rawParams,
558
+ result,
559
+ };
560
+ }
561
+
562
+ private matchesCallbackBase(url: string): boolean {
563
+ const actual = new URL(url);
564
+ const expected = new URL(this.appToAppConfig.callbackUrl);
565
+
566
+ return (
567
+ actual.protocol === expected.protocol &&
568
+ actual.hostname === expected.hostname &&
569
+ actual.pathname === expected.pathname
570
+ );
571
+ }
572
+
573
+ private matchesKnownTransactionId(rawParams: Record<string, string>): boolean {
574
+ const knownIds = [
575
+ this.currentClientTransactionId,
576
+ this.currentIsvClientTransactionId,
577
+ ].filter((value): value is string => Boolean(value));
578
+
579
+ if (knownIds.length === 0) {
580
+ return true;
581
+ }
582
+
583
+ const callbackIds = [
584
+ rawParams.clientTransactionId,
585
+ rawParams.ISV_clientTransactionId,
586
+ ].filter((value): value is string => Boolean(value));
587
+
588
+ if (callbackIds.length === 0) {
589
+ return true;
590
+ }
591
+
592
+ return callbackIds.some((callbackId) => knownIds.includes(callbackId));
593
+ }
594
+
595
+ private resolveFailureCode(
596
+ rawParams: Record<string, string>,
597
+ isCancelled: boolean,
598
+ ): string {
599
+ if (isCancelled) {
600
+ return PaymentFailureCode.PaymentCancelledByUser;
601
+ }
602
+
603
+ const rawErrorCode = rawParams.errorCode;
604
+ if (rawErrorCode?.includes(".")) {
605
+ return rawErrorCode;
606
+ }
607
+
608
+ if (this.isTransactionDetailsAction(rawParams)) {
609
+ const transactionType = (rawParams.transactionType ?? "").toLowerCase();
610
+ if (transactionType.includes("abort")) {
611
+ return PaymentFailureCode.SystemUnknown;
612
+ }
613
+ }
614
+
615
+ const rawMessage = (rawParams.message ?? "").toLowerCase();
616
+ const rawStatus = (rawParams.status ?? "").toLowerCase();
617
+
618
+ if (
619
+ rawMessage.includes("declined") ||
620
+ rawStatus === "declined" ||
621
+ rawStatus === "fail" ||
622
+ rawStatus === "failed"
623
+ ) {
624
+ return PaymentFailureCode.PaymentDeclined;
625
+ }
626
+
627
+ return PaymentFailureCode.SystemUnknown;
628
+ }
629
+
630
+ private isSuccessfulCallback(rawParams: Record<string, string>): boolean {
631
+ const rawStatus = (rawParams.status ?? "").toLowerCase();
632
+ if (!this.isTransactionDetailsAction(rawParams)) {
633
+ return rawStatus === "success";
634
+ }
635
+
636
+ if (rawStatus !== "success") {
637
+ return false;
638
+ }
639
+
640
+ const transactionType = (rawParams.transactionType ?? "").toLowerCase();
641
+ return !transactionType.includes("abort");
642
+ }
643
+
644
+ private isTransactionDetailsAction(rawParams: Record<string, string>): boolean {
645
+ return (rawParams.action ?? "").toLowerCase() ===
646
+ VIVA_ACTION_TRANSACTION_DETAILS.toLowerCase();
647
+ }
648
+
649
+ private isCancelledCallback(rawParams: Record<string, string>): boolean {
650
+ const rawStatus = (rawParams.status ?? "").toLowerCase();
651
+ const rawMessage = (rawParams.message ?? "").toLowerCase();
652
+ const rawErrorCode = (rawParams.errorCode ?? "").toLowerCase();
653
+
654
+ return (
655
+ rawStatus === "cancelled" ||
656
+ rawStatus === "canceled" ||
657
+ rawMessage.includes("user_cancel") ||
658
+ rawMessage.includes("user cancel") ||
659
+ rawErrorCode.includes("user_cancel") ||
660
+ rawErrorCode.includes("user cancel")
661
+ );
662
+ }
663
+
664
+ private extractErrorReferenceFromMessage(
665
+ message?: string,
666
+ ): string | undefined {
667
+ if (!message) {
668
+ return undefined;
669
+ }
670
+
671
+ const match = message.match(/\((-?\d+)\)/);
672
+ return match?.[1];
673
+ }
674
+
675
+ private mapCallbackToTransaction(rawParams: VivaCallbackParams): TransactionDto {
676
+ const transactionId =
677
+ rawParams.transactionId ??
678
+ rawParams.orderCode ??
679
+ rawParams.clientTransactionId ??
680
+ "unknown";
681
+ const timestamp = normalizeTimestamp(rawParams.transactionDate);
682
+ const amount = parseInteger(rawParams.amount);
683
+ const tipAmount = parseInteger(rawParams.tipAmount);
684
+
685
+ return {
686
+ amount,
687
+ cardDetail: {
688
+ aid: rawParams.aid ?? null,
689
+ applicationLabel: rawParams.applicationLabel ?? null,
690
+ cardNumber: rawParams.accountNumber ?? "N/A",
691
+ cardType: rawParams.cardType ?? "N/A",
692
+ issuer: rawParams.bankId ?? null,
693
+ orderCode: rawParams.orderCode ?? null,
694
+ timestamp,
695
+ transactionId,
696
+ },
697
+ createdAt: timestamp,
698
+ fees: this.buildFeesFromTip(tipAmount),
699
+ id: transactionId,
700
+ label: null,
701
+ provider: PaymentProvider.Viva,
702
+ rawData: rawParams,
703
+ referenceId: rawParams.clientTransactionId ?? null,
704
+ roundingDifference: 0,
705
+ type: this.getVivaPaymentMethodType(rawParams),
706
+ };
707
+ }
708
+
709
+ private getVivaPaymentMethodType(rawParams: VivaCallbackParams): PaymentMethod {
710
+ const applicationLabel = (rawParams.applicationLabel ?? "").toUpperCase().trim();
711
+ const maskedPan = rawParams.accountNumber ?? "";
712
+ const binPrefix = maskedPan.substring(0, 6);
713
+
714
+ if (binPrefix === EDENRED_BIN_PREFIX) {
715
+ return PaymentMethod.Edenred;
716
+ }
717
+
718
+ if (binPrefix === WOLT_BENEFIT_BIN_PREFIX) {
719
+ return PaymentMethod.WoltBenefit;
720
+ }
721
+
722
+ if (
723
+ CREDIT_KEYWORDS.some((keyword) => applicationLabel.includes(keyword))
724
+ ) {
725
+ return PaymentMethod.Credit;
726
+ }
727
+
728
+ return PaymentMethod.Debit;
729
+ }
730
+
731
+ private buildFeesFromTip(tipAmount: number): TransactionDtoFees | null {
732
+ if (!tipAmount || !this.currentCurrency) {
733
+ return null;
734
+ }
735
+
736
+ return {
737
+ parts: [
738
+ {
739
+ taxAmount: 0,
740
+ total: {
741
+ amount: tipAmount,
742
+ currency: this.currentCurrency,
743
+ },
744
+ type: FeeType.TipAmount,
745
+ vatPercentage: 0,
746
+ },
747
+ ],
748
+ };
749
+ }
750
+
751
+ private async lookupTransactionDetails(): Promise<PaymentResult | null> {
752
+ if (!this.currentClientTransactionId || !this.currentSourceTerminalId) {
753
+ return null;
754
+ }
755
+
756
+ const lookupParams = {
757
+ clientTransactionId: this.currentClientTransactionId,
758
+ sourceTerminalId: this.currentSourceTerminalId,
759
+ };
760
+
761
+ return new Promise<PaymentResult | null>((resolve) => {
762
+ let settled = false;
763
+
764
+ const timeoutId = setTimeout(() => {
765
+ if (settled) {
766
+ return;
767
+ }
768
+
769
+ settled = true;
770
+ unsubscribe();
771
+ resolve(null);
772
+ }, VIVA_TRANSACTION_DETAILS_TIMEOUT_MS);
773
+
774
+ const unsubscribe = this.appToAppConfig.adapter.subscribe((callbackUrl) => {
775
+ if (settled) {
776
+ return;
777
+ }
778
+
779
+ const parsed = this.parseCallbackUrl(callbackUrl);
780
+ if (!parsed) {
781
+ return;
782
+ }
783
+
784
+ if (!this.matchesKnownTransactionId(parsed.rawParams)) {
785
+ return;
786
+ }
787
+
788
+ settled = true;
789
+ clearTimeout(timeoutId);
790
+ unsubscribe();
791
+ resolve(parsed.result);
792
+ });
793
+
794
+ this.appToAppConfig.adapter
795
+ .openUrl(
796
+ this.buildActionUrl(VIVA_ACTION_TRANSACTION_DETAILS, lookupParams),
797
+ )
798
+ .catch(() => {
799
+ if (settled) {
800
+ return;
801
+ }
802
+
803
+ settled = true;
804
+ clearTimeout(timeoutId);
805
+ unsubscribe();
806
+ resolve(null);
807
+ });
808
+ });
809
+ }
810
+ }