@@ -12,8 +12,23 @@ import type {
1212 CloudCancelTaskResponse ,
1313 CloudRetryPageResponse ,
1414 CloudApiPagination ,
15+ PaymentCheckoutApiResponse ,
16+ PaymentCheckoutStatusApiResponse ,
17+ PaymentHistoryApiItem ,
1518} from '../../../shared/types/cloud-api.js' ;
1619
20+ const PAYMENT_STATUSES = new Set ( [ 'pending' , 'completed' , 'failed' , 'refunded' ] ) ;
21+ const PAYMENT_PROVIDER_STATUSES = new Set ( [
22+ 'pending' ,
23+ 'processing' ,
24+ 'completed' ,
25+ 'failed' ,
26+ 'canceled' ,
27+ 'expired' ,
28+ 'refunded' ,
29+ 'unknown' ,
30+ ] ) ;
31+
1732/**
1833 * CloudService handles interaction with the MarkPDFDown Cloud API
1934 */
@@ -22,6 +37,47 @@ class CloudService {
2237
2338 private constructor ( ) { }
2439
40+ private normalizeCheckoutStatus ( data : any ) : PaymentCheckoutStatusApiResponse | null {
41+ if ( ! data || typeof data !== 'object' ) {
42+ return null ;
43+ }
44+
45+ const sessionId = typeof data . session_id === 'string' ? data . session_id . trim ( ) : '' ;
46+ const status = typeof data . status === 'string' ? data . status : '' ;
47+ const providerStatus = typeof data . provider_status === 'string' ? data . provider_status : '' ;
48+ const amountUsd = Number ( data . amount_usd ) ;
49+ const creditsAdded = Number ( data . credits_added ) ;
50+ const createdAt = typeof data . created_at === 'string' ? data . created_at : '' ;
51+
52+ if ( ! sessionId || ! PAYMENT_STATUSES . has ( status ) ) {
53+ return null ;
54+ }
55+ if ( ! providerStatus || ! PAYMENT_PROVIDER_STATUSES . has ( providerStatus ) ) {
56+ return null ;
57+ }
58+ if ( ! Number . isFinite ( amountUsd ) || ! Number . isFinite ( creditsAdded ) ) {
59+ return null ;
60+ }
61+ if ( typeof data . is_final !== 'boolean' || typeof data . changed !== 'boolean' ) {
62+ return null ;
63+ }
64+ if ( ! createdAt ) {
65+ return null ;
66+ }
67+
68+ return {
69+ session_id : sessionId ,
70+ order_id : typeof data . order_id === 'string' ? data . order_id : undefined ,
71+ status,
72+ provider_status : providerStatus ,
73+ is_final : data . is_final ,
74+ changed : data . changed ,
75+ amount_usd : amountUsd ,
76+ credits_added : creditsAdded ,
77+ created_at : createdAt ,
78+ } ;
79+ }
80+
2581 public static getInstance ( ) : CloudService {
2682 if ( ! CloudService . instance ) {
2783 CloudService . instance = new CloudService ( ) ;
@@ -452,6 +508,153 @@ class CloudService {
452508 }
453509 }
454510
511+ /**
512+ * Create payment checkout session
513+ */
514+ public async createCheckout ( amountUsd : number ) : Promise < {
515+ success : boolean ;
516+ data ?: PaymentCheckoutApiResponse ;
517+ error ?: string ;
518+ } > {
519+ try {
520+ if ( ! Number . isFinite ( amountUsd ) || amountUsd <= 0 ) {
521+ return { success : false , error : 'Invalid top-up amount' } ;
522+ }
523+
524+ const res = await authManager . fetchWithAuth ( `${ API_BASE_URL } /api/v1/payment/checkout` , {
525+ method : 'POST' ,
526+ headers : { 'Content-Type' : 'application/json' } ,
527+ body : JSON . stringify ( { amount_usd : amountUsd } ) ,
528+ } ) ;
529+
530+ const responseJson = await res . json ( ) . catch ( ( ) => null ) ;
531+ if ( ! res . ok || ! responseJson ?. success ) {
532+ const serverMessage = responseJson ?. error ?. message ;
533+ const allowedAmounts = responseJson ?. error ?. details ?. allowed_amounts_usd ;
534+ const allowedSuffix = Array . isArray ( allowedAmounts ) && allowedAmounts . length > 0
535+ ? ` (allowed: ${ allowedAmounts . join ( ', ' ) } )`
536+ : '' ;
537+
538+ return {
539+ success : false ,
540+ error : serverMessage
541+ ? `${ serverMessage } ${ allowedSuffix } `
542+ : `Failed to create checkout session: ${ res . status } ` ,
543+ } ;
544+ }
545+
546+ if ( ! responseJson . data ?. checkout_url || ! responseJson . data ?. session_id ) {
547+ return { success : false , error : 'Invalid checkout response' } ;
548+ }
549+
550+ return { success : true , data : responseJson . data } ;
551+ } catch ( error ) {
552+ console . error ( '[CloudService] createCheckout error:' , error ) ;
553+ return {
554+ success : false ,
555+ error : error instanceof Error ? error . message : String ( error ) ,
556+ } ;
557+ }
558+ }
559+
560+ /**
561+ * Query checkout order status by session_id (supports long polling)
562+ */
563+ public async getCheckoutStatus ( sessionId : string , waitSeconds : number = 10 ) : Promise < {
564+ success : boolean ;
565+ data ?: PaymentCheckoutStatusApiResponse ;
566+ error ?: string ;
567+ } > {
568+ try {
569+ const normalizedSessionId = typeof sessionId === 'string' ? sessionId . trim ( ) : '' ;
570+ if ( ! normalizedSessionId ) {
571+ return { success : false , error : 'Invalid checkout session id' } ;
572+ }
573+
574+ if ( ! Number . isFinite ( waitSeconds ) ) {
575+ return { success : false , error : 'Invalid wait_seconds' } ;
576+ }
577+ const normalizedWaitSeconds = Math . min ( 30 , Math . max ( 0 , Math . floor ( waitSeconds ) ) ) ;
578+
579+ const params = new URLSearchParams ( {
580+ wait_seconds : String ( normalizedWaitSeconds ) ,
581+ } ) ;
582+
583+ // Long-polling endpoint can hold the connection up to wait_seconds.
584+ // Leave enough headroom for network jitter/proxy buffering to avoid local abort.
585+ const requestTimeoutMs = Math . max ( ( normalizedWaitSeconds + 20 ) * 1000 , 30000 ) ;
586+
587+ const res = await authManager . fetchWithAuth (
588+ `${ API_BASE_URL } /api/v1/payment/checkout/${ encodeURIComponent ( normalizedSessionId ) } /status?${ params . toString ( ) } ` ,
589+ { } ,
590+ { timeoutMs : requestTimeoutMs } ,
591+ ) ;
592+
593+ const responseJson = await res . json ( ) . catch ( ( ) => null ) ;
594+ if ( ! res . ok || ! responseJson ?. success ) {
595+ return {
596+ success : false ,
597+ error : responseJson ?. error ?. message || `Failed to query checkout status: ${ res . status } ` ,
598+ } ;
599+ }
600+
601+ const data = this . normalizeCheckoutStatus ( responseJson . data ) ;
602+ if ( ! data ) {
603+ return { success : false , error : 'Invalid checkout status response' } ;
604+ }
605+
606+ return { success : true , data } ;
607+ } catch ( error ) {
608+ console . error ( '[CloudService] getCheckoutStatus error:' , error ) ;
609+ return {
610+ success : false ,
611+ error : error instanceof Error ? error . message : String ( error ) ,
612+ } ;
613+ }
614+ }
615+
616+ /**
617+ * Trigger proactive reconciliation for a checkout session
618+ */
619+ public async reconcileCheckout ( sessionId : string ) : Promise < {
620+ success : boolean ;
621+ data ?: PaymentCheckoutStatusApiResponse ;
622+ error ?: string ;
623+ } > {
624+ try {
625+ const normalizedSessionId = typeof sessionId === 'string' ? sessionId . trim ( ) : '' ;
626+ if ( ! normalizedSessionId ) {
627+ return { success : false , error : 'Invalid checkout session id' } ;
628+ }
629+
630+ const res = await authManager . fetchWithAuth (
631+ `${ API_BASE_URL } /api/v1/payment/checkout/${ encodeURIComponent ( normalizedSessionId ) } /reconcile` ,
632+ { method : 'POST' } ,
633+ ) ;
634+
635+ const responseJson = await res . json ( ) . catch ( ( ) => null ) ;
636+ if ( ! res . ok || ! responseJson ?. success ) {
637+ return {
638+ success : false ,
639+ error : responseJson ?. error ?. message || `Failed to reconcile checkout: ${ res . status } ` ,
640+ } ;
641+ }
642+
643+ const data = this . normalizeCheckoutStatus ( responseJson . data ) ;
644+ if ( ! data ) {
645+ return { success : false , error : 'Invalid checkout reconcile response' } ;
646+ }
647+
648+ return { success : true , data } ;
649+ } catch ( error ) {
650+ console . error ( '[CloudService] reconcileCheckout error:' , error ) ;
651+ return {
652+ success : false ,
653+ error : error instanceof Error ? error . message : String ( error ) ,
654+ } ;
655+ }
656+ }
657+
455658 /**
456659 * Get credits info from the cloud API
457660 */
@@ -539,6 +742,55 @@ class CloudService {
539742 }
540743 }
541744
745+ /**
746+ * Get payment history from the cloud API
747+ */
748+ public async getPaymentHistory (
749+ page : number = 1 ,
750+ pageSize : number = 20 ,
751+ ) : Promise < {
752+ success : boolean ;
753+ data ?: PaymentHistoryApiItem [ ] ;
754+ pagination ?: { page : number ; page_size : number ; total : number ; total_pages : number } ;
755+ error ?: string ;
756+ } > {
757+ try {
758+ const params = new URLSearchParams ( {
759+ page : String ( page ) ,
760+ page_size : String ( pageSize ) ,
761+ } ) ;
762+
763+ const res = await authManager . fetchWithAuth (
764+ `${ API_BASE_URL } /api/v1/payment/history?${ params . toString ( ) } ` ,
765+ ) ;
766+
767+ if ( ! res . ok ) {
768+ const errorBody = await res . json ( ) . catch ( ( ) => null ) ;
769+ return {
770+ success : false ,
771+ error : errorBody ?. error ?. message || `Failed to fetch payment history: ${ res . status } ` ,
772+ } ;
773+ }
774+
775+ const responseJson = await res . json ( ) ;
776+ if ( ! responseJson . success ) {
777+ return { success : false , error : responseJson . error ?. message || 'Invalid payment history response' } ;
778+ }
779+
780+ return {
781+ success : true ,
782+ data : responseJson . data ,
783+ pagination : responseJson . pagination ,
784+ } ;
785+ } catch ( error ) {
786+ console . error ( '[CloudService] getPaymentHistory error:' , error ) ;
787+ return {
788+ success : false ,
789+ error : error instanceof Error ? error . message : String ( error ) ,
790+ } ;
791+ }
792+ }
793+
542794 /**
543795 * Delete a cloud task (only terminal states can be deleted)
544796 * Terminal states: FAILED=0, COMPLETED=6, CANCELLED=7, PARTIAL_FAILED=8
0 commit comments