@windrun-huaiin/backend-core 14.0.0 → 14.1.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.
- package/dist/app/api/user/anonymous/init/route.d.ts.map +1 -1
- package/dist/app/api/user/anonymous/init/route.js +320 -8
- package/dist/app/api/user/anonymous/init/route.mjs +320 -8
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/dist/lib/index.js +1 -1
- package/dist/lib/index.mjs +1 -1
- package/dist/lib/stripe-config.d.ts +1 -1
- package/dist/lib/stripe-config.d.ts.map +1 -1
- package/dist/lib/stripe-config.js +25 -16
- package/dist/lib/stripe-config.mjs +25 -16
- package/dist/prisma/prisma.d.ts +1 -1
- package/dist/prisma/prisma.d.ts.map +1 -1
- package/dist/services/stripe/webhook-handler.js +4 -2
- package/dist/services/stripe/webhook-handler.mjs +3 -1
- package/package.json +2 -2
- package/src/app/api/user/anonymous/init/route.ts +384 -10
- package/src/lib/stripe-config.ts +27 -15
- package/src/services/stripe/webhook-handler.ts +3 -1
package/dist/prisma/prisma.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { PrismaClient, Prisma } from '@prisma/client';
|
|
2
|
-
export declare const prisma:
|
|
2
|
+
export declare const prisma: PrismaClient<Prisma.PrismaClientOptions, never, import("@prisma/client/runtime/library").DefaultArgs>;
|
|
3
3
|
export declare function checkAndFallbackWithNonTCClient(tx?: Prisma.TransactionClient): Prisma.TransactionClient | PrismaClient;
|
|
4
4
|
//# sourceMappingURL=prisma.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"prisma.d.ts","sourceRoot":"","sources":["../../src/prisma/prisma.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAC;AAgCtD,eAAO,MAAM,MAAM,
|
|
1
|
+
{"version":3,"file":"prisma.d.ts","sourceRoot":"","sources":["../../src/prisma/prisma.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAC;AAgCtD,eAAO,MAAM,MAAM,uGAIf,CAAC;AAmFL,wBAAgB,+BAA+B,CAAC,EAAE,CAAC,EAAE,MAAM,CAAC,iBAAiB,GAAG,MAAM,CAAC,iBAAiB,GAAG,YAAY,CAEtH"}
|
|
@@ -122,9 +122,10 @@ function handleSubscriptionCheckoutInit(session, transaction) {
|
|
|
122
122
|
throw new Error('No subscription ID in checkout session');
|
|
123
123
|
}
|
|
124
124
|
const subscriptionId = session.subscription;
|
|
125
|
+
const stripe = stripeConfig.getStripe();
|
|
125
126
|
// ===== STEP 1: FETCH EXTERNAL API DATA (BEFORE TRANSACTION) =====
|
|
126
127
|
// 2. Get COMPLETE Stripe subscription details including billing period
|
|
127
|
-
const stripeSubscription = yield
|
|
128
|
+
const stripeSubscription = yield stripe.subscriptions.retrieve(subscriptionId);
|
|
128
129
|
// Extract billing period from subscription items (NOT from top-level subscription object)
|
|
129
130
|
// The current_period_start/end are on SubscriptionItem, not on Subscription
|
|
130
131
|
const subscriptionItem = (_b = (_a = stripeSubscription.items) === null || _a === void 0 ? void 0 : _a.data) === null || _b === void 0 ? void 0 : _b[0];
|
|
@@ -343,8 +344,9 @@ function handleSubscriptionDeleted(stripeSubscription) {
|
|
|
343
344
|
function handleAsyncPaymentSucceeded(session) {
|
|
344
345
|
return tslib_es6.__awaiter(this, void 0, void 0, function* () {
|
|
345
346
|
console.log(`Async payment succeeded: ${session.id}`);
|
|
347
|
+
const stripe = stripeConfig.getStripe();
|
|
346
348
|
// Retrieve the latest session state to ensure payment_status is up to date
|
|
347
|
-
const latestSession = yield
|
|
349
|
+
const latestSession = yield stripe.checkout.sessions.retrieve(session.id);
|
|
348
350
|
return yield handleCheckoutCompleted(latestSession);
|
|
349
351
|
});
|
|
350
352
|
}
|
|
@@ -9,7 +9,7 @@ import '@prisma/client';
|
|
|
9
9
|
import { Apilogger } from '../database/apilog.service.mjs';
|
|
10
10
|
import { oneTimeExpiredDays } from '../../lib/credit-init.mjs';
|
|
11
11
|
import { getCreditsFromPriceId } from '../../lib/money-price-config.mjs';
|
|
12
|
-
import {
|
|
12
|
+
import { getStripe, fetchPaymentId } from '../../lib/stripe-config.mjs';
|
|
13
13
|
import { viewLocalTime } from '@windrun-huaiin/lib/utils';
|
|
14
14
|
|
|
15
15
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
@@ -120,6 +120,7 @@ function handleSubscriptionCheckoutInit(session, transaction) {
|
|
|
120
120
|
throw new Error('No subscription ID in checkout session');
|
|
121
121
|
}
|
|
122
122
|
const subscriptionId = session.subscription;
|
|
123
|
+
const stripe = getStripe();
|
|
123
124
|
// ===== STEP 1: FETCH EXTERNAL API DATA (BEFORE TRANSACTION) =====
|
|
124
125
|
// 2. Get COMPLETE Stripe subscription details including billing period
|
|
125
126
|
const stripeSubscription = yield stripe.subscriptions.retrieve(subscriptionId);
|
|
@@ -341,6 +342,7 @@ function handleSubscriptionDeleted(stripeSubscription) {
|
|
|
341
342
|
function handleAsyncPaymentSucceeded(session) {
|
|
342
343
|
return __awaiter(this, void 0, void 0, function* () {
|
|
343
344
|
console.log(`Async payment succeeded: ${session.id}`);
|
|
345
|
+
const stripe = getStripe();
|
|
344
346
|
// Retrieve the latest session state to ensure payment_status is up to date
|
|
345
347
|
const latestSession = yield stripe.checkout.sessions.retrieve(session.id);
|
|
346
348
|
return yield handleCheckoutCompleted(latestSession);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@windrun-huaiin/backend-core",
|
|
3
|
-
"version": "14.
|
|
3
|
+
"version": "14.1.1",
|
|
4
4
|
"description": "Shared backend primitives: Prisma schema/client, database services, routing helpers",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -95,7 +95,7 @@
|
|
|
95
95
|
"svix": "^1.86.0",
|
|
96
96
|
"zod": "^4.3.6",
|
|
97
97
|
"@windrun-huaiin/lib": "^14.0.0",
|
|
98
|
-
"@windrun-huaiin/third-ui": "^14.0.
|
|
98
|
+
"@windrun-huaiin/third-ui": "^14.0.2"
|
|
99
99
|
},
|
|
100
100
|
"devDependencies": {
|
|
101
101
|
"@rollup/plugin-alias": "^5.1.1",
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
};
|
|
7
7
|
|
|
8
8
|
import { userAggregateService } from '@/aggregate/user.aggregate.service';
|
|
9
|
-
import { XCredit, XSubscription, XUser } from '@windrun-huaiin/third-ui/fingerprint';
|
|
9
|
+
import type { XCredit, XSubscription, XUser } from '@windrun-huaiin/third-ui/fingerprint';
|
|
10
10
|
import { extractFingerprintFromNextRequest } from '@windrun-huaiin/third-ui/fingerprint/server';
|
|
11
11
|
import { auth } from '@clerk/nextjs/server';
|
|
12
12
|
import { NextRequest, NextResponse } from 'next/server';
|
|
@@ -70,19 +70,59 @@ function createErrorResponse(message: string, status = 400): NextResponse {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
type SourceRefData = Prisma.InputJsonObject & {
|
|
73
|
+
capturedAt?: string;
|
|
74
|
+
landingUrl?: string;
|
|
75
|
+
landingPath?: string;
|
|
76
|
+
landingHost?: string;
|
|
73
77
|
httpRefer?: string;
|
|
78
|
+
refererHost?: string;
|
|
79
|
+
refererPath?: string;
|
|
80
|
+
refererDomain?: string;
|
|
81
|
+
sourceType?: string;
|
|
82
|
+
sourceChannel?: string;
|
|
83
|
+
sourcePlatform?: string;
|
|
84
|
+
isInternalReferer?: boolean;
|
|
74
85
|
utmSource?: string;
|
|
75
86
|
utmMedium?: string;
|
|
76
87
|
utmCampaign?: string;
|
|
77
88
|
utmTerm?: string;
|
|
78
89
|
utmContent?: string;
|
|
90
|
+
utmId?: string;
|
|
79
91
|
ref?: string;
|
|
92
|
+
gclid?: string;
|
|
93
|
+
fbclid?: string;
|
|
94
|
+
msclkid?: string;
|
|
95
|
+
ttclid?: string;
|
|
96
|
+
twclid?: string;
|
|
97
|
+
liFatId?: string;
|
|
98
|
+
userAgent?: string;
|
|
99
|
+
deviceType?: string;
|
|
100
|
+
os?: string;
|
|
101
|
+
browser?: string;
|
|
102
|
+
secChUaMobile?: string;
|
|
103
|
+
secChUaPlatform?: string;
|
|
80
104
|
};
|
|
81
105
|
|
|
82
|
-
type SourceRefKey =
|
|
106
|
+
type SourceRefKey =
|
|
107
|
+
| 'utmSource'
|
|
108
|
+
| 'utmMedium'
|
|
109
|
+
| 'utmCampaign'
|
|
110
|
+
| 'utmTerm'
|
|
111
|
+
| 'utmContent'
|
|
112
|
+
| 'utmId'
|
|
113
|
+
| 'ref'
|
|
114
|
+
| 'gclid'
|
|
115
|
+
| 'fbclid'
|
|
116
|
+
| 'msclkid'
|
|
117
|
+
| 'ttclid'
|
|
118
|
+
| 'twclid'
|
|
119
|
+
| 'liFatId';
|
|
83
120
|
|
|
84
121
|
const SOURCE_REF_MAX_LENGTH = 2048;
|
|
85
122
|
const QUERY_PARAM_MAX_LENGTH = 512;
|
|
123
|
+
const USER_AGENT_MAX_LENGTH = 1024;
|
|
124
|
+
const FIRST_TOUCH_HEADER_MAX_LENGTH = 4096;
|
|
125
|
+
const FIRST_TOUCH_HEADER_NAME = 'x-first-touch';
|
|
86
126
|
|
|
87
127
|
function normalizeSourceRef(ref: string | null): string | null {
|
|
88
128
|
if (!ref) {
|
|
@@ -114,6 +154,31 @@ function normalizeQueryParam(value: string | null): string | null {
|
|
|
114
154
|
: trimmed;
|
|
115
155
|
}
|
|
116
156
|
|
|
157
|
+
function decodeHeaderValue(value: string): string | null {
|
|
158
|
+
try {
|
|
159
|
+
return decodeURIComponent(value);
|
|
160
|
+
} catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function mergeSourceRef(target: SourceRefData, source: SourceRefData | null | undefined) {
|
|
166
|
+
if (!source) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const entries = Object.entries(source) as Array<[keyof SourceRefData, SourceRefData[keyof SourceRefData]]>;
|
|
171
|
+
for (const [key, value] of entries) {
|
|
172
|
+
if (value === undefined || value === null) {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (target[key] === undefined) {
|
|
177
|
+
(target as Record<string, unknown>)[key as string] = value;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
117
182
|
function applySearchParams(sourceRef: SourceRefData, params: URLSearchParams) {
|
|
118
183
|
const setIfEmpty = (key: SourceRefKey, value: string | null) => {
|
|
119
184
|
if (sourceRef[key] !== undefined) {
|
|
@@ -130,7 +195,306 @@ function applySearchParams(sourceRef: SourceRefData, params: URLSearchParams) {
|
|
|
130
195
|
setIfEmpty('utmCampaign', params.get('utm_campaign'));
|
|
131
196
|
setIfEmpty('utmTerm', params.get('utm_term'));
|
|
132
197
|
setIfEmpty('utmContent', params.get('utm_content'));
|
|
198
|
+
setIfEmpty('utmId', params.get('utm_id'));
|
|
133
199
|
setIfEmpty('ref', params.get('ref'));
|
|
200
|
+
setIfEmpty('gclid', params.get('gclid'));
|
|
201
|
+
setIfEmpty('fbclid', params.get('fbclid'));
|
|
202
|
+
setIfEmpty('msclkid', params.get('msclkid'));
|
|
203
|
+
setIfEmpty('ttclid', params.get('ttclid'));
|
|
204
|
+
setIfEmpty('twclid', params.get('twclid'));
|
|
205
|
+
setIfEmpty('liFatId', params.get('li_fat_id'));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function normalizeHost(host: string | null | undefined): string | null {
|
|
209
|
+
if (!host) {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return host.trim().toLowerCase() || null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function getRootDomain(host: string | null | undefined): string | null {
|
|
217
|
+
const normalizedHost = normalizeHost(host);
|
|
218
|
+
if (!normalizedHost) {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const hostname = normalizedHost.split(':')[0];
|
|
223
|
+
if (hostname === 'localhost' || /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname)) {
|
|
224
|
+
return hostname;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const parts = hostname.split('.').filter(Boolean);
|
|
228
|
+
if (parts.length <= 2) {
|
|
229
|
+
return hostname;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return parts.slice(-2).join('.');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function isInternalReferer(landingHost: string | null | undefined, refererHost: string | null | undefined): boolean {
|
|
236
|
+
const normalizedLandingHost = normalizeHost(landingHost);
|
|
237
|
+
const normalizedRefererHost = normalizeHost(refererHost);
|
|
238
|
+
if (!normalizedLandingHost || !normalizedRefererHost) {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (normalizedLandingHost === normalizedRefererHost) {
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return normalizedLandingHost.endsWith(`.${normalizedRefererHost}`)
|
|
247
|
+
|| normalizedRefererHost.endsWith(`.${normalizedLandingHost}`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function detectPlatform(value: string | null | undefined): string | null {
|
|
251
|
+
const normalized = value?.trim().toLowerCase();
|
|
252
|
+
if (!normalized) {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const matcherList: Array<{ pattern: RegExp; platform: string; channel: string; }> = [
|
|
257
|
+
{ pattern: /chatgpt|chat-openai|openai/, platform: 'openai', channel: 'ai' },
|
|
258
|
+
{ pattern: /claude|anthropic/, platform: 'anthropic', channel: 'ai' },
|
|
259
|
+
{ pattern: /perplexity/, platform: 'perplexity', channel: 'ai' },
|
|
260
|
+
{ pattern: /gemini/, platform: 'gemini', channel: 'ai' },
|
|
261
|
+
{ pattern: /copilot/, platform: 'copilot', channel: 'ai' },
|
|
262
|
+
{ pattern: /google/, platform: 'google', channel: 'search' },
|
|
263
|
+
{ pattern: /bing/, platform: 'bing', channel: 'search' },
|
|
264
|
+
{ pattern: /baidu/, platform: 'baidu', channel: 'search' },
|
|
265
|
+
{ pattern: /yahoo/, platform: 'yahoo', channel: 'search' },
|
|
266
|
+
{ pattern: /duckduckgo/, platform: 'duckduckgo', channel: 'search' },
|
|
267
|
+
{ pattern: /facebook/, platform: 'facebook', channel: 'social' },
|
|
268
|
+
{ pattern: /instagram/, platform: 'instagram', channel: 'social' },
|
|
269
|
+
{ pattern: /x\.com|twitter/, platform: 'x', channel: 'social' },
|
|
270
|
+
{ pattern: /linkedin/, platform: 'linkedin', channel: 'social' },
|
|
271
|
+
{ pattern: /reddit/, platform: 'reddit', channel: 'social' },
|
|
272
|
+
{ pattern: /youtube/, platform: 'youtube', channel: 'social' },
|
|
273
|
+
];
|
|
274
|
+
|
|
275
|
+
const matched = matcherList.find(({ pattern }) => pattern.test(normalized));
|
|
276
|
+
if (!matched) {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return matched.platform;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function detectChannelFromPlatform(platform: string | null | undefined): string | null {
|
|
284
|
+
switch (platform) {
|
|
285
|
+
case 'openai':
|
|
286
|
+
case 'anthropic':
|
|
287
|
+
case 'perplexity':
|
|
288
|
+
case 'gemini':
|
|
289
|
+
case 'copilot':
|
|
290
|
+
return 'ai';
|
|
291
|
+
case 'google':
|
|
292
|
+
case 'bing':
|
|
293
|
+
case 'baidu':
|
|
294
|
+
case 'yahoo':
|
|
295
|
+
case 'duckduckgo':
|
|
296
|
+
return 'search';
|
|
297
|
+
case 'facebook':
|
|
298
|
+
case 'instagram':
|
|
299
|
+
case 'x':
|
|
300
|
+
case 'linkedin':
|
|
301
|
+
case 'reddit':
|
|
302
|
+
case 'youtube':
|
|
303
|
+
return 'social';
|
|
304
|
+
default:
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function parseUserAgent(request: NextRequest): Pick<SourceRefData, 'userAgent' | 'deviceType' | 'os' | 'browser' | 'secChUaMobile' | 'secChUaPlatform'> {
|
|
310
|
+
const userAgentHeader = request.headers.get('user-agent');
|
|
311
|
+
const secChUaMobile = normalizeQueryParam(request.headers.get('sec-ch-ua-mobile')) ?? undefined;
|
|
312
|
+
const secChUaPlatform = normalizeQueryParam(request.headers.get('sec-ch-ua-platform')) ?? undefined;
|
|
313
|
+
const userAgent = normalizeSourceRef(userAgentHeader)?.slice(0, USER_AGENT_MAX_LENGTH) ?? undefined;
|
|
314
|
+
const ua = userAgent?.toLowerCase() ?? '';
|
|
315
|
+
|
|
316
|
+
let deviceType = 'desktop';
|
|
317
|
+
if (!ua) {
|
|
318
|
+
deviceType = 'unknown';
|
|
319
|
+
} else if (/bot|spider|crawler|curl|wget|headless/.test(ua)) {
|
|
320
|
+
deviceType = 'bot';
|
|
321
|
+
} else if (/ipad|tablet/.test(ua)) {
|
|
322
|
+
deviceType = 'tablet';
|
|
323
|
+
} else if (/mobi|iphone|android/.test(ua) || secChUaMobile === '?1') {
|
|
324
|
+
deviceType = 'mobile';
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
let os = 'Unknown';
|
|
328
|
+
if (/iphone|ipad|ipod/.test(ua)) {
|
|
329
|
+
os = 'iOS';
|
|
330
|
+
} else if (/android/.test(ua)) {
|
|
331
|
+
os = 'Android';
|
|
332
|
+
} else if (/windows nt/.test(ua)) {
|
|
333
|
+
os = 'Windows';
|
|
334
|
+
} else if (/mac os x|macintosh/.test(ua)) {
|
|
335
|
+
os = 'macOS';
|
|
336
|
+
} else if (/cros/.test(ua)) {
|
|
337
|
+
os = 'Chrome OS';
|
|
338
|
+
} else if (/linux/.test(ua)) {
|
|
339
|
+
os = 'Linux';
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (secChUaPlatform) {
|
|
343
|
+
const normalizedPlatform = secChUaPlatform.replaceAll('"', '');
|
|
344
|
+
if (normalizedPlatform && normalizedPlatform !== 'Unknown') {
|
|
345
|
+
os = normalizedPlatform;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
let browser = 'Unknown';
|
|
350
|
+
if (/edg\//.test(ua)) {
|
|
351
|
+
browser = 'Edge';
|
|
352
|
+
} else if (/opr\//.test(ua) || /opera/.test(ua)) {
|
|
353
|
+
browser = 'Opera';
|
|
354
|
+
} else if (/samsungbrowser\//.test(ua)) {
|
|
355
|
+
browser = 'Samsung Internet';
|
|
356
|
+
} else if (/crios\//.test(ua) || /chrome\//.test(ua)) {
|
|
357
|
+
browser = 'Chrome';
|
|
358
|
+
} else if (/firefox\//.test(ua)) {
|
|
359
|
+
browser = 'Firefox';
|
|
360
|
+
} else if (/safari\//.test(ua) && !/chrome\//.test(ua) && !/crios\//.test(ua)) {
|
|
361
|
+
browser = 'Safari';
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
userAgent,
|
|
366
|
+
deviceType,
|
|
367
|
+
os,
|
|
368
|
+
browser,
|
|
369
|
+
secChUaMobile,
|
|
370
|
+
secChUaPlatform,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function parseFirstTouchHeader(request: NextRequest): SourceRefData | null {
|
|
375
|
+
const rawHeader = request.headers.get(FIRST_TOUCH_HEADER_NAME);
|
|
376
|
+
const normalizedHeader = normalizeSourceRef(rawHeader)?.slice(0, FIRST_TOUCH_HEADER_MAX_LENGTH);
|
|
377
|
+
if (!normalizedHeader) {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const decodedHeader = decodeHeaderValue(normalizedHeader);
|
|
382
|
+
if (!decodedHeader) {
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
try {
|
|
387
|
+
const parsed = JSON.parse(decodedHeader) as Record<string, unknown>;
|
|
388
|
+
const sourceRef: SourceRefData = {};
|
|
389
|
+
|
|
390
|
+
sourceRef.capturedAt = normalizeQueryParam(typeof parsed.capturedAt === 'string' ? parsed.capturedAt : null) ?? undefined;
|
|
391
|
+
sourceRef.landingUrl = normalizeSourceRef(typeof parsed.landingUrl === 'string' ? parsed.landingUrl : null) ?? undefined;
|
|
392
|
+
sourceRef.landingPath = normalizeSourceRef(typeof parsed.landingPath === 'string' ? parsed.landingPath : null) ?? undefined;
|
|
393
|
+
sourceRef.landingHost = normalizeHost(typeof parsed.landingHost === 'string' ? parsed.landingHost : null) ?? undefined;
|
|
394
|
+
sourceRef.ref = normalizeQueryParam(typeof parsed.ref === 'string' ? parsed.ref : null) ?? undefined;
|
|
395
|
+
sourceRef.utmSource = normalizeQueryParam(typeof parsed.utmSource === 'string' ? parsed.utmSource : null) ?? undefined;
|
|
396
|
+
sourceRef.utmMedium = normalizeQueryParam(typeof parsed.utmMedium === 'string' ? parsed.utmMedium : null) ?? undefined;
|
|
397
|
+
sourceRef.utmCampaign = normalizeQueryParam(typeof parsed.utmCampaign === 'string' ? parsed.utmCampaign : null) ?? undefined;
|
|
398
|
+
sourceRef.utmTerm = normalizeQueryParam(typeof parsed.utmTerm === 'string' ? parsed.utmTerm : null) ?? undefined;
|
|
399
|
+
sourceRef.utmContent = normalizeQueryParam(typeof parsed.utmContent === 'string' ? parsed.utmContent : null) ?? undefined;
|
|
400
|
+
sourceRef.utmId = normalizeQueryParam(typeof parsed.utmId === 'string' ? parsed.utmId : null) ?? undefined;
|
|
401
|
+
sourceRef.gclid = normalizeQueryParam(typeof parsed.gclid === 'string' ? parsed.gclid : null) ?? undefined;
|
|
402
|
+
sourceRef.fbclid = normalizeQueryParam(typeof parsed.fbclid === 'string' ? parsed.fbclid : null) ?? undefined;
|
|
403
|
+
sourceRef.msclkid = normalizeQueryParam(typeof parsed.msclkid === 'string' ? parsed.msclkid : null) ?? undefined;
|
|
404
|
+
sourceRef.ttclid = normalizeQueryParam(typeof parsed.ttclid === 'string' ? parsed.ttclid : null) ?? undefined;
|
|
405
|
+
sourceRef.twclid = normalizeQueryParam(typeof parsed.twclid === 'string' ? parsed.twclid : null) ?? undefined;
|
|
406
|
+
sourceRef.liFatId = normalizeQueryParam(typeof parsed.liFatId === 'string' ? parsed.liFatId : null) ?? undefined;
|
|
407
|
+
|
|
408
|
+
const externalReferrer = normalizeSourceRef(typeof parsed.externalReferrer === 'string' ? parsed.externalReferrer : null);
|
|
409
|
+
if (externalReferrer) {
|
|
410
|
+
sourceRef.httpRefer = externalReferrer;
|
|
411
|
+
try {
|
|
412
|
+
const refererUrl = new URL(externalReferrer);
|
|
413
|
+
sourceRef.refererHost = normalizeHost(refererUrl.host) ?? undefined;
|
|
414
|
+
sourceRef.refererPath = normalizeSourceRef(refererUrl.pathname) ?? undefined;
|
|
415
|
+
sourceRef.refererDomain = getRootDomain(refererUrl.host) ?? undefined;
|
|
416
|
+
applySearchParams(sourceRef, refererUrl.searchParams);
|
|
417
|
+
} catch (error) {
|
|
418
|
+
console.warn('Failed to parse first-touch referrer url:', error);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return Object.keys(sourceRef).length > 0 ? sourceRef : null;
|
|
423
|
+
} catch (error) {
|
|
424
|
+
console.warn('Failed to parse first-touch header:', error);
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function finalizeAttribution(sourceRef: SourceRefData) {
|
|
430
|
+
const landingHost = normalizeHost(sourceRef.landingHost);
|
|
431
|
+
const refererHost = normalizeHost(sourceRef.refererHost);
|
|
432
|
+
const internal = isInternalReferer(landingHost, refererHost);
|
|
433
|
+
if (internal) {
|
|
434
|
+
sourceRef.isInternalReferer = true;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const utmPlatform = detectPlatform(sourceRef.utmSource) || detectPlatform(sourceRef.ref);
|
|
438
|
+
if (utmPlatform) {
|
|
439
|
+
sourceRef.sourcePlatform = utmPlatform;
|
|
440
|
+
sourceRef.sourceChannel = detectChannelFromPlatform(utmPlatform) ?? sourceRef.sourceChannel ?? 'campaign';
|
|
441
|
+
sourceRef.sourceType = 'campaign';
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (sourceRef.gclid) {
|
|
446
|
+
sourceRef.sourcePlatform = 'google';
|
|
447
|
+
sourceRef.sourceChannel = 'search';
|
|
448
|
+
sourceRef.sourceType = 'campaign';
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (sourceRef.msclkid) {
|
|
453
|
+
sourceRef.sourcePlatform = 'bing';
|
|
454
|
+
sourceRef.sourceChannel = 'search';
|
|
455
|
+
sourceRef.sourceType = 'campaign';
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (sourceRef.fbclid) {
|
|
460
|
+
sourceRef.sourcePlatform = 'facebook';
|
|
461
|
+
sourceRef.sourceChannel = 'social';
|
|
462
|
+
sourceRef.sourceType = 'campaign';
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (sourceRef.ttclid) {
|
|
467
|
+
sourceRef.sourcePlatform = 'tiktok';
|
|
468
|
+
sourceRef.sourceChannel = 'social';
|
|
469
|
+
sourceRef.sourceType = 'campaign';
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (sourceRef.twclid) {
|
|
474
|
+
sourceRef.sourcePlatform = 'x';
|
|
475
|
+
sourceRef.sourceChannel = 'social';
|
|
476
|
+
sourceRef.sourceType = 'campaign';
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (sourceRef.liFatId) {
|
|
481
|
+
sourceRef.sourcePlatform = 'linkedin';
|
|
482
|
+
sourceRef.sourceChannel = 'social';
|
|
483
|
+
sourceRef.sourceType = 'campaign';
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (!internal && refererHost) {
|
|
488
|
+
const refererPlatform = detectPlatform(refererHost) || detectPlatform(sourceRef.httpRefer);
|
|
489
|
+
sourceRef.sourcePlatform = refererPlatform ?? getRootDomain(refererHost) ?? refererHost;
|
|
490
|
+
sourceRef.sourceChannel = detectChannelFromPlatform(refererPlatform) ?? 'referral';
|
|
491
|
+
sourceRef.sourceType = 'referer';
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
sourceRef.sourcePlatform = 'direct';
|
|
496
|
+
sourceRef.sourceChannel = 'direct';
|
|
497
|
+
sourceRef.sourceType = 'direct';
|
|
134
498
|
}
|
|
135
499
|
|
|
136
500
|
// 提取用户首次访问来源
|
|
@@ -138,21 +502,26 @@ function extractSourceRef(request: NextRequest): SourceRefData | null {
|
|
|
138
502
|
const headerRef = request.headers.get('referer') || request.headers.get('referrer');
|
|
139
503
|
const customRef = request.headers.get('x-source-ref');
|
|
140
504
|
const queryRef = request.nextUrl.searchParams.get('ref');
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
505
|
+
const firstTouchRef = parseFirstTouchHeader(request);
|
|
506
|
+
|
|
507
|
+
const sourceRef: SourceRefData = {
|
|
508
|
+
...parseUserAgent(request),
|
|
509
|
+
};
|
|
146
510
|
|
|
147
|
-
|
|
511
|
+
mergeSourceRef(sourceRef, firstTouchRef);
|
|
512
|
+
|
|
513
|
+
sourceRef.landingUrl = sourceRef.landingUrl ?? normalizeSourceRef(request.nextUrl.toString()) ?? undefined;
|
|
514
|
+
sourceRef.landingPath = sourceRef.landingPath ?? normalizeSourceRef(request.nextUrl.pathname) ?? undefined;
|
|
515
|
+
sourceRef.landingHost = sourceRef.landingHost ?? normalizeHost(request.nextUrl.host) ?? undefined;
|
|
516
|
+
sourceRef.ref = sourceRef.ref ?? normalizeQueryParam(queryRef) ?? undefined;
|
|
148
517
|
|
|
149
518
|
let normalizedHttpRef: string | null = null;
|
|
150
|
-
const candidates = [
|
|
519
|
+
const candidates = [customRef, headerRef];
|
|
151
520
|
for (const candidate of candidates) {
|
|
152
521
|
const normalized = normalizeSourceRef(candidate);
|
|
153
522
|
if (normalized) {
|
|
154
523
|
normalizedHttpRef = normalized;
|
|
155
|
-
sourceRef.httpRefer = normalized;
|
|
524
|
+
sourceRef.httpRefer = sourceRef.httpRefer ?? normalized;
|
|
156
525
|
break;
|
|
157
526
|
}
|
|
158
527
|
}
|
|
@@ -163,12 +532,17 @@ function extractSourceRef(request: NextRequest): SourceRefData | null {
|
|
|
163
532
|
if (normalizedHttpRef) {
|
|
164
533
|
try {
|
|
165
534
|
const refererUrl = new URL(normalizedHttpRef);
|
|
535
|
+
sourceRef.refererHost = sourceRef.refererHost ?? normalizeHost(refererUrl.host) ?? undefined;
|
|
536
|
+
sourceRef.refererPath = sourceRef.refererPath ?? normalizeSourceRef(refererUrl.pathname) ?? undefined;
|
|
537
|
+
sourceRef.refererDomain = sourceRef.refererDomain ?? getRootDomain(refererUrl.host) ?? undefined;
|
|
166
538
|
applySearchParams(sourceRef, refererUrl.searchParams);
|
|
167
539
|
} catch (error) {
|
|
168
540
|
console.warn('Failed to parse referer url for utm/ref:', error);
|
|
169
541
|
}
|
|
170
542
|
}
|
|
171
543
|
|
|
544
|
+
finalizeAttribution(sourceRef);
|
|
545
|
+
|
|
172
546
|
return Object.keys(sourceRef).length > 0 ? sourceRef : null;
|
|
173
547
|
}
|
|
174
548
|
|
package/src/lib/stripe-config.ts
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
import Stripe from 'stripe';
|
|
2
2
|
import { Apilogger, userService, subscriptionService } from '../services/database/index';
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
let stripeInstance: Stripe | null = null;
|
|
5
|
+
|
|
6
|
+
export const getStripe = (): Stripe => {
|
|
7
|
+
const apiKey = process.env.STRIPE_SECRET_KEY;
|
|
8
|
+
if (!apiKey) {
|
|
9
|
+
throw new Error('STRIPE_SECRET_KEY is not configured');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (!stripeInstance) {
|
|
13
|
+
stripeInstance = new Stripe(apiKey, {
|
|
14
|
+
apiVersion: '2025-11-17.clover',
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return stripeInstance;
|
|
19
|
+
};
|
|
8
20
|
|
|
9
21
|
// Helper function to validate webhook signature
|
|
10
22
|
export const validateStripeWebhook = (
|
|
@@ -12,7 +24,7 @@ export const validateStripeWebhook = (
|
|
|
12
24
|
signature: string,
|
|
13
25
|
secret: string
|
|
14
26
|
): Stripe.Event => {
|
|
15
|
-
return
|
|
27
|
+
return getStripe().webhooks.constructEvent(payload, signature, secret);
|
|
16
28
|
};
|
|
17
29
|
|
|
18
30
|
export interface BasicCheckoutSessionParams {
|
|
@@ -95,7 +107,7 @@ export const createCheckoutSession = async (
|
|
|
95
107
|
const logId = await Apilogger.logStripeOutgoing('createCheckoutSession', params);
|
|
96
108
|
|
|
97
109
|
try {
|
|
98
|
-
const session = await
|
|
110
|
+
const session = await getStripe().checkout.sessions.create(sessionParams);
|
|
99
111
|
|
|
100
112
|
// Update log record with response
|
|
101
113
|
Apilogger.updateResponse(logId, {
|
|
@@ -116,7 +128,7 @@ export const createCheckoutSession = async (
|
|
|
116
128
|
|
|
117
129
|
// 根据发票ID去查支付ID
|
|
118
130
|
export const fetchPaymentId = async (invoiceId: string ): Promise<string> => {
|
|
119
|
-
const fullInvoice = await
|
|
131
|
+
const fullInvoice = await getStripe().invoices.retrieve(invoiceId, {
|
|
120
132
|
expand: ['payments']
|
|
121
133
|
});
|
|
122
134
|
const payment = fullInvoice.payments?.data[0];
|
|
@@ -147,7 +159,7 @@ export const createOrGetCustomer = async (params: {
|
|
|
147
159
|
|
|
148
160
|
if (user.stripeCusId) {
|
|
149
161
|
try {
|
|
150
|
-
const customer = await
|
|
162
|
+
const customer = await getStripe().customers.retrieve(user.stripeCusId);
|
|
151
163
|
if ('deleted' in customer) {
|
|
152
164
|
await setStripeCustomerId(null);
|
|
153
165
|
} else {
|
|
@@ -164,7 +176,7 @@ export const createOrGetCustomer = async (params: {
|
|
|
164
176
|
}
|
|
165
177
|
|
|
166
178
|
if (user.email) {
|
|
167
|
-
const existingCustomers = await
|
|
179
|
+
const existingCustomers = await getStripe().customers.list({
|
|
168
180
|
email: user.email,
|
|
169
181
|
limit: 1,
|
|
170
182
|
});
|
|
@@ -201,7 +213,7 @@ export const createOrGetCustomer = async (params: {
|
|
|
201
213
|
});
|
|
202
214
|
|
|
203
215
|
try {
|
|
204
|
-
const customer = await
|
|
216
|
+
const customer = await getStripe().customers.create(customerParams);
|
|
205
217
|
await setStripeCustomerId(customer.id);
|
|
206
218
|
|
|
207
219
|
// Update log record with response
|
|
@@ -228,13 +240,13 @@ export const updateSubscription = async (params: {
|
|
|
228
240
|
}): Promise<Stripe.Subscription> => {
|
|
229
241
|
const { subscriptionId, priceId, prorationBehavior = 'create_prorations' } = params;
|
|
230
242
|
|
|
231
|
-
const subscription = await
|
|
243
|
+
const subscription = await getStripe().subscriptions.retrieve(subscriptionId);
|
|
232
244
|
|
|
233
245
|
// Create log record with request
|
|
234
246
|
const logId = await Apilogger.logStripeOutgoing('updateSubscription', params);
|
|
235
247
|
|
|
236
248
|
try {
|
|
237
|
-
const updatedSubscription = await
|
|
249
|
+
const updatedSubscription = await getStripe().subscriptions.update(subscriptionId, {
|
|
238
250
|
items: [
|
|
239
251
|
{
|
|
240
252
|
id: subscription.items.data[0].id,
|
|
@@ -267,7 +279,7 @@ export const createCustomerPortalSession = async (params: {
|
|
|
267
279
|
const logId = await Apilogger.logStripeOutgoing('createCustomerPortalSession', params);
|
|
268
280
|
|
|
269
281
|
try {
|
|
270
|
-
const session = await
|
|
282
|
+
const session = await getStripe().billingPortal.sessions.create({
|
|
271
283
|
customer: params.customerId,
|
|
272
284
|
return_url: params.returnUrl,
|
|
273
285
|
});
|
|
@@ -301,11 +313,11 @@ export const cancelSubscription = async (
|
|
|
301
313
|
let result: Stripe.Subscription;
|
|
302
314
|
|
|
303
315
|
if (cancelAtPeriodEnd) {
|
|
304
|
-
result = await
|
|
316
|
+
result = await getStripe().subscriptions.update(subscriptionId, {
|
|
305
317
|
cancel_at_period_end: true,
|
|
306
318
|
});
|
|
307
319
|
} else {
|
|
308
|
-
result = await
|
|
320
|
+
result = await getStripe().subscriptions.cancel(subscriptionId);
|
|
309
321
|
}
|
|
310
322
|
|
|
311
323
|
// Update log record with response
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
import { Transaction } from '@/db/prisma-model-type';
|
|
14
14
|
import { oneTimeExpiredDays } from '@/lib/credit-init';
|
|
15
15
|
import { getCreditsFromPriceId } from '@/lib/money-price-config';
|
|
16
|
-
import { fetchPaymentId,
|
|
16
|
+
import { fetchPaymentId, getStripe } from '@/lib/stripe-config';
|
|
17
17
|
import Stripe from 'stripe';
|
|
18
18
|
import { viewLocalTime } from '@windrun-huaiin/lib/utils';
|
|
19
19
|
|
|
@@ -150,6 +150,7 @@ async function handleSubscriptionCheckoutInit(
|
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
const subscriptionId = session.subscription as string;
|
|
153
|
+
const stripe = getStripe();
|
|
153
154
|
|
|
154
155
|
// ===== STEP 1: FETCH EXTERNAL API DATA (BEFORE TRANSACTION) =====
|
|
155
156
|
// 2. Get COMPLETE Stripe subscription details including billing period
|
|
@@ -416,6 +417,7 @@ async function handleSubscriptionDeleted(stripeSubscription: Stripe.Subscription
|
|
|
416
417
|
|
|
417
418
|
async function handleAsyncPaymentSucceeded(session: Stripe.Checkout.Session) {
|
|
418
419
|
console.log(`Async payment succeeded: ${session.id}`);
|
|
420
|
+
const stripe = getStripe();
|
|
419
421
|
|
|
420
422
|
// Retrieve the latest session state to ensure payment_status is up to date
|
|
421
423
|
const latestSession = await stripe.checkout.sessions.retrieve(session.id);
|