Compare commits

..

130 Commits

Author SHA1 Message Date
javayhu
2a6e322c0a Merge remote-tracking branch 'origin/main' into cloudflare 2025-09-05 22:23:04 +08:00
javayhu
e3ac4a0a29 Merge remote-tracking branch 'origin/main' into cloudflare 2025-09-03 01:19:48 +08:00
javayhu
b0a065ced9 Merge remote-tracking branch 'origin/main' into cloudflare 2025-09-03 00:08:21 +08:00
javayhu
ad1cbedb56 Merge remote-tracking branch 'origin/main' into cloudflare 2025-09-02 00:18:06 +08:00
javayhu
e3f44a85a5 Merge remote-tracking branch 'origin/main' into cloudflare 2025-09-01 00:14:08 +08:00
javayhu
2faedc2043 Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-30 22:30:44 +08:00
javayhu
0c415ee24b Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-28 10:09:34 +08:00
javayhu
658409cfbd Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-27 00:53:02 +08:00
javayhu
613bbd0d78 Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-26 00:50:03 +08:00
javayhu
5f14259197 Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-24 22:34:35 +08:00
javayhu
b4dab95c04 Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-23 00:14:47 +08:00
javayhu
8221f1753f Merge branch 'cloudflare' of https://github.com/MkSaaSHQ/mksaas-template into cloudflare 2025-08-22 01:17:47 +08:00
javayhu
18691030e7 Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-22 01:17:38 +08:00
javayhu
7aa7cb5603 Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-21 10:04:48 +08:00
javayhu
ca30f95027 Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-21 09:59:28 +08:00
javayhu
d747683f82 Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-20 00:20:22 +08:00
javayhu
b55613b471 Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-18 00:59:21 +08:00
javayhu
47679ab91e Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-18 00:32:31 +08:00
javayhu
f468638f49 Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-17 23:27:24 +08:00
javayhu
35ddf5e08e Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-17 08:45:27 +08:00
javayhu
1f7c38f9f5 Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-16 08:19:56 +08:00
javayhu
63dd4e52fb Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-15 23:03:55 +08:00
javayhu
200a9963f7 Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-15 00:41:00 +08:00
javayhu
0da8f7d335 Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-15 00:10:26 +08:00
javayhu
004edeecea Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-14 23:14:32 +08:00
javayhu
6bb12a2d86 Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-11 07:41:56 +08:00
javayhu
97654d97ea Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-11 07:36:28 +08:00
javayhu
aa2e025270 cf: update cloudflare env types 2025-08-09 13:12:42 +08:00
javayhu
11bfcb731d cf: upgrade version of opennextjs and wrangler 2025-08-09 10:13:32 +08:00
javayhu
62eb4124be Merge remote-tracking branch 'origin/main' into cloudflare 2025-08-03 15:20:14 +08:00
javayhu
d7cc9b956d Merge remote-tracking branch 'origin/main' into cloudflare 2025-07-24 01:17:23 +08:00
javayhu
22d68c005a refactor: optimize credits rendering by memoizing and moving checks before hooks 2025-07-24 00:50:23 +08:00
javayhu
70446d10b3 Merge remote-tracking branch 'origin/main' into cloudflare 2025-07-24 00:10:11 +08:00
javayhu
313c783dbd Merge remote-tracking branch 'origin/main' into cloudflare 2025-07-20 14:44:58 +08:00
javayhu
cc56f9d729 Merge remote-tracking branch 'origin/main' into cloudflare 2025-07-19 15:15:42 +08:00
javayhu
e5569dabd1 Merge remote-tracking branch 'origin/main' into cloudflare 2025-07-13 23:04:14 +08:00
javayhu
813d8ea0bb Merge remote-tracking branch 'origin/main' into cloudflare 2025-07-13 23:02:55 +08:00
javayhu
c67b804f4f Merge branch 'cloudflare' of https://github.com/MkSaaSHQ/mksaas-template into cloudflare 2025-07-10 00:48:21 +08:00
javayhu
a44e4a669c Merge remote-tracking branch 'origin/main' into cloudflare 2025-07-10 00:48:17 +08:00
javayhu
da4b018e8d Merge remote-tracking branch 'origin/main' into cloudflare 2025-07-09 19:14:30 +08:00
javayhu
b838ddc293 Merge remote-tracking branch 'origin/main' into cloudflare 2025-07-02 22:53:01 +08:00
javayhu
8e63af3e7f Merge remote-tracking branch 'origin/main' into cloudflare 2025-07-02 01:15:29 +08:00
javayhu
1e2e4d77f7 Merge remote-tracking branch 'origin/main' into cloudflare 2025-07-02 00:54:31 +08:00
javayhu
e94625ce4e custom: add command cf-dev on port 8787 2025-06-22 11:58:13 +08:00
javayhu
2153cf6771 chore: add dev.vars example file 2025-06-21 17:35:32 +08:00
javayhu
0164c833db Merge remote-tracking branch 'origin/main' into cloudflare 2025-06-21 14:40:23 +08:00
javayhu
5d50135ed6 Merge remote-tracking branch 'origin/main' into cloudflare 2025-06-20 22:21:04 +08:00
javayhu
cbfe5e433d Merge remote-tracking branch 'origin/main' into cloudflare 2025-06-20 02:04:54 +08:00
javayhu
7ab7d2d504 refactor(storage) replace with s3mini sdk & fix upload issue in cloudflare worker 2025-06-20 02:03:22 +08:00
javayhu
522d8de4ee chore: add comments for nodejs_compat 2025-06-20 01:36:35 +08:00
javayhu
0739c717d8 chore: fix lint and format issues 2025-06-20 01:18:48 +08:00
javayhu
71b9807433 Merge remote-tracking branch 'origin/main' into cloudflare 2025-06-19 00:40:41 +08:00
javayhu
8a72fb2409 chore: revert change website name 2025-06-18 00:18:58 +08:00
javayhu
e00c22d0fe Merge remote-tracking branch 'origin/main' into cloudflare 2025-06-18 00:17:09 +08:00
javayhu
bd8ccf4cf3 Merge remote-tracking branch 'origin/main' into cloudflare 2025-06-18 00:09:28 +08:00
javayhu
d0aef4b7d4 chore: remove useless packages 2025-06-17 23:26:33 +08:00
javayhu
c006ee750d refactor(blog) remove blog toc component 2025-06-17 23:26:02 +08:00
javayhu
19a6c4d994 cf: fix stripe api issue by setting httpClient 2025-06-17 22:35:27 +08:00
javayhu
86f13a1748 chore: reset compatibility_flags to nodejs_compat & nodejs_compat_v2 not solve file upload issue 2025-06-17 22:15:12 +08:00
javayhu
745ba457df cf: add init open next cf for dev 2025-06-17 22:14:01 +08:00
javayhu
beb53639a3 chore: adjust locale handling in Providers and DocsRootLayout, and simplify search API request handling 2025-06-17 21:44:48 +08:00
javayhu
65fb8722bc chore: update DynamicCodeBlock component props 2025-06-17 21:28:36 +08:00
javayhu
160a7eb929 chore: fix fumadocs top empty banner shown when in cloudflare worker env 2025-06-17 21:17:34 +08:00
javayhu
c3d82d9183 chore: upgrade fumadocs ui and core 2025-06-17 21:16:46 +08:00
javayhu
767351c5cd cf: add nodejs_compat_v2 to compatibility_flags 2025-06-17 20:43:54 +08:00
javayhu
fd3c82baaf cf: do not remove logs in prod env 2025-06-17 20:06:20 +08:00
javayhu
168eae946f cf: enable worker log push 2025-06-17 19:41:18 +08:00
javayhu
69390fed70 refactor(blog) update sitemap for blog pages 2025-06-17 18:17:05 +08:00
javayhu
2cb041beb1 refactor(blog) blog category pages 2025-06-17 18:02:29 +08:00
javayhu
3645cf5773 refactor(blog) optimize inline toc & blog page layout 2025-06-17 17:45:10 +08:00
javayhu
c6ad6d0ad5 refactor(blog) blog page ready with toc 2025-06-17 17:29:15 +08:00
javayhu
53ab869f07 refactor(blog) refactor blog home page 2025-06-17 11:28:20 +08:00
javayhu
e0f408fb07 refactor(blog) update date in mdx files 2025-06-17 09:52:13 +08:00
javayhu
1216732a55 refactor(blog) remove content-collections & add blog source 2025-06-17 09:51:57 +08:00
javayhu
4c6fddf99d refactor(blog) update content about blog posts 2025-06-17 00:08:07 +08:00
javayhu
90d5db88ab refactor(pages) migrate custom pages to using fumadocs 2025-06-16 23:34:22 +08:00
javayhu
af5a3265a6 refactor(changelog) refactor release card component 2025-06-16 22:55:24 +08:00
javayhu
ec8ce54824 refactor(changelog) migrate changelog to use fumadocs 2025-06-16 01:29:02 +08:00
javayhu
f4d8a09ab6 refactor(docs) parse and render docs mdx files by fumadocs 2025-06-16 00:51:36 +08:00
javayhu
3b741b3b98 refactor(docs) remove math and package-install as code block languages 2025-06-16 00:43:27 +08:00
javayhu
b07be5fab4 chore: add fumadocs-mdx 2025-06-15 20:50:03 +08:00
javayhu
a22a5def4d chore: update hyperdrive localConnectionString 2025-06-15 17:44:17 +08:00
javayhu
d190bcb358 chore: add pg to fix error [Better Auth]: INTERNAL_SERVER_ERROR Error: Cannot find module 'cloudflare:sockets' when run pnpm preview 2025-06-15 17:44:00 +08:00
javayhu
7f1fe23407 chore: remove unused types from tsconfig.json 2025-06-15 12:25:06 +08:00
javayhu
05a7de4599 fix: add type annotation for data in GitHubStarsButton component 2025-06-15 12:24:57 +08:00
javayhu
c098300481 chore: update next config & fix build error UnhandledSchemeError 2025-06-15 12:23:10 +08:00
javayhu
e7240db823 fix: fix build error: Module build failed: UnhandledSchemeError: Reading from "cloudflare:sockets" is not handled by plugins (Unhandled scheme).
https://github.com/vercel/next.js/discussions/50177
2025-06-15 12:00:38 +08:00
javayhu
a4390d433b chore: add types to compilerOptions in ts config 2025-06-15 11:44:37 +08:00
javayhu
ae49d06cf4 chore: update db instance & bind hyperdrive 2025-06-15 09:05:27 +08:00
javayhu
6a448825a6 Merge remote-tracking branch 'origin/main' into cloudflare 2025-06-15 08:12:38 +08:00
javayhu
4d60d48212 cf: reset gitignore file same as main branch 2025-06-09 01:25:33 +08:00
javayhu
26a88eb2f0 Merge remote-tracking branch 'origin/main' into cloudflare 2025-06-09 00:58:21 +08:00
javayhu
c5d08a9846 Revert "cf: test remove preview pages only"
This reverts commit f5b4ed2859.
2025-06-09 00:42:09 +08:00
javayhu
f5b4ed2859 cf: test remove preview pages only 2025-06-09 00:22:45 +08:00
javayhu
b88aa9c1f5 Revert "cf: test remove docs pages and content only"
This reverts commit 708fac652f.
2025-06-09 00:21:43 +08:00
javayhu
593333c3dd Revert "cf: test remove docs pages and docs+blog content"
This reverts commit c3392320b3.
2025-06-09 00:21:34 +08:00
javayhu
f3b6603db7 Revert "cf: test remove docs and blog pages and content"
This reverts commit 9cb559a48d.
2025-06-09 00:21:28 +08:00
javayhu
9cb559a48d cf: test remove docs and blog pages and content 2025-06-09 00:17:09 +08:00
javayhu
c3392320b3 cf: test remove docs pages and docs+blog content 2025-06-09 00:08:06 +08:00
javayhu
708fac652f cf: test remove docs pages and content only 2025-06-09 00:05:32 +08:00
javayhu
ec124640f1 Revert "cf: test delete blog and docs content only"
This reverts commit 862132d8eb.
2025-06-09 00:03:44 +08:00
javayhu
862132d8eb cf: test delete blog and docs content only 2025-06-08 23:58:11 +08:00
javayhu
bf11c143fe Revert "cf: test remove all blog and docs and preview pages"
This reverts commit 6cfc76d621.
2025-06-08 23:57:16 +08:00
javayhu
6cfc76d621 cf: test remove all blog and docs and preview pages 2025-06-08 22:07:33 +08:00
javayhu
d935bcff76 cf: set @opennextjs/cloudflare as devDependencies 2025-06-08 21:49:19 +08:00
javayhu
a727a31e2f cf: revert the og image 2025-06-08 21:41:01 +08:00
javayhu
81cfc5f6b3 cf: update website name in zh.json 2025-06-08 21:38:14 +08:00
javayhu
8e8291c325 cf: update configs by the opennextjs docs 2025-06-08 21:18:42 +08:00
javayhu
6ff2ea6845 cf: upgrade opennextjs to 1.2.1 2025-06-08 20:04:51 +08:00
javayhu
b6836db12d Merge remote-tracking branch 'origin/main' into cloudflare 2025-06-08 18:25:26 +08:00
javayhu
5f435b9614 chore: add mcp tool of context7 2025-05-18 17:36:30 +08:00
javayhu
9b03f6201f chore: show discord widget in homepage only 2025-05-18 16:55:37 +08:00
javayhu
111f00adaa chore: update @opennextjs/cloudflare version 2025-05-18 00:39:49 +08:00
javayhu
002d2090c2 Merge remote-tracking branch 'origin/main' into cloudflare 2025-05-17 23:40:51 +08:00
javayhu
c3913dbc88 chore: update open graph image 2025-05-13 01:43:25 +08:00
javayhu
9b68e3095e Merge remote-tracking branch 'origin/main' into cloudflare 2025-05-11 22:19:58 +08:00
javayhu
2fb627a6e9 chore: remove package-lock file 2025-05-08 00:07:35 +08:00
javayhu
f11e37374b Merge remote-tracking branch 'origin/main' into cloudflare 2025-05-08 00:03:57 +08:00
javayhu
3560616b52 Revert "chore: remove some doc files"
This reverts commit dd95dece87.
2025-05-08 00:03:34 +08:00
javayhu
80219fa10b Revert "fix: try fix build error, Error: EMFILE: too many open files"
This reverts commit a62abbf399.
2025-05-08 00:03:21 +08:00
javayhu
a62abbf399 fix: try fix build error, Error: EMFILE: too many open files
https://dash.cloudflare.com/b84ee5b2c0cdee9b0371c366945b0ab1/workers/services/view/mksaas-template/production/builds/7204761e-a2d1-490d-8d9d-b77a3985c1de
2025-05-07 00:06:49 +08:00
javayhu
dd95dece87 chore: remove some doc files 2025-05-06 23:52:32 +08:00
javayhu
c938122f7e chore: update package.json 2025-05-06 23:52:29 +08:00
javayhu
3887da26d0 chore: update wrangler config & add minify 2025-05-06 23:42:16 +08:00
javayhu
7af193f770 Merge remote-tracking branch 'origin/main' into cloudflare 2025-05-06 23:32:54 +08:00
javayhu
d6093394d8 chore: update cf scripts 2025-05-06 00:51:39 +08:00
javayhu
f1537e305a fix: build error & try add NEXT_PRIVATE_MAX_WORKER_THREADS=2 2025-05-06 00:42:31 +08:00
javayhu
1847ef4363 custom: update metadata name 2025-05-06 00:03:19 +08:00
javayhu
0fd695c8bc fix: build 2025-05-05 23:25:00 +08:00
javayhu
ae083a7992 feat: support cloudflare by diverce 2025-05-05 23:15:54 +08:00
18 changed files with 12133 additions and 1568 deletions

7483
cloudflare-env.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@ -35,14 +35,6 @@ After that, you can return to the blog post and you can read the rest of the blo
For more details, please check out the documentation: [Blog](https://mksaas.com/docs/blog).
Test show Tweet in the blog post.
<XEmbedClient url="https://x.com/mksaascom/status/1960417768505008291" width={500} />
Test show YouTube video in the blog post.
<YoutubeVideo url="https://www.youtube.com/embed/xvoeSnlFZJk" width={500} />
Now the rest of the blog post is premium content.
<PremiumContent>

View File

@ -35,14 +35,6 @@ CVV: 567
更多详情,请参考文档:[博客](https://mksaas.com/docs/blog)。
测试展示 X 帖子。
<XEmbedClient url="https://x.com/mksaascom/status/1960417768505008291" width={500} />
测试展示 YouTube 视频。
<YoutubeVideo url="https://www.youtube.com/embed/xvoeSnlFZJk" width={500} />
现在剩下的内容是付费内容。
<PremiumContent>

1
dev.vars.example Normal file
View File

@ -0,0 +1 @@
NEXTJS_ENV=development

View File

@ -18,6 +18,18 @@ const nextConfig: NextConfig = {
// removeConsole: process.env.NODE_ENV === 'production',
},
// https://github.com/vercel/next.js/discussions/50177#discussioncomment-6006702
// fix build error: Module build failed: UnhandledSchemeError:
// Reading from "cloudflare:sockets" is not handled by plugins (Unhandled scheme).
webpack: (config, { webpack }) => {
config.plugins.push(
new webpack.IgnorePlugin({
resourceRegExp: /^pg-native$|^cloudflare:sockets$/,
})
);
return config;
},
images: {
// https://vercel.com/docs/image-optimization/managing-image-optimization-costs#minimizing-image-optimization-costs
// https://nextjs.org/docs/app/api-reference/components/image#unoptimized
@ -70,3 +82,9 @@ const withNextIntl = createNextIntlPlugin();
const withMDX = createMDX();
export default withMDX(withNextIntl(nextConfig));
// https://opennext.js.org/cloudflare/get-started#12-develop-locally
import { initOpenNextCloudflareForDev } from '@opennextjs/cloudflare';
// during local development, to access in any of your server code, local versions of Cloudflare bindings
initOpenNextCloudflareForDev();

6
open-next.config.ts Normal file
View File

@ -0,0 +1,6 @@
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
export default defineCloudflareConfig({
});

View File

@ -4,6 +4,7 @@
"private": true,
"scripts": {
"dev": "next dev",
"cf-dev": "next dev -p 8787",
"build": "next build",
"start": "next start",
"postinstall": "fumadocs-mdx",
@ -111,6 +112,7 @@
"next-intl": "^4.0.0",
"next-safe-action": "^7.10.4",
"next-themes": "^0.4.4",
"pg": "^8.16.0",
"nuqs": "^2.5.1",
"postgres": "^3.4.5",
"radix-ui": "^1.4.2",
@ -120,7 +122,6 @@
"react-hook-form": "^7.62.0",
"react-remove-scroll": "^2.6.3",
"react-resizable-panels": "^2.1.7",
"react-social-media-embed": "^2.5.18",
"react-syntax-highlighter": "^15.6.3",
"react-tweet": "^3.2.2",
"react-use-measure": "^2.1.7",
@ -144,6 +145,7 @@
},
"devDependencies": {
"@biomejs/biome": "1.9.4",
"@opennextjs/cloudflare": "^1.6.5",
"@tailwindcss/postcss": "^4.0.14",
"@tanstack/eslint-plugin-query": "^5.83.1",
"@types/mdx": "^2.0.13",
@ -158,6 +160,7 @@
"react-email": "3.0.7",
"tailwindcss": "^4.0.14",
"tsx": "^4.19.3",
"typescript": "^5.8.3"
"typescript": "^5.8.3",
"wrangler": "^4.28.1"
}
}

4394
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

2
public/_headers Normal file
View File

@ -0,0 +1,2 @@
/_next/static/*
Cache-Control: public,max-age=31536000,immutable

View File

@ -12,7 +12,6 @@ import defaultMdxComponents from 'fumadocs-ui/mdx';
import * as LucideIcons from 'lucide-react';
import type { MDXComponents } from 'mdx/types';
import type { ComponentProps, FC } from 'react';
import { XEmbedClient } from './xembed';
/**
* Enhanced MDX Content component that includes commonly used MDX components
@ -24,7 +23,6 @@ export function getMDXComponents(components?: MDXComponents): MDXComponents {
...defaultMdxComponents,
...LucideIcons,
// ...((await import('lucide-react')) as unknown as MDXComponents),
XEmbedClient,
YoutubeVideo,
PremiumContent,
Tabs,

View File

@ -1,16 +0,0 @@
'use client';
import { XEmbed, type XEmbedProps } from 'react-social-media-embed';
/**
* Embedding X Posts in Fumadocs
*
* https://rjv.im/blog/solution/embed-x-post-in-fuma-docs
*/
export function XEmbedClient({ ...props }: XEmbedProps) {
return (
<div className="flex justify-center">
<XEmbed {...props} />
</div>
);
}

View File

@ -2,17 +2,23 @@
* Connect to PostgreSQL Database (Supabase/Neon/Local PostgreSQL)
* https://orm.drizzle.team/docs/tutorials/drizzle-with-supabase
*/
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { getCloudflareContext } from '@opennextjs/cloudflare';
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from './schema';
let db: ReturnType<typeof drizzle> | null = null;
// https://opennext.js.org/cloudflare/howtos/db#postgresql
export async function getDb() {
if (db) return db;
const connectionString = process.env.DATABASE_URL!;
const client = postgres(connectionString, { prepare: false });
db = drizzle(client, { schema });
const { env } = await getCloudflareContext({ async: true });
const pool = new Pool({
connectionString: env.HYPERDRIVE.connectionString,
// You don't want to reuse the same connection for multiple requests
maxUses: 1,
});
db = drizzle({ client: pool, schema });
return db;
}

View File

@ -1,3 +0,0 @@
ALTER TABLE "payment" ADD COLUMN "invoice_id" text;--> statement-breakpoint
CREATE INDEX "payment_invoice_id_idx" ON "payment" USING btree ("invoice_id");--> statement-breakpoint
ALTER TABLE "payment" ADD CONSTRAINT "payment_invoice_id_unique" UNIQUE("invoice_id");

View File

@ -1,946 +0,0 @@
{
"id": "adff59d1-8ceb-4472-ae47-148a950e700a",
"prevId": "318baf42-b0f6-4288-807b-778767149685",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"account_user_id_idx": {
"name": "account_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"account_account_id_idx": {
"name": "account_account_id_idx",
"columns": [
{
"expression": "account_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"account_provider_id_idx": {
"name": "account_provider_id_idx",
"columns": [
{
"expression": "provider_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.credit_transaction": {
"name": "credit_transaction",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"amount": {
"name": "amount",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"remaining_amount": {
"name": "remaining_amount",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"payment_id": {
"name": "payment_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"expiration_date": {
"name": "expiration_date",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"expiration_date_processed_at": {
"name": "expiration_date_processed_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"credit_transaction_user_id_idx": {
"name": "credit_transaction_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"credit_transaction_type_idx": {
"name": "credit_transaction_type_idx",
"columns": [
{
"expression": "type",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"credit_transaction_user_id_user_id_fk": {
"name": "credit_transaction_user_id_user_id_fk",
"tableFrom": "credit_transaction",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.payment": {
"name": "payment",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"price_id": {
"name": "price_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"interval": {
"name": "interval",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_id": {
"name": "customer_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"subscription_id": {
"name": "subscription_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"session_id": {
"name": "session_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"invoice_id": {
"name": "invoice_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true
},
"period_start": {
"name": "period_start",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"period_end": {
"name": "period_end",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"cancel_at_period_end": {
"name": "cancel_at_period_end",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"trial_start": {
"name": "trial_start",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"trial_end": {
"name": "trial_end",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"payment_type_idx": {
"name": "payment_type_idx",
"columns": [
{
"expression": "type",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"payment_price_id_idx": {
"name": "payment_price_id_idx",
"columns": [
{
"expression": "price_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"payment_user_id_idx": {
"name": "payment_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"payment_customer_id_idx": {
"name": "payment_customer_id_idx",
"columns": [
{
"expression": "customer_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"payment_status_idx": {
"name": "payment_status_idx",
"columns": [
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"payment_subscription_id_idx": {
"name": "payment_subscription_id_idx",
"columns": [
{
"expression": "subscription_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"payment_session_id_idx": {
"name": "payment_session_id_idx",
"columns": [
{
"expression": "session_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"payment_invoice_id_idx": {
"name": "payment_invoice_id_idx",
"columns": [
{
"expression": "invoice_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"payment_user_id_user_id_fk": {
"name": "payment_user_id_user_id_fk",
"tableFrom": "payment",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"payment_invoice_id_unique": {
"name": "payment_invoice_id_unique",
"nullsNotDistinct": false,
"columns": [
"invoice_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"impersonated_by": {
"name": "impersonated_by",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"session_token_idx": {
"name": "session_token_idx",
"columns": [
{
"expression": "token",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"session_user_id_idx": {
"name": "session_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false
},
"banned": {
"name": "banned",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ban_expires": {
"name": "ban_expires",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"customer_id": {
"name": "customer_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"user_id_idx": {
"name": "user_id_idx",
"columns": [
{
"expression": "id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"user_customer_id_idx": {
"name": "user_customer_id_idx",
"columns": [
{
"expression": "customer_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"user_role_idx": {
"name": "user_role_idx",
"columns": [
{
"expression": "role",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_credit": {
"name": "user_credit",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"current_credits": {
"name": "current_credits",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"last_refresh_at": {
"name": "last_refresh_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"user_credit_user_id_idx": {
"name": "user_credit_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"user_credit_user_id_user_id_fk": {
"name": "user_credit_user_id_user_id_fk",
"tableFrom": "user_credit",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@ -29,13 +29,6 @@
"when": 1752992749001,
"tag": "0003_loving_risque",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1757258758531,
"tag": "0004_clever_molly_hayes",
"breakpoints": true
}
]
}

View File

@ -72,7 +72,6 @@ export const payment = pgTable("payment", {
customerId: text('customer_id').notNull(),
subscriptionId: text('subscription_id'),
sessionId: text('session_id'),
invoiceId: text('invoice_id').unique(), // unique constraint for avoiding duplicate processing
status: text('status').notNull(),
periodStart: timestamp('period_start'),
periodEnd: timestamp('period_end'),
@ -89,7 +88,6 @@ export const payment = pgTable("payment", {
paymentStatusIdx: index("payment_status_idx").on(table.status),
paymentSubscriptionIdIdx: index("payment_subscription_id_idx").on(table.subscriptionId),
paymentSessionIdIdx: index("payment_session_id_idx").on(table.sessionId),
paymentInvoiceIdIdx: index("payment_invoice_id_idx").on(table.invoiceId),
}));
export const userCredit = pgTable("user_credit", {
@ -110,7 +108,7 @@ export const creditTransaction = pgTable("credit_transaction", {
description: text("description"),
amount: integer("amount").notNull(),
remainingAmount: integer("remaining_amount"),
paymentId: text("payment_id"), // payment id is actually invoice id
paymentId: text("payment_id"),
expirationDate: timestamp("expiration_date"),
expirationDateProcessedAt: timestamp("expiration_date_processed_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),

View File

@ -9,7 +9,11 @@ import { getCreditPackageById } from '@/credits/server';
import { CREDIT_TRANSACTION_TYPE } from '@/credits/types';
import { getDb } from '@/db';
import { payment, user } from '@/db/schema';
import { findPlanByPlanId, findPriceInPlan } from '@/lib/price-plan';
import {
findPlanByPlanId,
findPlanByPriceId,
findPriceInPlan,
} from '@/lib/price-plan';
import { sendNotification } from '@/notification/notification';
import { desc, eq } from 'drizzle-orm';
import { Stripe } from 'stripe';
@ -53,7 +57,13 @@ export class StripeProvider implements PaymentProvider {
}
// Initialize Stripe without specifying apiVersion to use default/latest version
this.stripe = new Stripe(apiKey);
// https://opennext.js.org/cloudflare/howtos/stripeAPI
// When creating a Stripe object, the default http client implementation is based on
// node:https which is not implemented on Workers.
this.stripe = new Stripe(apiKey, {
// Cloudflare Workers use the Fetch API for their API requests.
httpClient: Stripe.createFetchHttpClient(),
});
this.webhookSecret = webhookSecret;
}
@ -488,15 +498,6 @@ export class StripeProvider implements PaymentProvider {
break;
}
}
} else if (eventType.startsWith('invoice.')) {
// Handle invoice events
switch (eventType) {
case 'invoice.payment_succeeded': {
const invoice = event.data.object as Stripe.Invoice;
await this.onInvoicePaymentSucceeded(invoice);
break;
}
}
} else if (eventType.startsWith('checkout.')) {
// Handle checkout events
if (eventType === 'checkout.session.completed') {
@ -519,426 +520,115 @@ export class StripeProvider implements PaymentProvider {
}
/**
* Handle successful invoice payment - NEW ARCHITECTURE
* Only create payment records here after payment is confirmed
*
* For one-time payments, the order of events may be:
* checkout.session.completed
* invoice.payment_succeeded
*
* For subscription payments, the order of events may be:
* checkout.session.completed
* customer.subscription.created
* customer.subscription.updated
* invoice.payment_succeeded
*
* @param invoice Stripe invoice
*/
private async onInvoicePaymentSucceeded(
invoice: Stripe.Invoice
): Promise<void> {
console.log('>> Handle invoice payment succeeded');
try {
const subscriptionId = invoice.subscription as string | null;
if (subscriptionId) {
// This is a subscription payment
await this.createSubscriptionPayment(invoice, subscriptionId);
} else {
// This is a one-time payment
await this.createOneTimePayment(invoice);
}
console.log('<< Successfully processed invoice payment');
} catch (error) {
console.error('<< Handle invoice payment succeeded error:', error);
// Check if it's a duplicate invoice error (database constraint violation)
if (
error instanceof Error &&
error.message.includes('unique constraint')
) {
console.log('<< Invoice already processed:', invoice.id);
return; // Don't throw, this is expected for duplicate processing
}
// For other errors, let Stripe retry
throw error;
}
}
/**
* Create subscription payment record and process benefits - NEW ARCHITECTURE
*
* The order of events may be:
* checkout.session.completed
* customer.subscription.created
* customer.subscription.updated
* invoice.payment_succeeded
*
* @param invoice Stripe invoice
* @param subscriptionId Subscription ID
*/
private async createSubscriptionPayment(
invoice: Stripe.Invoice,
subscriptionId: string
): Promise<void> {
console.log(
'>> Create subscription payment record for subscription:',
subscriptionId
);
try {
// Get subscription details from Stripe
const subscription =
await this.stripe.subscriptions.retrieve(subscriptionId);
const customerId = subscription.customer as string;
// Get priceId from subscription items
const priceId = subscription.items.data[0]?.price.id;
if (!priceId) {
console.warn('<< No priceId found for subscription');
return;
}
// Get userId from subscription metadata or fallback to customerId lookup
let userId: string | undefined = subscription.metadata.userId;
// If no userId in metadata (common in renewals), find by customerId
if (!userId) {
console.log('No userId in metadata, finding by customerId');
userId = await this.findUserIdByCustomerId(customerId);
if (!userId) {
console.error('<< No userId found, this should not happen');
return;
}
}
const periodStart = this.getPeriodStart(subscription);
const periodEnd = this.getPeriodEnd(subscription);
const trialStart = subscription.trial_start
? new Date(subscription.trial_start * 1000)
: null;
const trialEnd = subscription.trial_end
? new Date(subscription.trial_end * 1000)
: null;
const currentDate = new Date();
// Create payment record with subscription status
const db = await getDb();
const paymentResult = await db
.insert(payment)
.values({
id: randomUUID(),
priceId,
type: PaymentTypes.SUBSCRIPTION,
userId,
customerId,
subscriptionId,
invoiceId: invoice.id,
interval: this.mapStripeIntervalToPlanInterval(subscription),
status: this.mapSubscriptionStatusToPaymentStatus(
subscription.status
), // Use actual subscription status
periodStart,
periodEnd,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
trialStart,
trialEnd,
createdAt: currentDate,
updatedAt: currentDate,
})
.returning({ id: payment.id });
if (paymentResult.length === 0) {
console.warn('<< Failed to create subscription payment record');
return;
}
// Add subscription credits if enabled
if (websiteConfig.credits?.enableCredits) {
await addSubscriptionCredits(userId, priceId);
console.log('Added subscription credits for invoice:', invoice.id);
}
console.log('<< Successfully processed subscription payment');
} catch (error) {
console.error('<< Create subscription payment error:', error);
// Don't throw error if it's already processed
if (
error instanceof Error &&
error.message.includes('unique constraint')
) {
console.log('<< Subscription payment already processed:', invoice.id);
return;
}
throw error;
}
}
/**
* Create one-time payment record and process benefits - NEW ARCHITECTURE
*
* The order of events may be:
* checkout.session.completed
* invoice.payment_succeeded
*
* @param invoice Stripe invoice
*/
private async createOneTimePayment(invoice: Stripe.Invoice): Promise<void> {
console.log('>> Create one-time payment record for invoice:', invoice.id);
try {
const customerId = invoice.customer as string;
const paymentIntentId = invoice.payment_intent as string;
if (!paymentIntentId) {
console.warn('<< No payment_intent found in invoice:', invoice.id);
return;
}
// Get payment intent to access metadata
const paymentIntent =
await this.stripe.paymentIntents.retrieve(paymentIntentId);
const metadata = paymentIntent.metadata;
// Get userId from payment intent metadata or fallback to customerId lookup
let userId: string | undefined = metadata?.userId;
if (!userId) {
console.log('No userId in metadata, finding by customerId');
userId = await this.findUserIdByCustomerId(customerId);
if (!userId) {
console.error('<< No userId found, this should not happen');
return;
}
}
// Check if this is a credit purchase
const isCreditPurchase = metadata?.type === 'credit_purchase';
if (isCreditPurchase) {
// Process credit purchase
await this.createCreditPurchasePayment(invoice, metadata, userId);
} else {
// Process lifetime plan purchase
await this.createLifetimePlanPayment(
invoice,
metadata,
userId,
customerId
);
}
console.log('<< Successfully created one-time payment record');
} catch (error) {
console.error('<< Create one-time payment error:', error);
throw error;
}
}
/**
* Create payment record for credit purchase - NEW ARCHITECTURE
* @param invoice Stripe invoice
* @param metadata Payment intent metadata
* @param userId User ID
*/
private async createCreditPurchasePayment(
invoice: Stripe.Invoice,
metadata: { [key: string]: string },
userId: string
): Promise<void> {
console.log('>> Create credit purchase payment record');
try {
const packageId = metadata.packageId;
const credits = metadata.credits;
const customerId = invoice.customer as string;
if (!packageId || !credits) {
console.warn('<< Missing packageId or credits in metadata');
return;
}
// Get credit package
const creditPackage = getCreditPackageById(packageId);
if (!creditPackage) {
console.warn('<< Credit package not found:', packageId);
return;
}
// Create payment record
const db = await getDb();
const currentDate = new Date();
const paymentResult = await db
.insert(payment)
.values({
id: randomUUID(),
priceId: metadata.priceId || '',
type: PaymentTypes.ONE_TIME,
userId,
customerId,
invoiceId: invoice.id,
status: 'completed',
periodStart: currentDate,
createdAt: currentDate,
updatedAt: currentDate,
})
.returning({ id: payment.id });
if (paymentResult.length === 0) {
console.warn('<< Failed to create credit purchase payment record');
return;
}
// Add credits to user account
const amount = invoice.amount_paid ? invoice.amount_paid / 100 : 0;
await addCredits({
userId,
amount: Number.parseInt(credits),
type: CREDIT_TRANSACTION_TYPE.PURCHASE_PACKAGE,
description: `+${credits} credits for package ${packageId} ($${amount.toLocaleString()})`,
paymentId: invoice.id, // Use invoice ID as payment ID
expireDays: creditPackage.expireDays,
});
console.log('<< Successfully added credits to user for credit purchase');
} catch (error) {
console.error('<< Create credit purchase payment error:', error);
// Don't throw error if it's already processed
if (
error instanceof Error &&
error.message.includes('unique constraint')
) {
console.log('<< Credit purchase already processed:', invoice.id);
return;
}
throw error;
}
}
/**
* Create payment record for lifetime plan purchase - NEW ARCHITECTURE
* @param invoice Stripe invoice
* @param metadata Payment intent metadata
* @param userId User ID
* @param customerId Customer ID
*/
private async createLifetimePlanPayment(
invoice: Stripe.Invoice,
metadata: { [key: string]: string },
userId: string,
customerId: string
): Promise<void> {
console.log('>> Create lifetime plan payment record');
try {
const priceId = metadata?.priceId;
if (!priceId) {
console.warn('<< No priceId found in payment intent metadata');
return;
}
// Create payment record
const db = await getDb();
const currentDate = new Date();
const paymentResult = await db
.insert(payment)
.values({
id: randomUUID(),
priceId,
type: PaymentTypes.ONE_TIME,
userId,
customerId,
invoiceId: invoice.id,
status: 'completed',
periodStart: currentDate,
createdAt: currentDate,
updatedAt: currentDate,
})
.returning({ id: payment.id });
if (paymentResult.length === 0) {
console.warn('<< Failed to create lifetime plan payment record');
return;
}
// Add lifetime credits if enabled
if (websiteConfig.credits?.enableCredits) {
await addLifetimeMonthlyCredits(userId, priceId);
console.log('Added lifetime credits for invoice:', invoice.id);
}
// Send notification
const amount = invoice.amount_paid ? invoice.amount_paid / 100 : 0;
await sendNotification(invoice.id, customerId, userId, amount);
console.log('<< Successfully created lifetime plan payment record');
} catch (error) {
console.error('<< Create lifetime plan payment error:', error);
// Don't throw error if it's already processed
if (
error instanceof Error &&
error.message.includes('unique constraint')
) {
console.log('<< Lifetime plan payment already processed:', invoice.id);
return;
}
throw error;
}
}
/**
* Handle subscription creation - NEW ARCHITECTURE
* Only log the event, payment records created in invoice.payment_succeeded
* Create payment record
* @param stripeSubscription Stripe subscription
*/
private async onCreateSubscription(
stripeSubscription: Stripe.Subscription
): Promise<void> {
console.log('Handle subscription creation:', stripeSubscription.id);
console.log('>> Create payment record for Stripe subscription');
const customerId = stripeSubscription.customer as string;
// get priceId from subscription items (this is always available)
const priceId = stripeSubscription.items.data[0]?.price.id;
if (!priceId) {
console.warn('No priceId found for subscription');
return;
}
// get userId from metadata, we add it in the createCheckout session
const userId = stripeSubscription.metadata.userId;
if (!userId) {
console.warn('No userId found for subscription');
return;
}
const periodStart = this.getPeriodStart(stripeSubscription);
const periodEnd = this.getPeriodEnd(stripeSubscription);
// create fields
const createFields: any = {
id: randomUUID(),
priceId: priceId,
type: PaymentTypes.SUBSCRIPTION,
userId: userId,
customerId: customerId,
subscriptionId: stripeSubscription.id,
interval: this.mapStripeIntervalToPlanInterval(stripeSubscription),
status: this.mapSubscriptionStatusToPaymentStatus(
stripeSubscription.status
),
periodStart: periodStart,
periodEnd: periodEnd,
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end,
trialStart: stripeSubscription.trial_start
? new Date(stripeSubscription.trial_start * 1000)
: null,
trialEnd: stripeSubscription.trial_end
? new Date(stripeSubscription.trial_end * 1000)
: null,
createdAt: new Date(),
updatedAt: new Date(),
};
const db = await getDb();
const result = await db
.insert(payment)
.values(createFields)
.returning({ id: payment.id });
if (result.length > 0) {
console.log('<< Created new payment record for Stripe subscription');
} else {
console.warn('<< No payment record created for Stripe subscription');
}
// Conditionally handle credits after subscription creation if enables credits
if (websiteConfig.credits?.enableCredits) {
await addSubscriptionCredits(userId, priceId);
console.log('<< Added subscription monthly credits for user');
}
}
/**
* Update payment record
*
* When subscription is renewed, the order of events may be:
* customer.subscription.updated
* invoice.payment_succeeded
*
* In this case, we need to update the payment record.
*
* @param stripeSubscription Stripe subscription
*/
private async onUpdateSubscription(
stripeSubscription: Stripe.Subscription
): Promise<void> {
console.log('>> Handle subscription update');
console.log('>> Update payment record for Stripe subscription');
// get priceId from subscription items (this is always available)
const priceId = stripeSubscription.items.data[0]?.price.id;
if (!priceId) {
console.warn('<< No priceId found for subscription');
console.warn('No priceId found for subscription');
return;
}
// Get current payment record to check for period changes (indicating renewal)
const db = await getDb();
const payments = await db
.select({
userId: payment.userId,
periodStart: payment.periodStart,
periodEnd: payment.periodEnd,
})
.from(payment)
.where(eq(payment.subscriptionId, stripeSubscription.id))
.limit(1);
// get new period start and end
const newPeriodStart = this.getPeriodStart(stripeSubscription);
const newPeriodEnd = this.getPeriodEnd(stripeSubscription);
const trialStart = stripeSubscription.trial_start
? new Date(stripeSubscription.trial_start * 1000)
: undefined;
const trialEnd = stripeSubscription.trial_end
? new Date(stripeSubscription.trial_end * 1000)
: undefined;
// Check if this is a renewal (period has changed and subscription is active)
const isRenewal =
payments.length > 0 &&
stripeSubscription.status === 'active' &&
payments[0].periodStart &&
newPeriodStart &&
payments[0].periodStart.getTime() !== newPeriodStart.getTime();
// update fields
const updateFields: any = {
@ -950,12 +640,15 @@ export class StripeProvider implements PaymentProvider {
periodStart: newPeriodStart,
periodEnd: newPeriodEnd,
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end,
trialStart: trialStart,
trialEnd: trialEnd,
trialStart: stripeSubscription.trial_start
? new Date(stripeSubscription.trial_start * 1000)
: undefined,
trialEnd: stripeSubscription.trial_end
? new Date(stripeSubscription.trial_end * 1000)
: undefined,
updatedAt: new Date(),
};
const db = await getDb();
const result = await db
.update(payment)
.set(updateFields)
@ -963,9 +656,24 @@ export class StripeProvider implements PaymentProvider {
.returning({ id: payment.id });
if (result.length > 0) {
console.log('<< Updated payment record for subscription');
console.log('<< Updated payment record for Stripe subscription');
// Add credits for subscription renewal
const currentPayment = payments[0];
const userId = currentPayment.userId;
// Add subscription renewal credits if plan config enables credits
if (isRenewal && userId && websiteConfig.credits?.enableCredits) {
// Note: For yearly subscriptions, this webhook only triggers once per year
// Monthly credits for yearly subscribers are handled by the distributeCreditsToAllUsers cron job
await addSubscriptionCredits(userId, priceId);
console.log('<< Added subscription renewal credits for user');
} else {
console.log(
'<< No renewal credits added for user, isRenewal: ' + isRenewal
);
}
} else {
console.warn('<< No payment record found for subscription update');
console.warn('<< No payment record found for Stripe subscription');
}
}
@ -976,8 +684,7 @@ export class StripeProvider implements PaymentProvider {
private async onDeleteSubscription(
stripeSubscription: Stripe.Subscription
): Promise<void> {
console.log('>> Handle subscription deletion');
console.log('>> Mark payment record for Stripe subscription as canceled');
const db = await getDb();
const result = await db
.update(payment)
@ -993,30 +700,177 @@ export class StripeProvider implements PaymentProvider {
if (result.length > 0) {
console.log('<< Marked payment record for subscription as canceled');
} else {
console.warn('<< No payment record found for subscription deletion');
console.warn(
'<< No payment record found to cancel for Stripe subscription'
);
}
}
/**
* Handle checkout session completion - NEW ARCHITECTURE
* Only log the event, payment records created in invoice.payment_succeeded
* Handle one-time payment
* @param session Stripe checkout session
*/
private async onOnetimePayment(
session: Stripe.Checkout.Session
): Promise<void> {
console.log('Handle checkout session completion:', session.id);
const customerId = session.customer as string;
console.log('>> Handle onetime payment for customer');
// get userId from session metadata, we add it in the createCheckout session
const userId = session.metadata?.userId;
if (!userId) {
console.warn('No userId found for checkout session');
return;
}
// get priceId from session metadata, not from line items
// const priceId = session.line_items?.data[0]?.price?.id;
const priceId = session.metadata?.priceId;
if (!priceId) {
console.warn('No priceId found for checkout session');
return;
}
try {
const db = await getDb();
// Check if this session has already been processed to prevent duplicate processing
const existingPayment = await db
.select({ id: payment.id })
.from(payment)
.where(eq(payment.sessionId, session.id))
.limit(1);
if (existingPayment.length > 0) {
console.log(
'One-time payment session already processed: ' + session.id
);
return;
}
// Create a one-time payment record
const now = new Date();
const result = await db
.insert(payment)
.values({
id: randomUUID(),
priceId: priceId,
type: PaymentTypes.ONE_TIME,
userId: userId,
customerId: customerId,
sessionId: session.id, // Track the session ID
status: 'completed', // One-time payments are always completed
periodStart: now,
createdAt: now,
updatedAt: now,
})
.returning({ id: payment.id });
if (result.length === 0) {
console.warn('<< Failed to create one-time payment record for user');
return;
}
console.log('Created one-time payment record for user');
// Conditionally handle credits after one-time payment
if (websiteConfig.credits?.enableCredits) {
// For now, one time payment is only for lifetime plan
await addLifetimeMonthlyCredits(userId, priceId);
console.log('<< Added lifetime monthly credits for user');
}
// Send notification
const amount = session.amount_total ? session.amount_total / 100 : 0;
await sendNotification(session.id, customerId, userId, amount);
} catch (error) {
console.error('onOnetimePayment error for session: ' + session.id, error);
throw error;
}
}
/**
* Handle credit purchase checkout completion - NEW ARCHITECTURE
* Only log the event, payment records created in invoice.payment_succeeded
* Handle credit purchase
* @param session Stripe checkout session
*/
private async onCreditPurchase(
session: Stripe.Checkout.Session
): Promise<void> {
console.log('Handle credit purchase checkout completion:', session.id);
const customerId = session.customer as string;
console.log('>> Handle credit purchase for customer');
// get userId from session metadata, we add it in the createCheckout session
const userId = session.metadata?.userId;
if (!userId) {
console.warn('No userId found for checkout session');
return;
}
// get packageId from session metadata
const packageId = session.metadata?.packageId;
if (!packageId) {
console.warn('No packageId found for checkout session');
return;
}
// get credits from session metadata
const credits = session.metadata?.credits;
if (!credits) {
console.warn('No credits found for checkout session');
return;
}
// get credit package
const creditPackage = getCreditPackageById(packageId);
if (!creditPackage) {
console.warn('Credit package ' + packageId + ' not found');
return;
}
try {
// Check if this session has already been processed to prevent duplicate credit additions
const db = await getDb();
const existingPayment = await db
.select({ id: payment.id })
.from(payment)
.where(eq(payment.sessionId, session.id))
.limit(1);
if (existingPayment.length > 0) {
console.log('Credit purchase session already processed: ' + session.id);
return;
}
// Create payment record first to mark this session as processed
const now = new Date();
await db.insert(payment).values({
id: randomUUID(),
priceId: session.metadata?.priceId || '',
type: PaymentTypes.ONE_TIME,
userId: userId,
customerId: customerId,
sessionId: session.id, // Use sessionId to track processed sessions
status: 'completed',
periodStart: now,
createdAt: now,
updatedAt: now,
});
// add credits to user account
const amount = session.amount_total ? session.amount_total / 100 : 0;
await addCredits({
userId,
amount: Number.parseInt(credits),
type: CREDIT_TRANSACTION_TYPE.PURCHASE_PACKAGE,
description: `+${credits} credits for package ${packageId} ($${amount.toLocaleString()})`,
paymentId: session.id,
expireDays: creditPackage.expireDays,
});
console.log('Added ' + credits + ' credits to user');
} catch (error) {
console.error('onCreditPurchase error for session: ' + session.id, error);
throw error;
}
}
/**

84
wrangler.jsonc Normal file
View File

@ -0,0 +1,84 @@
/**
* For more details on how to configure Wrangler, refer to:
* https://developers.cloudflare.com/workers/wrangler/configuration/
*/
{
"$schema": "node_modules/wrangler/config-schema.json",
"main": ".open-next/worker.js",
"name": "mksaas-template",
"compatibility_date": "2024-12-30",
"compatibility_flags": [
// Enable Node.js API
// see https://developers.cloudflare.com/workers/configuration/compatibility-flags/#nodejs-compatibility-flag
"nodejs_compat",
// This also enables nodejs_compat_v2 as long as compatibility date is 2024-09-23 or later.
// https://developers.cloudflare.com/workers/configuration/compatibility-dates/#nodejs-compatibility-flag
// Enable improved Node.js API with polyfills and native code
// see https://blog.cloudflare.com/zh-cn/more-npm-packages-on-cloudflare-workers-combining-polyfills-and-native-code/
// "nodejs_compat_v2",
// Enable auto-populating process.env
// see https://developers.cloudflare.com/workers/configuration/compatibility-flags/#enable-auto-populating-processenv
"nodejs_compat_populate_process_env",
// Allow to fetch URLs in your app
// see https://developers.cloudflare.com/workers/configuration/compatibility-flags/#global-fetch-strictly-public
"global_fetch_strictly_public"
],
// Minification helps to keep the Worker bundle size down and improve start up time.
"minify": true,
// Enables Workers Trace Events Logpush for a Worker
"logpush": true,
// https://developers.cloudflare.com/workers/wrangler/configuration/#top-level-only-keys
// Whether Wrangler should keep variables configured in the dashboard on deploy
"keep_vars": true,
"assets": {
"binding": "ASSETS",
"directory": ".open-next/assets"
},
// https://developers.cloudflare.com/workers/wrangler/configuration/#observability
"observability": {
"enabled": true
},
/**
* Smart Placement
* Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement
*/
// "placement": { "mode": "smart" },
/**
* Bindings
* Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including
* databases, object storage, AI inference, real-time communication and more.
* https://developers.cloudflare.com/workers/runtime-apis/bindings/
*/
/**
* Hyperdrive
* https://opennext.js.org/cloudflare/howtos/db#hyperdrive-example
* https://developers.cloudflare.com/workers/tutorials/postgres/#8-use-hyperdrive-to-accelerate-queries
*/
"hyperdrive": [
{
"binding": "HYPERDRIVE",
"id": "8ba4508b28cf42f987f3533c1f09433c",
"localConnectionString": "postgresql://postgres:postgres@localhost:5432/postgres"
}
],
/**
* Environment Variables
* https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables
*/
"vars": {},
/**
* Note: Use secrets to store sensitive data.
* https://developers.cloudflare.com/workers/configuration/secrets/
*/
"kv_namespaces": []
}