@munchi_oy/payments 1.6.10 → 1.10.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.
@@ -0,0 +1,402 @@
1
+ import {
2
+ type InitiateVivaSessionDto,
3
+ PaymentFailureCode,
4
+ PaymentSessionApi,
5
+ PaymentSessionStatus,
6
+ type PaymentSessionViewDto,
7
+ type PaymentStatusDto,
8
+ type ReverseSessionDto,
9
+ SimplePaymentStatus,
10
+ type TransactionDto,
11
+ } from "@munchi_oy/core";
12
+ import type { AxiosInstance } from "axios";
13
+
14
+ import { PaymentErrorCode, PaymentSDKError } from "../error";
15
+ import {
16
+ type IMessagingAdapter,
17
+ type PaymentInteractionState,
18
+ PaymentInteractionState as InteractionState,
19
+ type PaymentRequest,
20
+ type PaymentResult,
21
+ type PaymentTerminalConfig,
22
+ type RefundRequest,
23
+ type RevertRequest,
24
+ SdkPaymentStatus,
25
+ } from "../types/payment";
26
+ import type { IPaymentStrategy } from "./IPaymentStrategy";
27
+ import { mapSessionResult, pendingResult } from "./sessionResult";
28
+
29
+ export class VivaSessionStrategy implements IPaymentStrategy {
30
+ private static readonly POLLING_DURATION_MS = 180000;
31
+ private static readonly POLLING_INTERVAL_MS = 1000;
32
+ private static readonly VERIFY_DURATION_MS = 30000;
33
+ private static readonly INITIAL_POLL_DELAY_MS = 10000;
34
+
35
+ private sessionApi: PaymentSessionApi;
36
+ private abortController: AbortController | null = null;
37
+ private idempotencyKey: string | null = null;
38
+ private idempotencyOrderRef: string | null = null;
39
+ private currentClientToken: string | null = null;
40
+
41
+ constructor(
42
+ axios: AxiosInstance,
43
+ private messaging: IMessagingAdapter,
44
+ private config: PaymentTerminalConfig,
45
+ ) {
46
+ this.sessionApi = new PaymentSessionApi(undefined, "", axios);
47
+ }
48
+
49
+ async processPayment(
50
+ request: PaymentRequest,
51
+ onStateChange: (state: PaymentInteractionState, detail?: { sessionId?: string }) => void,
52
+ ): Promise<PaymentResult> {
53
+ this.abortController = new AbortController();
54
+ if (
55
+ this.idempotencyKey === null ||
56
+ this.idempotencyOrderRef !== request.orderRef
57
+ ) {
58
+ this.idempotencyKey = this.generateIdempotencyKey();
59
+ this.idempotencyOrderRef = request.orderRef;
60
+ }
61
+ this.currentClientToken = null;
62
+
63
+ onStateChange(InteractionState.CONNECTING);
64
+
65
+ const payload: InitiateVivaSessionDto = {
66
+ amount: request.amountCents,
67
+ businessId: Number(this.config.storeId),
68
+ orderId: request.orderRef,
69
+ };
70
+ if (request.splitContext !== undefined) {
71
+ payload.splitContext = request.splitContext;
72
+ }
73
+
74
+ try {
75
+ const { data } = await this.sessionApi.initiateVivaSession(
76
+ payload,
77
+ this.idempotencyKey,
78
+ );
79
+ this.currentClientToken = data.clientToken;
80
+ const signal = this.abortController.signal;
81
+
82
+ if (signal.aborted) {
83
+ throw new Error("Aborted");
84
+ }
85
+
86
+ onStateChange(InteractionState.REQUIRES_INPUT, { sessionId: data.sessionId });
87
+
88
+ const terminal = await this.waitForCompletion(
89
+ data.clientToken,
90
+ data.sessionId,
91
+ request,
92
+ signal,
93
+ );
94
+
95
+ if (terminal) {
96
+ return this.finalize(terminal);
97
+ }
98
+
99
+ await this.armCancel(data.clientToken);
100
+ return pendingResult(request.orderRef);
101
+ } catch (err) {
102
+ if (err instanceof PaymentSDKError) throw err;
103
+ throw new PaymentSDKError(
104
+ PaymentErrorCode.NETWORK_ERROR,
105
+ "Failed to create Viva payment session",
106
+ err,
107
+ );
108
+ }
109
+ }
110
+
111
+ async verifyFinalStatus(
112
+ request: PaymentRequest,
113
+ _sessionId: string,
114
+ ): Promise<PaymentResult> {
115
+ if (!this.currentClientToken) {
116
+ throw new PaymentSDKError(
117
+ PaymentErrorCode.NETWORK_ERROR,
118
+ "No active payment session to verify",
119
+ );
120
+ }
121
+
122
+ const terminal = await this.pollUntilTerminal(
123
+ this.currentClientToken,
124
+ request,
125
+ undefined,
126
+ VivaSessionStrategy.VERIFY_DURATION_MS,
127
+ );
128
+
129
+ return this.finalize(terminal ?? pendingResult(request.orderRef));
130
+ }
131
+
132
+ async cancelTransaction(
133
+ _onStateChange: (state: PaymentInteractionState) => void,
134
+ ): Promise<boolean> {
135
+ if (!this.currentClientToken) {
136
+ return false;
137
+ }
138
+
139
+ try {
140
+ await this.sessionApi.cancelVivaSession(this.currentClientToken);
141
+ } catch (error) {
142
+ throw new PaymentSDKError(
143
+ PaymentErrorCode.NETWORK_ERROR,
144
+ "Failed to cancel Viva payment session",
145
+ error,
146
+ );
147
+ }
148
+
149
+ this.abortController?.abort();
150
+ return true;
151
+ }
152
+
153
+ async refundTransaction(
154
+ request: RefundRequest,
155
+ _onStateChange: (state: PaymentInteractionState, detail?: { sessionId?: string }) => void,
156
+ ): Promise<PaymentResult> {
157
+ try {
158
+ const payload: ReverseSessionDto = {
159
+ sessionId: request.originalTransactionId,
160
+ amount: request.amountCents,
161
+ };
162
+
163
+ const { data } = await this.sessionApi.refundVivaSession(payload);
164
+
165
+ const isRefunded =
166
+ data.status === PaymentSessionStatus.Refunded ||
167
+ data.status === PaymentSessionStatus.Refunding;
168
+
169
+ const result: PaymentResult = {
170
+ success: isRefunded,
171
+ status: isRefunded ? SdkPaymentStatus.SUCCESS : SdkPaymentStatus.FAILED,
172
+ orderId: request.orderRef,
173
+ transactionId: data.providerTransactionId ?? data.sessionId,
174
+ };
175
+
176
+ if (!isRefunded) {
177
+ if (data.errorCode) result.errorCode = data.errorCode;
178
+ if (data.errorMessage) result.errorMessage = data.errorMessage;
179
+ }
180
+
181
+ return result;
182
+ } catch (error) {
183
+ throw new PaymentSDKError(
184
+ PaymentErrorCode.NETWORK_ERROR,
185
+ "Failed to refund Viva transaction",
186
+ error,
187
+ );
188
+ }
189
+ }
190
+
191
+ async revertTransaction(request: RevertRequest): Promise<PaymentResult> {
192
+ try {
193
+ const payload: ReverseSessionDto = {
194
+ sessionId: request.originalTransactionId,
195
+ };
196
+
197
+ const { data } = await this.sessionApi.revertVivaSession(payload);
198
+
199
+ const isReverted =
200
+ data.status === PaymentSessionStatus.Refunded ||
201
+ data.status === PaymentSessionStatus.Refunding;
202
+
203
+ const result: PaymentResult = {
204
+ success: isReverted,
205
+ status: isReverted ? SdkPaymentStatus.SUCCESS : SdkPaymentStatus.FAILED,
206
+ orderId: request.orderRef,
207
+ transactionId: data.providerTransactionId ?? data.sessionId,
208
+ };
209
+
210
+ if (!isReverted) {
211
+ if (data.errorCode) result.errorCode = data.errorCode;
212
+ if (data.errorMessage) result.errorMessage = data.errorMessage;
213
+ }
214
+
215
+ return result;
216
+ } catch (error) {
217
+ throw new PaymentSDKError(
218
+ PaymentErrorCode.NETWORK_ERROR,
219
+ "Failed to revert Viva transaction",
220
+ error,
221
+ );
222
+ }
223
+ }
224
+
225
+ abort(): void {
226
+ this.abortController?.abort();
227
+ }
228
+
229
+ private async pollUntilTerminal(
230
+ clientToken: string,
231
+ request: PaymentRequest,
232
+ signal: AbortSignal | undefined,
233
+ durationMs: number,
234
+ ): Promise<PaymentResult | undefined> {
235
+ const deadline = Date.now() + durationMs;
236
+
237
+ while (Date.now() < deadline) {
238
+ if (signal?.aborted) {
239
+ throw new Error("Aborted");
240
+ }
241
+
242
+ let data: PaymentSessionViewDto | undefined;
243
+ try {
244
+ data = (await this.sessionApi.getPaymentSession(clientToken)).data;
245
+ } catch (error) {
246
+ if (error instanceof Error && error.message === "Aborted") throw error;
247
+ }
248
+
249
+ if (signal?.aborted) {
250
+ throw new Error("Aborted");
251
+ }
252
+
253
+ if (data) {
254
+ const result = mapSessionResult(data, request.orderRef);
255
+ if (result.status !== SdkPaymentStatus.PENDING) {
256
+ return result;
257
+ }
258
+ }
259
+
260
+ await this.delay(VivaSessionStrategy.POLLING_INTERVAL_MS, signal);
261
+ }
262
+
263
+ return undefined;
264
+ }
265
+
266
+ private waitForCompletion(
267
+ clientToken: string,
268
+ sessionId: string,
269
+ request: PaymentRequest,
270
+ signal: AbortSignal,
271
+ ): Promise<PaymentResult | undefined> {
272
+ const channelName = `viva.${this.config.channel.toLowerCase()}.requests.${sessionId}`;
273
+
274
+ return new Promise((resolve, reject) => {
275
+ let settled = false;
276
+
277
+ const cleanup = () => {
278
+ settled = true;
279
+ if (typeof unsubscribe === "function") unsubscribe();
280
+ clearTimeout(pollTimer);
281
+ signal.removeEventListener("abort", onAbort);
282
+ };
283
+
284
+ const onAbort = () => {
285
+ cleanup();
286
+ reject(new Error("Aborted"));
287
+ };
288
+ signal.addEventListener("abort", onAbort);
289
+
290
+ const unsubscribe = this.messaging.subscribe<PaymentStatusDto>(
291
+ channelName,
292
+ "payment:status-changed",
293
+ (data: PaymentStatusDto) => {
294
+ if (settled) return;
295
+ if (data.status === SimplePaymentStatus.Pending) return;
296
+ cleanup();
297
+ resolve(this.mapStatusDto(data, request));
298
+ },
299
+ );
300
+
301
+ const pollTimer = setTimeout(() => {
302
+ if (settled) return;
303
+ this.pollUntilTerminal(
304
+ clientToken,
305
+ request,
306
+ signal,
307
+ VivaSessionStrategy.POLLING_DURATION_MS,
308
+ ).then(
309
+ (result) => {
310
+ if (settled) return;
311
+ cleanup();
312
+ resolve(result);
313
+ },
314
+ (error) => {
315
+ if (settled) return;
316
+ cleanup();
317
+ reject(error);
318
+ },
319
+ );
320
+ }, VivaSessionStrategy.INITIAL_POLL_DELAY_MS);
321
+ });
322
+ }
323
+
324
+ private mapStatusDto(
325
+ data: PaymentStatusDto,
326
+ request: PaymentRequest,
327
+ ): PaymentResult {
328
+ const isSuccess = data.status === SimplePaymentStatus.Success;
329
+
330
+ const result: PaymentResult = {
331
+ success: isSuccess,
332
+ status: isSuccess ? SdkPaymentStatus.SUCCESS : SdkPaymentStatus.FAILED,
333
+ orderId: request.orderRef,
334
+ errorCode:
335
+ data.error?.code || (isSuccess ? "" : PaymentFailureCode.SystemUnknown),
336
+ errorMessage:
337
+ data.error?.message ||
338
+ (isSuccess ? "" : "Transaction failed without error details"),
339
+ };
340
+
341
+ if (data.transactionId) {
342
+ result.transactionId = data.transactionId;
343
+ }
344
+ if (data.error?.referenceError) {
345
+ result.errorReference = data.error.referenceError;
346
+ }
347
+ if (data.transaction) {
348
+ result.transaction = data.transaction as unknown as TransactionDto;
349
+ }
350
+
351
+ return result;
352
+ }
353
+
354
+ private finalize(result: PaymentResult): PaymentResult {
355
+ if (result.status !== SdkPaymentStatus.PENDING) {
356
+ this.idempotencyKey = null;
357
+ this.idempotencyOrderRef = null;
358
+ }
359
+ return result;
360
+ }
361
+
362
+ private armCancel(clientToken: string): Promise<void> {
363
+ return this.sessionApi.cancelVivaSession(clientToken).then(
364
+ () => undefined,
365
+ () => undefined,
366
+ );
367
+ }
368
+
369
+ private generateIdempotencyKey(): string {
370
+ const cryptoRef = (
371
+ globalThis as { crypto?: { randomUUID?: () => string } }
372
+ ).crypto;
373
+
374
+ if (cryptoRef?.randomUUID) {
375
+ return cryptoRef.randomUUID();
376
+ }
377
+
378
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (char) => {
379
+ const random = (Math.random() * 16) | 0;
380
+ const value = char === "x" ? random : (random & 0x3) | 0x8;
381
+ return value.toString(16);
382
+ });
383
+ }
384
+
385
+ private delay(ms: number, signal?: AbortSignal): Promise<void> {
386
+ return new Promise((resolve) => {
387
+ const onAbort = () => {
388
+ clearTimeout(timeout);
389
+ resolve();
390
+ };
391
+ if (signal) {
392
+ signal.addEventListener("abort", onAbort, { once: true });
393
+ }
394
+ const timeout = setTimeout(() => {
395
+ if (signal) {
396
+ signal.removeEventListener("abort", onAbort);
397
+ }
398
+ resolve();
399
+ }, ms);
400
+ });
401
+ }
402
+ }
@@ -0,0 +1,92 @@
1
+ import {
2
+ PaymentFailureCode,
3
+ PaymentSessionStatus,
4
+ type PaymentSessionViewDto,
5
+ } from "@munchi_oy/core";
6
+
7
+ import { PaymentErrorCode, PaymentSDKError } from "../error";
8
+ import { type PaymentResult, SdkPaymentStatus } from "../types/payment";
9
+
10
+ function assertNever(value: never): never {
11
+ throw new PaymentSDKError(
12
+ PaymentErrorCode.UNKNOWN,
13
+ `Unhandled payment session status: ${String(value)}`,
14
+ );
15
+ }
16
+
17
+ export function pendingResult(orderId: string): PaymentResult {
18
+ return {
19
+ success: false,
20
+ status: SdkPaymentStatus.PENDING,
21
+ orderId,
22
+ errorCode: "",
23
+ errorMessage: "",
24
+ };
25
+ }
26
+
27
+ export function mapSessionResult(
28
+ data: PaymentSessionViewDto,
29
+ orderId: string,
30
+ ): PaymentResult {
31
+ switch (data.status) {
32
+ case PaymentSessionStatus.Initiated:
33
+ case PaymentSessionStatus.Processing:
34
+ case PaymentSessionStatus.Voiding:
35
+ case PaymentSessionStatus.Refunding:
36
+ return pendingResult(orderId);
37
+
38
+ case PaymentSessionStatus.Success:
39
+ case PaymentSessionStatus.Closed: {
40
+ const result: PaymentResult = {
41
+ success: true,
42
+ status: SdkPaymentStatus.SUCCESS,
43
+ orderId,
44
+ errorCode: "",
45
+ errorMessage: "",
46
+ };
47
+ if (data.providerTransactionId) {
48
+ result.transactionId = data.providerTransactionId;
49
+ }
50
+ return result;
51
+ }
52
+
53
+ case PaymentSessionStatus.VoidFailed:
54
+ case PaymentSessionStatus.Failed: {
55
+ const result: PaymentResult = {
56
+ success: false,
57
+ status: SdkPaymentStatus.FAILED,
58
+ orderId,
59
+ errorCode: data.errorCode || PaymentFailureCode.SystemUnknown,
60
+ errorMessage:
61
+ data.errorMessage || "Transaction failed without error details",
62
+ };
63
+ if (data.providerTransactionId) {
64
+ result.transactionId = data.providerTransactionId;
65
+ }
66
+ return result;
67
+ }
68
+
69
+ case PaymentSessionStatus.Cancelled:
70
+ case PaymentSessionStatus.Voided:
71
+ case PaymentSessionStatus.Refunded: {
72
+ const result: PaymentResult = {
73
+ success: false,
74
+ status: SdkPaymentStatus.CANCELLED,
75
+ orderId,
76
+ };
77
+ if (data.errorCode) {
78
+ result.errorCode = data.errorCode;
79
+ }
80
+ if (data.errorMessage) {
81
+ result.errorMessage = data.errorMessage;
82
+ }
83
+ if (data.providerTransactionId) {
84
+ result.transactionId = data.providerTransactionId;
85
+ }
86
+ return result;
87
+ }
88
+
89
+ default:
90
+ return assertNever(data.status);
91
+ }
92
+ }
@@ -1,8 +1,9 @@
1
1
  import type {
2
- CurrencyCode,
3
- PaymentProvider,
4
- ProviderEnum,
5
- TransactionDto,
2
+ CurrencyCode,
3
+ PaymentProvider,
4
+ ProviderEnum,
5
+ SplitContextDto,
6
+ TransactionDto,
6
7
  } from "@munchi_oy/core";
7
8
 
8
9
  export type TransactionDetails = Omit<
@@ -63,6 +64,12 @@ export interface PaymentRequest {
63
64
  currency: CurrencyCode;
64
65
  displayId: string;
65
66
  options?: VivaOptions | NetsOptions | WorldlineOptions;
67
+ /**
68
+ * Split context for the payment-session flow. Omit for a full-cart payment,
69
+ * or send { mode, partId|seatId } for a split. Must match the order's
70
+ * checkoutModel. Only consumed by VivaSessionStrategy.
71
+ */
72
+ splitContext?: SplitContextDto;
66
73
  }
67
74
 
68
75
  export interface RefundRequest {
@@ -83,9 +90,33 @@ export interface RefundRequest {
83
90
  options?: VivaRefundOptions | NetsRefundOptions | WorldlineRefundOptions;
84
91
  }
85
92
 
93
+ export interface ManualPaymentRequest {
94
+ orderRef: string;
95
+ amountCents: number;
96
+ paymethod: string;
97
+ tipAmount?: number;
98
+ splitContext?: SplitContextDto;
99
+ /**
100
+ * Arbitrary client metadata persisted on the session (e.g. voucher code,
101
+ * gift card id, employee id, external reference, notes). Surfaced to
102
+ * reconciliation when building the order paymentEvent.
103
+ */
104
+ metadata?: Record<string, unknown>;
105
+ }
106
+
107
+ export interface VoidRequest {
108
+ orderRef: string;
109
+ sessionId: string;
110
+ }
111
+
112
+ export interface RevertRequest {
113
+ orderRef: string;
114
+ originalTransactionId: string;
115
+ }
116
+
86
117
  export interface PaymentTerminalConfig {
87
- channel: ProviderEnum,
88
- provider?: PaymentProvider | null;
118
+ channel: ProviderEnum;
119
+ provider: PaymentProvider;
89
120
  kioskId: string;
90
121
  storeId: string;
91
122
  }
@@ -102,13 +133,28 @@ export interface PaymentResult {
102
133
  }
103
134
 
104
135
  export interface TransactionOptions {
105
- onConnecting?: (ctx: { orderRef: string; refPaymentId?: string | undefined }) => void;
106
- onRequiresInput?: (ctx: { orderRef: string; refPaymentId?: string | undefined }) => void;
107
- onProcessing?: (ctx: { orderRef: string; refPaymentId?: string | undefined }) => void;
108
- onVerifying?: (ctx: { orderRef: string; refPaymentId?: string | undefined }) => void;
136
+ onConnecting?: (ctx: {
137
+ orderRef: string;
138
+ refPaymentId?: string | undefined;
139
+ }) => void;
140
+ onRequiresInput?: (ctx: {
141
+ orderRef: string;
142
+ refPaymentId?: string | undefined;
143
+ }) => void;
144
+ onProcessing?: (ctx: {
145
+ orderRef: string;
146
+ refPaymentId?: string | undefined;
147
+ }) => void;
148
+ onVerifying?: (ctx: {
149
+ orderRef: string;
150
+ refPaymentId?: string | undefined;
151
+ }) => void;
109
152
  onSuccess?: (result: PaymentResult) => void;
110
153
  onError?: (result: PaymentResult) => void;
111
- onCancelled?: (ctx: { orderRef: string; refPaymentId?: string | undefined }) => void;
154
+ onCancelled?: (ctx: {
155
+ orderRef: string;
156
+ refPaymentId?: string | undefined;
157
+ }) => void;
112
158
  }
113
159
 
114
160
  export interface IMessagingAdapter {
@@ -129,6 +175,21 @@ export interface IMunchiPaymentSDK {
129
175
  options?: TransactionOptions,
130
176
  ): Promise<PaymentResult>;
131
177
  cancel(): Promise<boolean>;
132
- refund(params: RefundRequest, options?: TransactionOptions): Promise<PaymentResult>;
178
+ refund(
179
+ params: RefundRequest,
180
+ options?: TransactionOptions,
181
+ ): Promise<PaymentResult>;
182
+ revert(
183
+ params: RevertRequest,
184
+ options?: TransactionOptions,
185
+ ): Promise<PaymentResult>;
186
+ recordManualPayment(
187
+ params: ManualPaymentRequest,
188
+ options?: TransactionOptions,
189
+ ): Promise<PaymentResult>;
190
+ voidManualPayment(
191
+ params: VoidRequest,
192
+ options?: TransactionOptions,
193
+ ): Promise<PaymentResult>;
133
194
  reset(): void;
134
195
  }
package/src/types/sdk.ts CHANGED
@@ -57,4 +57,14 @@ export interface SDKOptions {
57
57
  */
58
58
  autoResetOnPaymentComplete?: AutoResetOptions;
59
59
  appToApp?: AppToAppConfig;
60
+ /**
61
+ * Routes POS Viva payments through the backend payment-session flow
62
+ * (VivaSessionStrategy) instead of the legacy v4 VivaStrategy. Kiosk/handheld
63
+ * leave this unset and stay on the legacy flow.
64
+ */
65
+ paymentSession?: PaymentSessionConfig;
66
+ }
67
+
68
+ export interface PaymentSessionConfig {
69
+ enabled: boolean;
60
70
  }
package/src/version.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  // This file is auto-generated. Do not edit manually.
2
2
  // Run 'pnpm version:sync' to update this file.
3
- export const VERSION = "1.6.10";
3
+ export const VERSION = "1.10.1";