Skip to content

Commit 81d7911

Browse files
authored
feat(cloud): ✨ add checkout integration and payment callback flow (#55)
* feat(cloud): ✨ integrate checkout API with payment callback handling * feat(payment): ✨ add payment history tab and robust checkout polling * feat(account): ✨ add fullscreen confetti on payment success
1 parent a45d68f commit 81d7911

19 files changed

Lines changed: 1849 additions & 64 deletions

File tree

src/core/infrastructure/services/CloudService.ts

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

src/main/index.ts

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,45 @@ import fileLogic from "../core/infrastructure/services/FileService.js";
8888

8989
// 自定义协议名称(用于 OAuth 回调)
9090
const PROTOCOL_NAME = 'markpdfdown';
91+
const PAYMENT_CALLBACK_EVENT = 'payment:callback';
92+
93+
interface PaymentCallbackPayload {
94+
url: string;
95+
status: string | null;
96+
sessionId: string | null;
97+
amountUsd: number | null;
98+
creditsToAdd: number | null;
99+
query: Record<string, string>;
100+
receivedAt: string;
101+
}
102+
103+
let pendingPaymentCallback: PaymentCallbackPayload | null = null;
104+
105+
function toNullableNumber(value: string | null): number | null {
106+
if (value === null || value.trim() === '') {
107+
return null;
108+
}
109+
const parsed = Number(value);
110+
return Number.isFinite(parsed) ? parsed : null;
111+
}
112+
113+
function dispatchPaymentCallback(payload: PaymentCallbackPayload) {
114+
if (!mainWindow) {
115+
pendingPaymentCallback = payload;
116+
return;
117+
}
118+
119+
const sendPayload = () => {
120+
mainWindow?.webContents.send(PAYMENT_CALLBACK_EVENT, payload);
121+
};
122+
123+
if (mainWindow.webContents.isLoadingMainFrame()) {
124+
mainWindow.webContents.once('did-finish-load', sendPayload);
125+
return;
126+
}
127+
128+
sendPayload();
129+
}
91130

92131
// 在 app ready 之前注册自定义协议的权限
93132
protocol.registerSchemesAsPrivileged([
@@ -123,10 +162,13 @@ function handleProtocolUrl(url: string) {
123162
}
124163

125164
// 解析并严格校验路径(直接使用 URL 结构化组件,不解码 host)
165+
let parsed: URL;
166+
let host: string;
167+
let pathname: string;
126168
try {
127-
const parsed = new URL(url);
128-
const host = parsed.host.toLowerCase();
129-
const pathname = parsed.pathname.replace(/\/+/g, '/').replace(/\/+$/, '');
169+
parsed = new URL(url);
170+
host = parsed.host.toLowerCase();
171+
pathname = parsed.pathname.replace(/\/+/g, '/').replace(/\/+$/, '');
130172

131173
// Reject percent-encoded slashes in host (bypass attempt)
132174
if (parsed.host.includes('%')) {
@@ -136,7 +178,8 @@ function handleProtocolUrl(url: string) {
136178

137179
const isAllowed =
138180
(host === 'auth' && pathname === '/callback') ||
139-
(host === 'auth' && pathname === '');
181+
(host === 'auth' && pathname === '') ||
182+
(host === 'payment' && pathname === '/callback');
140183

141184
if (!isAllowed) {
142185
console.warn(`[Main] Ignoring protocol URL with unexpected path: ${host}${pathname}`);
@@ -156,7 +199,25 @@ function handleProtocolUrl(url: string) {
156199
}
157200

158201
// 立即检查 token 状态,加速获取 token
159-
authManager.checkDeviceTokenStatus();
202+
if (host === 'auth') {
203+
authManager.checkDeviceTokenStatus();
204+
return;
205+
}
206+
207+
if (host === 'payment' && pathname === '/callback') {
208+
const payload: PaymentCallbackPayload = {
209+
url,
210+
status: parsed.searchParams.get('status'),
211+
sessionId: parsed.searchParams.get('session_id'),
212+
amountUsd: toNullableNumber(parsed.searchParams.get('amount_usd')),
213+
creditsToAdd: toNullableNumber(parsed.searchParams.get('credits_to_add')),
214+
query: Object.fromEntries(parsed.searchParams.entries()),
215+
receivedAt: new Date().toISOString(),
216+
};
217+
218+
console.log('[Main] Payment callback received:', payload.status || 'unknown');
219+
dispatchPaymentCallback(payload);
220+
}
160221
}
161222

162223
// macOS: 通过 open-url 事件处理协议 URL
@@ -351,6 +412,11 @@ function createWindow() {
351412
mainWindow = null;
352413
windowManager.setMainWindow(null);
353414
});
415+
416+
if (pendingPaymentCallback) {
417+
dispatchPaymentCallback(pendingPaymentCallback);
418+
pendingPaymentCallback = null;
419+
}
354420
}
355421

356422
app.whenReady().then(async () => {

0 commit comments

Comments
 (0)