mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-18 18:41:52 +08:00
Merge canary branch with callout background color feature
This commit is contained in:
@@ -148,6 +148,11 @@
|
|||||||
"description": "Whether allow new registrations.\n@default true",
|
"description": "Whether allow new registrations.\n@default true",
|
||||||
"default": true
|
"default": true
|
||||||
},
|
},
|
||||||
|
"allowSignupForOauth": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether allow new registrations via configured oauth.\n@default true",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
"requireEmailDomainVerification": {
|
"requireEmailDomainVerification": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "Whether require email domain record verification before accessing restricted resources.\n@default false",
|
"description": "Whether require email domain record verification before accessing restricted resources.\n@default false",
|
||||||
@@ -190,6 +195,11 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Configuration for mailer module",
|
"description": "Configuration for mailer module",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"SMTP.name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Name of the email server (e.g. your domain name)\n@default \"AFFiNE Server\"\n@environment `MAILER_SERVERNAME`",
|
||||||
|
"default": "AFFiNE Server"
|
||||||
|
},
|
||||||
"SMTP.host": {
|
"SMTP.host": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Host of the email server (e.g. smtp.gmail.com)\n@default \"\"\n@environment `MAILER_HOST`",
|
"description": "Host of the email server (e.g. smtp.gmail.com)\n@default \"\"\n@environment `MAILER_HOST`",
|
||||||
@@ -225,6 +235,11 @@
|
|||||||
"description": "The emails from these domains are always sent using the fallback SMTP server.\n@default []",
|
"description": "The emails from these domains are always sent using the fallback SMTP server.\n@default []",
|
||||||
"default": []
|
"default": []
|
||||||
},
|
},
|
||||||
|
"fallbackSMTP.name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Name of the fallback email server (e.g. your domain name)\n@default \"AFFiNE Server\"",
|
||||||
|
"default": "AFFiNE Server"
|
||||||
|
},
|
||||||
"fallbackSMTP.host": {
|
"fallbackSMTP.host": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Host of the email server (e.g. smtp.gmail.com)\n@default \"\"",
|
"description": "Host of the email server (e.g. smtp.gmail.com)\n@default \"\"",
|
||||||
@@ -1093,18 +1108,33 @@
|
|||||||
},
|
},
|
||||||
"apiKey": {
|
"apiKey": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Stripe API key to enable payment service.\n@default \"\"\n@environment `STRIPE_API_KEY`",
|
"description": "[Deprecated] Stripe API key. Use payment.stripe.apiKey instead.\n@default \"\"\n@environment `STRIPE_API_KEY`",
|
||||||
"default": ""
|
"default": ""
|
||||||
},
|
},
|
||||||
"webhookKey": {
|
"webhookKey": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Stripe webhook key to enable payment service.\n@default \"\"\n@environment `STRIPE_WEBHOOK_KEY`",
|
"description": "[Deprecated] Stripe webhook key. Use payment.stripe.webhookKey instead.\n@default \"\"\n@environment `STRIPE_WEBHOOK_KEY`",
|
||||||
"default": ""
|
"default": ""
|
||||||
},
|
},
|
||||||
"stripe": {
|
"stripe": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Stripe sdk options\n@default {}\n@link https://docs.stripe.com/api",
|
"description": "Stripe sdk options and credentials\n@default {\"apiKey\":\"\",\"webhookKey\":\"\"}\n@link https://docs.stripe.com/api",
|
||||||
"default": {}
|
"default": {
|
||||||
|
"apiKey": "",
|
||||||
|
"webhookKey": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"revenuecat": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "RevenueCat integration configs\n@default {\"enabled\":false,\"apiKey\":\"\",\"projectId\":\"\",\"webhookAuth\":\"\",\"environment\":\"production\",\"productMap\":{}}\n@link https://www.revenuecat.com/docs/",
|
||||||
|
"default": {
|
||||||
|
"enabled": false,
|
||||||
|
"apiKey": "",
|
||||||
|
"projectId": "",
|
||||||
|
"webhookAuth": "",
|
||||||
|
"environment": "production",
|
||||||
|
"productMap": {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ Star us, and you will receive all release notifications from GitHub without any
|
|||||||
|
|
||||||
**Multimodal AI partner ready to kick in any work**
|
**Multimodal AI partner ready to kick in any work**
|
||||||
|
|
||||||
- Write up professional work report? Turn an outline into expressive and presentable slides? Summary an article into a well-structured mindmap? Sorting your job plan and backlog for tasks? Or... draw and code prototype apps and web pages directly all with one prompt? With you, [AFFiNE AI](https://affine.pro/ai) pushes your creativity to the edge of your imagination,just like [Canvas AI](https://affine.pro/blog/best-canvas-ai) to generate mind map for brainstorming.
|
- Write up professional work report? Turn an outline into expressive and presentable slides? Summary an article into a well-structured mindmap? Sorting your job plan and backlog for tasks? Or... draw and code prototype apps and web pages directly all with one prompt? With you, [AFFiNE AI](https://affine.pro/ai) pushes your creativity to the edge of your imagination, just like [Canvas AI](https://affine.pro/blog/best-canvas-ai) to generate mind map for brainstorming.
|
||||||
|
|
||||||
**Local-first & Real-time collaborative**
|
**Local-first & Real-time collaborative**
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@lit/context": "^1.1.2",
|
"@lit/context": "^1.1.2",
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@emoji-mart/data": "^1.2.1",
|
"@emoji-mart/data": "^1.2.1",
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
||||||
"@blocksuite/data-view": "workspace:*",
|
"@blocksuite/data-view": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
||||||
"@blocksuite/data-view": "workspace:*",
|
"@blocksuite/data-view": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@emotion/css": "^11.13.5",
|
"@emotion/css": "^11.13.5",
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
||||||
"@blocksuite/affine-widget-frame-title": "workspace:*",
|
"@blocksuite/affine-widget-frame-title": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
"@blocksuite/affine-rich-text": "workspace:*",
|
"@blocksuite/affine-rich-text": "workspace:*",
|
||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@lit/context": "^1.1.2",
|
"@lit/context": "^1.1.2",
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
||||||
"@blocksuite/data-view": "workspace:*",
|
"@blocksuite/data-view": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
"@blocksuite/affine-widget-slash-menu": "workspace:*",
|
||||||
"@blocksuite/data-view": "workspace:*",
|
"@blocksuite/data-view": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@emotion/css": "^11.13.5",
|
"@emotion/css": "^11.13.5",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"@blocksuite/affine-model": "workspace:*",
|
"@blocksuite/affine-model": "workspace:*",
|
||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@blocksuite/sync": "workspace:*",
|
"@blocksuite/sync": "workspace:*",
|
||||||
|
|||||||
@@ -193,6 +193,7 @@ export const menuButtonItems = {
|
|||||||
(config: {
|
(config: {
|
||||||
name: string;
|
name: string;
|
||||||
label?: () => TemplateResult;
|
label?: () => TemplateResult;
|
||||||
|
info?: TemplateResult;
|
||||||
prefix?: TemplateResult;
|
prefix?: TemplateResult;
|
||||||
postfix?: TemplateResult;
|
postfix?: TemplateResult;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
@@ -211,7 +212,7 @@ export const menuButtonItems = {
|
|||||||
return html`
|
return html`
|
||||||
${config.prefix}
|
${config.prefix}
|
||||||
<div class="affine-menu-action-text">
|
<div class="affine-menu-action-text">
|
||||||
${config.label?.() ?? config.name}
|
${config.label?.() ?? config.name} ${config.info}
|
||||||
</div>
|
</div>
|
||||||
${config.postfix ?? (config.isSelected ? DoneIcon() : undefined)}
|
${config.postfix ?? (config.isSelected ? DoneIcon() : undefined)}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"@blocksuite/affine-components": "workspace:*",
|
"@blocksuite/affine-components": "workspace:*",
|
||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@emotion/css": "^11.13.5",
|
"@emotion/css": "^11.13.5",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/data-view": "workspace:*",
|
"@blocksuite/data-view": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
"@blocksuite/affine-model": "workspace:*",
|
"@blocksuite/affine-model": "workspace:*",
|
||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
"@blocksuite/affine-rich-text": "workspace:*",
|
"@blocksuite/affine-rich-text": "workspace:*",
|
||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const DOC_BLOCK_CHILD_PADDING = 24;
|
|||||||
|
|
||||||
export class DocTitle extends WithDisposable(ShadowlessElement) {
|
export class DocTitle extends WithDisposable(ShadowlessElement) {
|
||||||
static override styles = css`
|
static override styles = css`
|
||||||
|
.doc-icon-container,
|
||||||
.doc-title-container {
|
.doc-title-container {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-family: var(--affine-font-family);
|
font-family: var(--affine-font-family);
|
||||||
@@ -49,6 +50,7 @@ export class DocTitle extends WithDisposable(ShadowlessElement) {
|
|||||||
|
|
||||||
/* Extra small devices (phones, 640px and down) */
|
/* Extra small devices (phones, 640px and down) */
|
||||||
@container viewport (width <= 640px) {
|
@container viewport (width <= 640px) {
|
||||||
|
.doc-icon-container,
|
||||||
.doc-title-container {
|
.doc-title-container {
|
||||||
padding-left: ${DOC_BLOCK_CHILD_PADDING}px;
|
padding-left: ${DOC_BLOCK_CHILD_PADDING}px;
|
||||||
padding-right: ${DOC_BLOCK_CHILD_PADDING}px;
|
padding-right: ${DOC_BLOCK_CHILD_PADDING}px;
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
"@blocksuite/affine-rich-text": "workspace:*",
|
"@blocksuite/affine-rich-text": "workspace:*",
|
||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
"@blocksuite/affine-rich-text": "workspace:*",
|
"@blocksuite/affine-rich-text": "workspace:*",
|
||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@lit/context": "^1.1.2",
|
"@lit/context": "^1.1.2",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@lit/context": "^1.1.2",
|
"@lit/context": "^1.1.2",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@lit/context": "^1.1.2",
|
"@lit/context": "^1.1.2",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@lit/context": "^1.1.2",
|
"@lit/context": "^1.1.2",
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@lit/context": "^1.1.2",
|
"@lit/context": "^1.1.2",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@lit/context": "^1.1.2",
|
"@lit/context": "^1.1.2",
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@lit/context": "^1.1.2",
|
"@lit/context": "^1.1.2",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@lit/context": "^1.1.2",
|
"@lit/context": "^1.1.2",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@lit/context": "^1.1.2",
|
"@lit/context": "^1.1.2",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"@blocksuite/affine-model": "workspace:*",
|
"@blocksuite/affine-model": "workspace:*",
|
||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"@blocksuite/affine-rich-text": "workspace:*",
|
"@blocksuite/affine-rich-text": "workspace:*",
|
||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"@blocksuite/affine-model": "workspace:*",
|
"@blocksuite/affine-model": "workspace:*",
|
||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
"@blocksuite/affine-model": "workspace:*",
|
"@blocksuite/affine-model": "workspace:*",
|
||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
"@blocksuite/affine-rich-text": "workspace:*",
|
"@blocksuite/affine-rich-text": "workspace:*",
|
||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
"@blocksuite/affine-model": "workspace:*",
|
"@blocksuite/affine-model": "workspace:*",
|
||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"@blocksuite/affine-model": "workspace:*",
|
"@blocksuite/affine-model": "workspace:*",
|
||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@blocksuite/affine-model": "workspace:*",
|
"@blocksuite/affine-model": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"@blocksuite/affine-model": "workspace:*",
|
"@blocksuite/affine-model": "workspace:*",
|
||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"@blocksuite/affine-model": "workspace:*",
|
"@blocksuite/affine-model": "workspace:*",
|
||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@preact/signals-core": "^1.8.0",
|
"@preact/signals-core": "^1.8.0",
|
||||||
"@toeverything/theme": "^1.1.16",
|
"@toeverything/theme": "^1.1.16",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"@blocksuite/affine-model": "workspace:*",
|
"@blocksuite/affine-model": "workspace:*",
|
||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@lit/context": "^1.1.2",
|
"@lit/context": "^1.1.2",
|
||||||
"@preact/signals-core": "^1.8.0",
|
"@preact/signals-core": "^1.8.0",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"@blocksuite/affine-model": "workspace:*",
|
"@blocksuite/affine-model": "workspace:*",
|
||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@lit/context": "^1.1.2",
|
"@lit/context": "^1.1.2",
|
||||||
"@preact/signals-core": "^1.8.0",
|
"@preact/signals-core": "^1.8.0",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"@blocksuite/affine-rich-text": "workspace:*",
|
"@blocksuite/affine-rich-text": "workspace:*",
|
||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
"@lit/context": "^1.1.2",
|
"@lit/context": "^1.1.2",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"@blocksuite/affine-model": "workspace:*",
|
"@blocksuite/affine-model": "workspace:*",
|
||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
"@preact/signals-core": "^1.8.0",
|
"@preact/signals-core": "^1.8.0",
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/data-view": "workspace:*",
|
"@blocksuite/data-view": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@lit/context": "^1.1.2",
|
"@lit/context": "^1.1.2",
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
"@blocksuite/affine-rich-text": "workspace:*",
|
"@blocksuite/affine-rich-text": "workspace:*",
|
||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@lit/context": "^1.1.2",
|
"@lit/context": "^1.1.2",
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/affine-widget-edgeless-selected-rect": "workspace:*",
|
"@blocksuite/affine-widget-edgeless-selected-rect": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@lit/context": "^1.1.2",
|
"@lit/context": "^1.1.2",
|
||||||
"@preact/signals-core": "^1.8.0",
|
"@preact/signals-core": "^1.8.0",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
"@blocksuite/affine-model": "workspace:*",
|
"@blocksuite/affine-model": "workspace:*",
|
||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@lit/context": "^1.1.2",
|
"@lit/context": "^1.1.2",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"@blocksuite/affine-model": "workspace:*",
|
"@blocksuite/affine-model": "workspace:*",
|
||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@preact/signals-core": "^1.8.0",
|
"@preact/signals-core": "^1.8.0",
|
||||||
"@toeverything/theme": "^1.1.16",
|
"@toeverything/theme": "^1.1.16",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
"@blocksuite/affine-rich-text": "workspace:*",
|
"@blocksuite/affine-rich-text": "workspace:*",
|
||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@blocksuite/store": "workspace:*",
|
"@blocksuite/store": "workspace:*",
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
"@blocksuite/affine-model": "workspace:*",
|
"@blocksuite/affine-model": "workspace:*",
|
||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
"@preact/signals-core": "^1.8.0",
|
"@preact/signals-core": "^1.8.0",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
"@blocksuite/affine-model": "workspace:*",
|
"@blocksuite/affine-model": "workspace:*",
|
||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/global": "workspace:*",
|
"@blocksuite/global": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/std": "workspace:*",
|
"@blocksuite/std": "workspace:*",
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
"@preact/signals-core": "^1.8.0",
|
"@preact/signals-core": "^1.8.0",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@blocksuite/affine": "workspace:*",
|
"@blocksuite/affine": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@floating-ui/dom": "^1.6.13",
|
"@floating-ui/dom": "^1.6.13",
|
||||||
"@lit/context": "^1.1.3",
|
"@lit/context": "^1.1.3",
|
||||||
"@lottiefiles/dotlottie-wc": "^0.5.0",
|
"@lottiefiles/dotlottie-wc": "^0.5.0",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"@blocksuite/affine-model": "workspace:*",
|
"@blocksuite/affine-model": "workspace:*",
|
||||||
"@blocksuite/affine-shared": "workspace:*",
|
"@blocksuite/affine-shared": "workspace:*",
|
||||||
"@blocksuite/data-view": "workspace:*",
|
"@blocksuite/data-view": "workspace:*",
|
||||||
"@blocksuite/icons": "^2.2.12",
|
"@blocksuite/icons": "^2.2.17",
|
||||||
"@blocksuite/integration-test": "workspace:*",
|
"@blocksuite/integration-test": "workspace:*",
|
||||||
"@preact/signals-core": "^1.8.0",
|
"@preact/signals-core": "^1.8.0",
|
||||||
"@shoelace-style/shoelace": "2.20.1",
|
"@shoelace-style/shoelace": "2.20.1",
|
||||||
|
|||||||
+1
-1
@@ -53,7 +53,7 @@
|
|||||||
"@affine-tools/cli": "workspace:*",
|
"@affine-tools/cli": "workspace:*",
|
||||||
"@capacitor/cli": "^7.0.0",
|
"@capacitor/cli": "^7.0.0",
|
||||||
"@eslint/js": "^9.16.0",
|
"@eslint/js": "^9.16.0",
|
||||||
"@faker-js/faker": "^9.3.0",
|
"@faker-js/faker": "^10.0.0",
|
||||||
"@istanbuljs/schema": "^0.1.3",
|
"@istanbuljs/schema": "^0.1.3",
|
||||||
"@magic-works/i18n-codegen": "^0.6.1",
|
"@magic-works/i18n-codegen": "^0.6.1",
|
||||||
"@playwright/test": "=1.52.0",
|
"@playwright/test": "=1.52.0",
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Provider" AS ENUM ('stripe', 'revenuecat');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "IapStore" AS ENUM ('app_store', 'play_store');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "subscriptions" ADD COLUMN "iap_store" "IapStore",
|
||||||
|
ADD COLUMN "provider" "Provider" NOT NULL DEFAULT 'stripe',
|
||||||
|
ADD COLUMN "rc_entitlement" VARCHAR,
|
||||||
|
ADD COLUMN "rc_external_ref" VARCHAR,
|
||||||
|
ADD COLUMN "rc_product_id" VARCHAR;
|
||||||
@@ -127,7 +127,8 @@
|
|||||||
"@affine-tools/cli": "workspace:*",
|
"@affine-tools/cli": "workspace:*",
|
||||||
"@affine-tools/utils": "workspace:*",
|
"@affine-tools/utils": "workspace:*",
|
||||||
"@affine/graphql": "workspace:*",
|
"@affine/graphql": "workspace:*",
|
||||||
"@faker-js/faker": "^9.6.0",
|
"@faker-js/faker": "^10.0.0",
|
||||||
|
"@nestjs/swagger": "^11.2.0",
|
||||||
"@nestjs/testing": "patch:@nestjs/testing@npm%3A10.4.15#~/.yarn/patches/@nestjs-testing-npm-10.4.15-d591a1705a.patch",
|
"@nestjs/testing": "patch:@nestjs/testing@npm%3A10.4.15#~/.yarn/patches/@nestjs-testing-npm-10.4.15-d591a1705a.patch",
|
||||||
"@types/cookie-parser": "^1.4.8",
|
"@types/cookie-parser": "^1.4.8",
|
||||||
"@types/express": "^5.0.1",
|
"@types/express": "^5.0.1",
|
||||||
|
|||||||
@@ -749,6 +749,16 @@ model Subscription {
|
|||||||
stripeSubscriptionId String? @unique @map("stripe_subscription_id")
|
stripeSubscriptionId String? @unique @map("stripe_subscription_id")
|
||||||
// stripe schedule id
|
// stripe schedule id
|
||||||
stripeScheduleId String? @map("stripe_schedule_id") @db.VarChar
|
stripeScheduleId String? @map("stripe_schedule_id") @db.VarChar
|
||||||
|
// subscription provider: stripe or revenuecat
|
||||||
|
provider Provider @default(stripe)
|
||||||
|
// iap store for revenuecat subscriptions
|
||||||
|
iapStore IapStore? @map("iap_store")
|
||||||
|
// revenuecat entitlement name like "Pro" / "AI"
|
||||||
|
rcEntitlement String? @map("rc_entitlement") @db.VarChar
|
||||||
|
// revenuecat product id like "app.affine.pro.Annual"
|
||||||
|
rcProductId String? @map("rc_product_id") @db.VarChar
|
||||||
|
// external reference, appstore originalTransactionId or play purchaseToken
|
||||||
|
rcExternalRef String? @map("rc_external_ref") @db.VarChar
|
||||||
// subscription.status, active/past_due/canceled/unpaid...
|
// subscription.status, active/past_due/canceled/unpaid...
|
||||||
status String @db.VarChar(20)
|
status String @db.VarChar(20)
|
||||||
// subscription.current_period_start
|
// subscription.current_period_start
|
||||||
@@ -770,6 +780,16 @@ model Subscription {
|
|||||||
@@map("subscriptions")
|
@@map("subscriptions")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum Provider {
|
||||||
|
stripe
|
||||||
|
revenuecat
|
||||||
|
}
|
||||||
|
|
||||||
|
enum IapStore {
|
||||||
|
app_store
|
||||||
|
play_store
|
||||||
|
}
|
||||||
|
|
||||||
model Invoice {
|
model Invoice {
|
||||||
stripeInvoiceId String @id @map("stripe_invoice_id")
|
stripeInvoiceId String @id @map("stripe_invoice_id")
|
||||||
targetId String @map("target_id") @db.VarChar
|
targetId String @map("target_id") @db.VarChar
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
# Snapshot report for `src/__tests__/payment/revenuecat.spec.ts`
|
||||||
|
|
||||||
|
The actual snapshot is saved in `revenuecat.spec.ts.snap`.
|
||||||
|
|
||||||
|
Generated by [AVA](https://avajs.dev).
|
||||||
|
|
||||||
|
## should resolve product mapping consistently (whitelist, override, unknown)
|
||||||
|
|
||||||
|
> should map product for whitelist/override/unknown
|
||||||
|
|
||||||
|
{
|
||||||
|
override: {
|
||||||
|
customMonthly: {
|
||||||
|
plan: 'pro',
|
||||||
|
recurring: 'monthly',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
unknown: null,
|
||||||
|
whitelist: {
|
||||||
|
aiAnnual: {
|
||||||
|
plan: 'ai',
|
||||||
|
recurring: 'yearly',
|
||||||
|
},
|
||||||
|
proAnnual: {
|
||||||
|
plan: 'pro',
|
||||||
|
recurring: 'yearly',
|
||||||
|
},
|
||||||
|
proMonthly: {
|
||||||
|
plan: 'pro',
|
||||||
|
recurring: 'monthly',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
## should standardize RC subscriber response and upsert subscription with observability fields
|
||||||
|
|
||||||
|
> should standardize payload and have events
|
||||||
|
|
||||||
|
{
|
||||||
|
activatedCount: 1,
|
||||||
|
canceledCount: 0,
|
||||||
|
dbObservability: {
|
||||||
|
iapStore: 'app_store',
|
||||||
|
provider: 'revenuecat',
|
||||||
|
rcEntitlement: 'Pro',
|
||||||
|
rcExternalRef: 'orig-tx-1',
|
||||||
|
rcProductId: 'app.affine.pro.Annual',
|
||||||
|
},
|
||||||
|
lastActivated: {
|
||||||
|
plan: 'pro',
|
||||||
|
recurring: 'yearly',
|
||||||
|
},
|
||||||
|
subscriberCount: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
## should process expiration/refund by deleting subscription and emitting canceled
|
||||||
|
|
||||||
|
> should process expiration/refund and emit canceled
|
||||||
|
|
||||||
|
{
|
||||||
|
activatedCount: 0,
|
||||||
|
canceledCount: 1,
|
||||||
|
finalDBCount: 0,
|
||||||
|
lastCanceled: {
|
||||||
|
plan: 'pro',
|
||||||
|
recurring: 'yearly',
|
||||||
|
},
|
||||||
|
subscriberCount: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
## should enqueue per-user reconciliation jobs for existing RC active/trialing/past_due subscriptions
|
||||||
|
|
||||||
|
> should enqueue per-user RC reconciliation jobs (deduplicated by userId)
|
||||||
|
|
||||||
|
{
|
||||||
|
queued: [
|
||||||
|
{
|
||||||
|
name: 'nightly.revenuecat.syncUser',
|
||||||
|
opts: {
|
||||||
|
attempts: 3,
|
||||||
|
backoff: {
|
||||||
|
delay: 60000,
|
||||||
|
type: 'exponential',
|
||||||
|
},
|
||||||
|
jobId: 'nightly-rc-sync-u1',
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
userId: 'u1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'nightly.revenuecat.syncUser',
|
||||||
|
opts: {
|
||||||
|
attempts: 3,
|
||||||
|
backoff: {
|
||||||
|
delay: 60000,
|
||||||
|
type: 'exponential',
|
||||||
|
},
|
||||||
|
jobId: 'nightly-rc-sync-u2',
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
userId: 'u2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
uniqueJobCount: 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
## should activate subscriptions via webhook for whitelisted products across stores (iOS/Android)
|
||||||
|
|
||||||
|
> should activate subscriptions via webhook for whitelisted products across stores (iOS/Android)
|
||||||
|
|
||||||
|
{
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
activatedCount: 1,
|
||||||
|
name: 'Pro monthly on iOS',
|
||||||
|
rec: {
|
||||||
|
iapStore: 'app_store',
|
||||||
|
plan: 'pro',
|
||||||
|
provider: 'revenuecat',
|
||||||
|
rcEntitlement: 'Pro',
|
||||||
|
rcExternalRef: 'orig-ios-1',
|
||||||
|
rcProductId: 'app.affine.pro.Monthly',
|
||||||
|
recurring: 'monthly',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
activatedCount: 1,
|
||||||
|
name: 'AI annual on Android',
|
||||||
|
rec: {
|
||||||
|
iapStore: 'play_store',
|
||||||
|
plan: 'ai',
|
||||||
|
provider: 'revenuecat',
|
||||||
|
rcEntitlement: 'AI',
|
||||||
|
rcExternalRef: 'token-android-1',
|
||||||
|
rcProductId: 'app.affine.pro.ai.Annual',
|
||||||
|
recurring: 'yearly',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
## should keep active and advance period dates when a trialing subscription renews
|
||||||
|
|
||||||
|
> should keep active after trial renewal
|
||||||
|
|
||||||
|
{
|
||||||
|
activatedCount: 2,
|
||||||
|
canceledCount: 0,
|
||||||
|
status: 'active',
|
||||||
|
}
|
||||||
|
|
||||||
|
## should remove or cancel the record and revoke entitlement when a trialing subscription expires
|
||||||
|
|
||||||
|
> should remove record
|
||||||
|
|
||||||
|
{
|
||||||
|
canceledCount: 1,
|
||||||
|
finalDBCount: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
## should set canceledAt and keep active until expiration when will_renew is false (cancellation before period end)
|
||||||
|
|
||||||
|
> should keep active until period end when will_renew is false
|
||||||
|
|
||||||
|
{
|
||||||
|
activatedCount: 1,
|
||||||
|
canceledCount: 0,
|
||||||
|
hasCanceledAt: true,
|
||||||
|
status: 'active',
|
||||||
|
}
|
||||||
|
|
||||||
|
## should retain record as past_due (inactive but not expired) and NOT emit canceled event
|
||||||
|
|
||||||
|
> should retain past_due record and NOT emit canceled event
|
||||||
|
|
||||||
|
{
|
||||||
|
canceledCount: 0,
|
||||||
|
status: 'past_due',
|
||||||
|
}
|
||||||
|
|
||||||
|
## should skip RC upsert when Stripe active already exists for same plan
|
||||||
|
|
||||||
|
> should skip RC upsert when Stripe active already exists
|
||||||
|
|
||||||
|
{
|
||||||
|
activatedCount: 0,
|
||||||
|
hasRCRecord: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
## should reconcile and fix missing or out-of-order states for revenuecat Active/Trialing/PastDue records
|
||||||
|
|
||||||
|
> should reconcile and fix missing or out-of-order states for revenuecat records
|
||||||
|
|
||||||
|
{
|
||||||
|
activatedCount: 1,
|
||||||
|
canceledCount: 0,
|
||||||
|
subscriberCount: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
## should treat refund as early expiration and revoke immediately
|
||||||
|
|
||||||
|
> should delete record and emit canceled on refund
|
||||||
|
|
||||||
|
{
|
||||||
|
canceledCount: 1,
|
||||||
|
finalDBCount: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
## should ignore non-whitelisted productId and not write to DB
|
||||||
|
|
||||||
|
> should ignore non-whitelisted productId and not write to DB
|
||||||
|
|
||||||
|
{
|
||||||
|
activatedCount: 0,
|
||||||
|
canceledCount: 0,
|
||||||
|
dbCount: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
## should map via entitlement+duration when productId not whitelisted (P1M/P1Y only)
|
||||||
|
|
||||||
|
> should map via entitlement+duration fallback and ignore unsupported durations
|
||||||
|
|
||||||
|
{
|
||||||
|
aiViaFallback: {
|
||||||
|
plan: 'ai',
|
||||||
|
provider: 'revenuecat',
|
||||||
|
recurring: 'yearly',
|
||||||
|
},
|
||||||
|
eventsCounts: {
|
||||||
|
afterFirst: {
|
||||||
|
a: 1,
|
||||||
|
c: 0,
|
||||||
|
},
|
||||||
|
afterSecond: {
|
||||||
|
a: 2,
|
||||||
|
c: 0,
|
||||||
|
},
|
||||||
|
afterThird: {
|
||||||
|
a: 2,
|
||||||
|
c: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
proViaFallback: {
|
||||||
|
plan: 'pro',
|
||||||
|
provider: 'revenuecat',
|
||||||
|
recurring: 'monthly',
|
||||||
|
},
|
||||||
|
totalCount: 2,
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,929 @@
|
|||||||
|
import { PrismaClient, User } from '@prisma/client';
|
||||||
|
import ava, { TestFn } from 'ava';
|
||||||
|
import { omit } from 'lodash-es';
|
||||||
|
import Sinon from 'sinon';
|
||||||
|
|
||||||
|
import {
|
||||||
|
EventBus,
|
||||||
|
ManagedByAppStoreOrPlay,
|
||||||
|
SubscriptionAlreadyExists,
|
||||||
|
} from '../../base';
|
||||||
|
import { ConfigModule } from '../../base/config';
|
||||||
|
import { FeatureService } from '../../core/features';
|
||||||
|
import { Models } from '../../models';
|
||||||
|
import { PaymentModule } from '../../plugins/payment';
|
||||||
|
import { SubscriptionCronJobs } from '../../plugins/payment/cron';
|
||||||
|
import { UserSubscriptionManager } from '../../plugins/payment/manager';
|
||||||
|
import {
|
||||||
|
RcEvent,
|
||||||
|
resolveProductMapping,
|
||||||
|
RevenueCatService,
|
||||||
|
RevenueCatWebhookController,
|
||||||
|
RevenueCatWebhookHandler,
|
||||||
|
type Subscription,
|
||||||
|
} from '../../plugins/payment/revenuecat';
|
||||||
|
import { SubscriptionService } from '../../plugins/payment/service';
|
||||||
|
import {
|
||||||
|
SubscriptionPlan,
|
||||||
|
SubscriptionRecurring,
|
||||||
|
} from '../../plugins/payment/types';
|
||||||
|
import { createTestingApp, TestingApp } from '../utils';
|
||||||
|
|
||||||
|
type Ctx = {
|
||||||
|
module: TestingApp;
|
||||||
|
db: PrismaClient;
|
||||||
|
models: Models;
|
||||||
|
event: Sinon.SinonStubbedInstance<EventBus>;
|
||||||
|
|
||||||
|
service: SubscriptionService;
|
||||||
|
rc: RevenueCatService;
|
||||||
|
webhook: RevenueCatWebhookHandler;
|
||||||
|
controller: RevenueCatWebhookController;
|
||||||
|
|
||||||
|
mockSub: (subs: Subscription[]) => Sinon.SinonStub;
|
||||||
|
mockSubSeq: (sequences: Subscription[][]) => Sinon.SinonStub;
|
||||||
|
triggerWebhook: (
|
||||||
|
userId: string,
|
||||||
|
event: Omit<RcEvent, 'app_id' | 'environment'>
|
||||||
|
) => Promise<void>;
|
||||||
|
collectEvents: () => {
|
||||||
|
activatedCount: number;
|
||||||
|
canceledCount: number;
|
||||||
|
events: Record<string, any[]>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const test = ava as TestFn<Ctx>;
|
||||||
|
let user: User;
|
||||||
|
|
||||||
|
test.beforeEach(async t => {
|
||||||
|
const app = await createTestingApp({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.override({
|
||||||
|
payment: {
|
||||||
|
revenuecat: {
|
||||||
|
enabled: true,
|
||||||
|
webhookAuth: '42',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
PaymentModule,
|
||||||
|
],
|
||||||
|
tapModule: m => {
|
||||||
|
m.overrideProvider(FeatureService).useValue(
|
||||||
|
Sinon.createStubInstance(FeatureService)
|
||||||
|
);
|
||||||
|
m.overrideProvider(EventBus).useValue(Sinon.createStubInstance(EventBus));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const db = app.get(PrismaClient);
|
||||||
|
const models = app.get(Models);
|
||||||
|
const event = app.get(EventBus) as Sinon.SinonStubbedInstance<EventBus>;
|
||||||
|
|
||||||
|
const service = app.get(SubscriptionService);
|
||||||
|
const rc = app.get(RevenueCatService);
|
||||||
|
const webhook = app.get(RevenueCatWebhookHandler);
|
||||||
|
const controller = app.get(RevenueCatWebhookController);
|
||||||
|
|
||||||
|
t.context.module = app;
|
||||||
|
t.context.db = db;
|
||||||
|
t.context.models = models;
|
||||||
|
t.context.event = event;
|
||||||
|
|
||||||
|
t.context.service = service;
|
||||||
|
t.context.rc = rc;
|
||||||
|
t.context.webhook = webhook;
|
||||||
|
t.context.controller = controller;
|
||||||
|
|
||||||
|
t.context.mockSub = subs => Sinon.stub(rc, 'getSubscriptions').resolves(subs);
|
||||||
|
t.context.mockSubSeq = sequences => {
|
||||||
|
const stub = Sinon.stub(rc, 'getSubscriptions');
|
||||||
|
sequences.forEach((seq, idx) => {
|
||||||
|
if (idx === 0) stub.onFirstCall().resolves(seq);
|
||||||
|
else if (idx === 1) stub.onSecondCall().resolves(seq);
|
||||||
|
else stub.onCall(idx).resolves(seq);
|
||||||
|
});
|
||||||
|
return stub;
|
||||||
|
};
|
||||||
|
t.context.triggerWebhook = async (appUserId, event) => {
|
||||||
|
await webhook.onWebhook({
|
||||||
|
appUserId,
|
||||||
|
event: {
|
||||||
|
...event,
|
||||||
|
app_id: 'app.affine.pro',
|
||||||
|
environment: 'SANDBOX',
|
||||||
|
} as RcEvent,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
t.context.collectEvents = () => {
|
||||||
|
const events = event.emit.getCalls().reduce(
|
||||||
|
(acc, c) => {
|
||||||
|
const [key, value] = c.args;
|
||||||
|
acc[key] = acc[key] || [];
|
||||||
|
acc[key].push(value);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as { [key: string]: any[] }
|
||||||
|
);
|
||||||
|
const activatedCount = events['user.subscription.activated']?.length || 0;
|
||||||
|
const canceledCount = events['user.subscription.canceled']?.length || 0;
|
||||||
|
|
||||||
|
return { activatedCount, canceledCount, events };
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
test.beforeEach(async t => {
|
||||||
|
await t.context.module.initTestingDB();
|
||||||
|
user = await t.context.models.user.create({
|
||||||
|
email: 'test@affine.pro',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach.always(async t => {
|
||||||
|
Sinon.reset();
|
||||||
|
await t.context.module.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should resolve product mapping consistently (whitelist, override, unknown)', t => {
|
||||||
|
const override = {
|
||||||
|
'custom.sku.monthly': { plan: 'pro', recurring: 'monthly' },
|
||||||
|
} as Record<string, { plan: string; recurring: string }>;
|
||||||
|
|
||||||
|
const actual = {
|
||||||
|
whitelist: {
|
||||||
|
proMonthly: resolveProductMapping({
|
||||||
|
productId: 'app.affine.pro.Monthly',
|
||||||
|
}),
|
||||||
|
proAnnual: resolveProductMapping({ productId: 'app.affine.pro.Annual' }),
|
||||||
|
aiAnnual: resolveProductMapping({
|
||||||
|
productId: 'app.affine.pro.ai.Annual',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
override: {
|
||||||
|
customMonthly: resolveProductMapping(
|
||||||
|
{ productId: 'custom.sku.monthly' },
|
||||||
|
override
|
||||||
|
),
|
||||||
|
},
|
||||||
|
unknown: resolveProductMapping({ productId: 'unknown.sku' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
t.snapshot(actual, 'should map product for whitelist/override/unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should standardize RC subscriber response and upsert subscription with observability fields', async t => {
|
||||||
|
const { webhook, collectEvents, mockSub } = t.context;
|
||||||
|
|
||||||
|
const subscriber = mockSub([
|
||||||
|
{
|
||||||
|
identifier: 'Pro',
|
||||||
|
isTrial: false,
|
||||||
|
isActive: true,
|
||||||
|
latestPurchaseDate: new Date('2025-01-01T00:00:00.000Z'),
|
||||||
|
expirationDate: new Date('2026-01-01T00:00:00.000Z'),
|
||||||
|
productId: 'app.affine.pro.Annual',
|
||||||
|
store: 'app_store',
|
||||||
|
willRenew: true,
|
||||||
|
duration: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await webhook.onWebhook({
|
||||||
|
appUserId: user.id,
|
||||||
|
event: {
|
||||||
|
id: 'evt_1',
|
||||||
|
environment: 'PRODUCTION',
|
||||||
|
app_id: 'app.affine.pro',
|
||||||
|
type: 'INITIAL_PURCHASE',
|
||||||
|
store: 'app_store',
|
||||||
|
original_transaction_id: 'orig-tx-1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { activatedCount, canceledCount, events } = collectEvents();
|
||||||
|
|
||||||
|
const record = await t.context.db.subscription.findUnique({
|
||||||
|
where: { targetId_plan: { targetId: user.id, plan: 'pro' } },
|
||||||
|
select: {
|
||||||
|
provider: true,
|
||||||
|
iapStore: true,
|
||||||
|
rcEntitlement: true,
|
||||||
|
rcProductId: true,
|
||||||
|
rcExternalRef: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
t.snapshot(
|
||||||
|
{
|
||||||
|
subscriberCount: subscriber.getCalls()?.length || 0,
|
||||||
|
activatedCount,
|
||||||
|
canceledCount,
|
||||||
|
lastActivated: omit(
|
||||||
|
events['user.subscription.activated']?.slice(-1)?.[0],
|
||||||
|
'userId'
|
||||||
|
),
|
||||||
|
dbObservability: record,
|
||||||
|
},
|
||||||
|
'should standardize payload and have events'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should process expiration/refund by deleting subscription and emitting canceled', async t => {
|
||||||
|
const { db, collectEvents, mockSub, triggerWebhook } = t.context;
|
||||||
|
|
||||||
|
await db.subscription.create({
|
||||||
|
data: {
|
||||||
|
targetId: user.id,
|
||||||
|
plan: 'pro',
|
||||||
|
status: 'active',
|
||||||
|
provider: 'revenuecat',
|
||||||
|
recurring: 'annual',
|
||||||
|
start: new Date('2025-01-01T00:00:00.000Z'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const subscriber = mockSub([
|
||||||
|
{
|
||||||
|
identifier: 'Pro',
|
||||||
|
isTrial: false,
|
||||||
|
isActive: false,
|
||||||
|
latestPurchaseDate: new Date('2024-01-01T00:00:00.000Z'),
|
||||||
|
expirationDate: new Date('2024-02-01T00:00:00.000Z'),
|
||||||
|
productId: 'app.affine.pro.Annual',
|
||||||
|
store: 'app_store',
|
||||||
|
willRenew: false,
|
||||||
|
duration: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await triggerWebhook(user.id, {
|
||||||
|
id: 'evt_2',
|
||||||
|
type: 'EXPIRATION',
|
||||||
|
store: 'app_store',
|
||||||
|
original_transaction_id: 'orig-tx-2',
|
||||||
|
});
|
||||||
|
|
||||||
|
const finalDBCount = await db.subscription.count({
|
||||||
|
where: { targetId: user.id, plan: 'pro' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { activatedCount, canceledCount, events } = collectEvents();
|
||||||
|
t.snapshot(
|
||||||
|
{
|
||||||
|
finalDBCount,
|
||||||
|
subscriberCount: subscriber.getCalls()?.length || 0,
|
||||||
|
activatedCount,
|
||||||
|
canceledCount,
|
||||||
|
lastCanceled: omit(
|
||||||
|
events['user.subscription.canceled']?.slice(-1)?.[0],
|
||||||
|
'userId'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
'should process expiration/refund and emit canceled'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should enqueue per-user reconciliation jobs for existing RC active/trialing/past_due subscriptions', async t => {
|
||||||
|
const { module, db } = t.context;
|
||||||
|
|
||||||
|
const cron = module.get(SubscriptionCronJobs);
|
||||||
|
|
||||||
|
const common = { provider: 'revenuecat', start: new Date() } as const;
|
||||||
|
await db.subscription.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
targetId: 'u1',
|
||||||
|
plan: 'pro',
|
||||||
|
status: 'active',
|
||||||
|
recurring: 'monthly',
|
||||||
|
...common,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetId: 'u2',
|
||||||
|
plan: 'ai',
|
||||||
|
status: 'trialing',
|
||||||
|
recurring: 'annual',
|
||||||
|
...common,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
targetId: 'u1',
|
||||||
|
plan: 'ai',
|
||||||
|
status: 'past_due',
|
||||||
|
recurring: 'monthly',
|
||||||
|
...common,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await cron.reconcileRevenueCatSubscriptions();
|
||||||
|
|
||||||
|
const calls = module.queue.add.getCalls().map(c => ({
|
||||||
|
name: c.args[0],
|
||||||
|
payload: c.args[1],
|
||||||
|
opts: c.args[2],
|
||||||
|
}));
|
||||||
|
t.snapshot(
|
||||||
|
{
|
||||||
|
queued: calls,
|
||||||
|
uniqueJobCount: calls.filter(
|
||||||
|
c => c.name === 'nightly.revenuecat.syncUser'
|
||||||
|
).length,
|
||||||
|
},
|
||||||
|
'should enqueue per-user RC reconciliation jobs (deduplicated by userId)'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should activate subscriptions via webhook for whitelisted products across stores (iOS/Android)', async t => {
|
||||||
|
const { db, event, collectEvents, mockSubSeq, triggerWebhook } = t.context;
|
||||||
|
|
||||||
|
const scenarios = [
|
||||||
|
{
|
||||||
|
name: 'Pro monthly on iOS',
|
||||||
|
stub: [
|
||||||
|
{
|
||||||
|
identifier: 'Pro',
|
||||||
|
isTrial: false,
|
||||||
|
isActive: true,
|
||||||
|
latestPurchaseDate: new Date('2025-01-10T00:00:00.000Z'),
|
||||||
|
expirationDate: new Date('2025-02-10T00:00:00.000Z'),
|
||||||
|
productId: 'app.affine.pro.Monthly',
|
||||||
|
store: 'app_store' as const,
|
||||||
|
willRenew: true,
|
||||||
|
duration: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
event: {
|
||||||
|
id: 'evt_ios_1',
|
||||||
|
type: 'INITIAL_PURCHASE',
|
||||||
|
store: 'app_store',
|
||||||
|
original_transaction_id: 'orig-ios-1',
|
||||||
|
},
|
||||||
|
expectedPlan: 'pro' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'AI annual on Android',
|
||||||
|
stub: [
|
||||||
|
{
|
||||||
|
identifier: 'AI',
|
||||||
|
isTrial: false,
|
||||||
|
isActive: true,
|
||||||
|
latestPurchaseDate: new Date('2025-03-01T00:00:00.000Z'),
|
||||||
|
expirationDate: new Date('2026-03-01T00:00:00.000Z'),
|
||||||
|
productId: 'app.affine.pro.ai.Annual',
|
||||||
|
store: 'play_store' as const,
|
||||||
|
willRenew: true,
|
||||||
|
duration: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
event: {
|
||||||
|
id: 'evt_android_1',
|
||||||
|
type: 'INITIAL_PURCHASE',
|
||||||
|
store: 'play_store',
|
||||||
|
purchase_token: 'token-android-1',
|
||||||
|
},
|
||||||
|
expectedPlan: 'ai' as const,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const results: any[] = [];
|
||||||
|
|
||||||
|
mockSubSeq(scenarios.map(s => s.stub));
|
||||||
|
|
||||||
|
for (const s of scenarios) {
|
||||||
|
// reset event history between scenarios for clean counts
|
||||||
|
event.emit.resetHistory?.();
|
||||||
|
await triggerWebhook(user.id, s.event);
|
||||||
|
const rec = await db.subscription.findUnique({
|
||||||
|
where: { targetId_plan: { targetId: user.id, plan: s.expectedPlan } },
|
||||||
|
select: {
|
||||||
|
plan: true,
|
||||||
|
recurring: true,
|
||||||
|
status: true,
|
||||||
|
provider: true,
|
||||||
|
iapStore: true,
|
||||||
|
rcEntitlement: true,
|
||||||
|
rcProductId: true,
|
||||||
|
rcExternalRef: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { activatedCount } = collectEvents();
|
||||||
|
results.push({ name: s.name, rec, activatedCount });
|
||||||
|
}
|
||||||
|
|
||||||
|
t.snapshot(
|
||||||
|
{ results },
|
||||||
|
'should activate subscriptions via webhook for whitelisted products across stores (iOS/Android)'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should keep active and advance period dates when a trialing subscription renews', async t => {
|
||||||
|
const { db, collectEvents, mockSubSeq, triggerWebhook } = t.context;
|
||||||
|
mockSubSeq([
|
||||||
|
[
|
||||||
|
{
|
||||||
|
identifier: 'Pro',
|
||||||
|
isTrial: false,
|
||||||
|
isActive: true,
|
||||||
|
latestPurchaseDate: new Date('2025-04-01T00:00:00.000Z'),
|
||||||
|
expirationDate: new Date('2025-04-08T00:00:00.000Z'),
|
||||||
|
productId: 'app.affine.pro.Annual',
|
||||||
|
store: 'app_store',
|
||||||
|
willRenew: true,
|
||||||
|
duration: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
identifier: 'Pro',
|
||||||
|
isTrial: false,
|
||||||
|
isActive: true,
|
||||||
|
latestPurchaseDate: new Date('2025-04-08T00:00:00.000Z'),
|
||||||
|
expirationDate: new Date('2026-04-08T00:00:00.000Z'),
|
||||||
|
productId: 'app.affine.pro.Annual',
|
||||||
|
store: 'app_store',
|
||||||
|
willRenew: true,
|
||||||
|
duration: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
await triggerWebhook(user.id, {
|
||||||
|
id: 'evt_trial',
|
||||||
|
type: 'INITIAL_PURCHASE',
|
||||||
|
period_type: 'trial',
|
||||||
|
store: 'app_store',
|
||||||
|
});
|
||||||
|
await triggerWebhook(user.id, {
|
||||||
|
id: 'evt_renew',
|
||||||
|
type: 'RENEWAL',
|
||||||
|
store: 'app_store',
|
||||||
|
});
|
||||||
|
|
||||||
|
const rec = await db.subscription.findUnique({
|
||||||
|
where: { targetId_plan: { targetId: user.id, plan: 'pro' } },
|
||||||
|
select: { status: true, start: true, end: true },
|
||||||
|
});
|
||||||
|
const { activatedCount, canceledCount } = collectEvents();
|
||||||
|
t.snapshot(
|
||||||
|
{ status: rec?.status, activatedCount, canceledCount },
|
||||||
|
'should keep active after trial renewal'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should remove or cancel the record and revoke entitlement when a trialing subscription expires', async t => {
|
||||||
|
const { db, collectEvents, mockSubSeq, triggerWebhook } = t.context;
|
||||||
|
mockSubSeq([
|
||||||
|
[
|
||||||
|
{
|
||||||
|
identifier: 'Pro',
|
||||||
|
isTrial: false,
|
||||||
|
isActive: true,
|
||||||
|
latestPurchaseDate: new Date('2025-04-01T00:00:00.000Z'),
|
||||||
|
expirationDate: new Date('2025-04-08T00:00:00.000Z'),
|
||||||
|
productId: 'app.affine.pro.Annual',
|
||||||
|
store: 'app_store',
|
||||||
|
willRenew: false,
|
||||||
|
duration: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
identifier: 'Pro',
|
||||||
|
isTrial: false,
|
||||||
|
isActive: false,
|
||||||
|
latestPurchaseDate: new Date('2025-04-01T00:00:00.000Z'),
|
||||||
|
expirationDate: new Date('2024-01-01T00:00:00.000Z'),
|
||||||
|
productId: 'app.affine.pro.Annual',
|
||||||
|
store: 'app_store',
|
||||||
|
willRenew: false,
|
||||||
|
duration: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
await triggerWebhook(user.id, {
|
||||||
|
id: 'evt_trial2',
|
||||||
|
type: 'INITIAL_PURCHASE',
|
||||||
|
period_type: 'trial',
|
||||||
|
store: 'app_store',
|
||||||
|
});
|
||||||
|
await triggerWebhook(user.id, {
|
||||||
|
id: 'evt_expire_trial',
|
||||||
|
type: 'EXPIRATION',
|
||||||
|
store: 'app_store',
|
||||||
|
});
|
||||||
|
|
||||||
|
const finalDBCount = await db.subscription.count({
|
||||||
|
where: { targetId: user.id, plan: 'pro' },
|
||||||
|
});
|
||||||
|
const { canceledCount } = collectEvents();
|
||||||
|
t.snapshot({ finalDBCount, canceledCount }, 'should remove record');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should set canceledAt and keep active until expiration when will_renew is false (cancellation before period end)', async t => {
|
||||||
|
const { db, collectEvents, mockSub, triggerWebhook } = t.context;
|
||||||
|
mockSub([
|
||||||
|
{
|
||||||
|
identifier: 'Pro',
|
||||||
|
isTrial: false,
|
||||||
|
isActive: true,
|
||||||
|
latestPurchaseDate: new Date('2025-05-01T00:00:00.000Z'),
|
||||||
|
expirationDate: new Date('2025-06-01T00:00:00.000Z'),
|
||||||
|
productId: 'app.affine.pro.Annual',
|
||||||
|
store: 'app_store',
|
||||||
|
willRenew: false,
|
||||||
|
duration: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await triggerWebhook(user.id, {
|
||||||
|
id: 'evt_cancel_before_end',
|
||||||
|
type: 'CANCELLATION',
|
||||||
|
store: 'app_store',
|
||||||
|
});
|
||||||
|
const rec = await db.subscription.findUnique({
|
||||||
|
where: { targetId_plan: { targetId: user.id, plan: 'pro' } },
|
||||||
|
select: { status: true, canceledAt: true },
|
||||||
|
});
|
||||||
|
const { activatedCount, canceledCount } = collectEvents();
|
||||||
|
t.snapshot(
|
||||||
|
{
|
||||||
|
status: rec?.status,
|
||||||
|
hasCanceledAt: !!rec?.canceledAt,
|
||||||
|
activatedCount,
|
||||||
|
canceledCount,
|
||||||
|
},
|
||||||
|
'should keep active until period end when will_renew is false'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should retain record as past_due (inactive but not expired) and NOT emit canceled event', async t => {
|
||||||
|
const { db, collectEvents, mockSub, triggerWebhook } = t.context;
|
||||||
|
mockSub([
|
||||||
|
{
|
||||||
|
identifier: 'Pro',
|
||||||
|
isTrial: false,
|
||||||
|
isActive: false,
|
||||||
|
latestPurchaseDate: new Date('2025-05-01T00:00:00.000Z'),
|
||||||
|
expirationDate: new Date('2999-01-01T00:00:00.000Z'),
|
||||||
|
productId: 'app.affine.pro.Annual',
|
||||||
|
store: 'app_store',
|
||||||
|
willRenew: true,
|
||||||
|
duration: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await triggerWebhook(user.id, {
|
||||||
|
id: 'evt_pastdue',
|
||||||
|
type: 'BILLING_ISSUE',
|
||||||
|
store: 'app_store',
|
||||||
|
});
|
||||||
|
|
||||||
|
const rec = await db.subscription.findUnique({
|
||||||
|
where: { targetId_plan: { targetId: user.id, plan: 'pro' } },
|
||||||
|
select: { status: true },
|
||||||
|
});
|
||||||
|
const { canceledCount } = collectEvents();
|
||||||
|
t.snapshot(
|
||||||
|
{ status: rec?.status, canceledCount },
|
||||||
|
'should retain past_due record and NOT emit canceled event'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should block checkout when an existing subscription of the same plan is active', async t => {
|
||||||
|
const { module, db } = t.context;
|
||||||
|
|
||||||
|
const manager = module.get(UserSubscriptionManager);
|
||||||
|
|
||||||
|
{
|
||||||
|
await db.subscription.create({
|
||||||
|
data: {
|
||||||
|
targetId: user.id,
|
||||||
|
plan: 'pro',
|
||||||
|
status: 'active',
|
||||||
|
provider: 'revenuecat',
|
||||||
|
recurring: 'monthly',
|
||||||
|
start: new Date('2025-01-01T00:00:00.000Z'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.throwsAsync(
|
||||||
|
manager.checkout(
|
||||||
|
{
|
||||||
|
plan: SubscriptionPlan.Pro,
|
||||||
|
recurring: SubscriptionRecurring.Monthly,
|
||||||
|
variant: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
successCallbackLink: '/',
|
||||||
|
plan: SubscriptionPlan.Pro,
|
||||||
|
recurring: SubscriptionRecurring.Monthly,
|
||||||
|
},
|
||||||
|
{ user: { id: user.id, email: user.email } }
|
||||||
|
),
|
||||||
|
{ instanceOf: ManagedByAppStoreOrPlay }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
await db.subscription.update({
|
||||||
|
where: { targetId_plan: { targetId: user.id, plan: 'pro' } },
|
||||||
|
data: { provider: 'stripe' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await t.throwsAsync(
|
||||||
|
() =>
|
||||||
|
manager.checkout(
|
||||||
|
{
|
||||||
|
plan: SubscriptionPlan.Pro,
|
||||||
|
recurring: SubscriptionRecurring.Monthly,
|
||||||
|
variant: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
successCallbackLink: '/',
|
||||||
|
plan: SubscriptionPlan.Pro,
|
||||||
|
recurring: SubscriptionRecurring.Monthly,
|
||||||
|
},
|
||||||
|
{ user: { id: user.id, email: user.email } }
|
||||||
|
),
|
||||||
|
{ instanceOf: SubscriptionAlreadyExists }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should skip RC upsert when Stripe active already exists for same plan', async t => {
|
||||||
|
const { db, collectEvents, mockSub, triggerWebhook } = t.context;
|
||||||
|
await db.subscription.create({
|
||||||
|
data: {
|
||||||
|
targetId: user.id,
|
||||||
|
plan: 'pro',
|
||||||
|
status: 'active',
|
||||||
|
provider: 'stripe',
|
||||||
|
recurring: 'monthly',
|
||||||
|
start: new Date('2025-01-01T00:00:00.000Z'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
mockSub([
|
||||||
|
{
|
||||||
|
identifier: 'Pro',
|
||||||
|
isTrial: false,
|
||||||
|
isActive: true,
|
||||||
|
latestPurchaseDate: new Date('2025-06-01T00:00:00.000Z'),
|
||||||
|
expirationDate: new Date('2025-07-01T00:00:00.000Z'),
|
||||||
|
productId: 'app.affine.pro.Monthly',
|
||||||
|
store: 'app_store',
|
||||||
|
willRenew: true,
|
||||||
|
duration: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await triggerWebhook(user.id, {
|
||||||
|
id: 'evt_conflict',
|
||||||
|
type: 'INITIAL_PURCHASE',
|
||||||
|
store: 'app_store',
|
||||||
|
});
|
||||||
|
|
||||||
|
const rcRec = await db.subscription.findFirst({
|
||||||
|
where: { targetId: user.id, plan: 'pro', provider: 'revenuecat' },
|
||||||
|
});
|
||||||
|
const { activatedCount } = collectEvents();
|
||||||
|
t.snapshot(
|
||||||
|
{ hasRCRecord: !!rcRec, activatedCount },
|
||||||
|
'should skip RC upsert when Stripe active already exists'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should block read-write ops on revenuecat-managed record (cancel/resume/updateRecurring)', async t => {
|
||||||
|
const { db, service } = t.context;
|
||||||
|
await db.subscription.create({
|
||||||
|
data: {
|
||||||
|
targetId: user.id,
|
||||||
|
plan: 'pro',
|
||||||
|
status: 'active',
|
||||||
|
provider: 'revenuecat',
|
||||||
|
recurring: 'monthly',
|
||||||
|
start: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// local helper used multiple times within this test
|
||||||
|
const expectManaged = async (fn: () => Promise<any>) =>
|
||||||
|
t.throwsAsync(() => fn(), { instanceOf: ManagedByAppStoreOrPlay });
|
||||||
|
|
||||||
|
await expectManaged(() =>
|
||||||
|
service.cancelSubscription({ plan: SubscriptionPlan.Pro, userId: user.id })
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectManaged(() =>
|
||||||
|
service.resumeSubscription({ plan: SubscriptionPlan.Pro, userId: user.id })
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectManaged(() =>
|
||||||
|
service.updateSubscriptionRecurring(
|
||||||
|
{ plan: SubscriptionPlan.Pro, userId: user.id },
|
||||||
|
SubscriptionRecurring.Yearly
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reconcile and fix missing or out-of-order states for revenuecat Active/Trialing/PastDue records', async t => {
|
||||||
|
const { webhook, collectEvents, mockSub } = t.context;
|
||||||
|
|
||||||
|
const subscriber = mockSub([
|
||||||
|
{
|
||||||
|
identifier: 'Pro',
|
||||||
|
isTrial: false,
|
||||||
|
isActive: true,
|
||||||
|
latestPurchaseDate: new Date('2025-03-01T00:00:00.000Z'),
|
||||||
|
expirationDate: new Date('2026-03-01T00:00:00.000Z'),
|
||||||
|
productId: 'app.affine.pro.Annual',
|
||||||
|
store: 'play_store',
|
||||||
|
willRenew: true,
|
||||||
|
duration: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await webhook.syncAppUser(user.id);
|
||||||
|
const { activatedCount, canceledCount } = collectEvents();
|
||||||
|
const subscriberCount = subscriber.getCalls()?.length || 0;
|
||||||
|
|
||||||
|
t.snapshot(
|
||||||
|
{ subscriberCount, activatedCount, canceledCount },
|
||||||
|
'should reconcile and fix missing or out-of-order states for revenuecat records'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should treat refund as early expiration and revoke immediately', async t => {
|
||||||
|
const { db, collectEvents, mockSub, triggerWebhook } = t.context;
|
||||||
|
|
||||||
|
await db.subscription.create({
|
||||||
|
data: {
|
||||||
|
targetId: user.id,
|
||||||
|
plan: 'pro',
|
||||||
|
status: 'active',
|
||||||
|
provider: 'revenuecat',
|
||||||
|
recurring: 'monthly',
|
||||||
|
start: new Date('2025-01-01T00:00:00.000Z'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
mockSub([
|
||||||
|
{
|
||||||
|
identifier: 'Pro',
|
||||||
|
isTrial: false,
|
||||||
|
isActive: false,
|
||||||
|
latestPurchaseDate: new Date('2025-01-01T00:00:00.000Z'),
|
||||||
|
expirationDate: new Date('2025-01-15T00:00:00.000Z'),
|
||||||
|
productId: 'app.affine.pro.Monthly',
|
||||||
|
store: 'app_store',
|
||||||
|
willRenew: false,
|
||||||
|
duration: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await triggerWebhook(user.id, {
|
||||||
|
id: 'evt_refund',
|
||||||
|
type: 'CANCELLATION',
|
||||||
|
store: 'app_store',
|
||||||
|
});
|
||||||
|
|
||||||
|
const count = await db.subscription.count({
|
||||||
|
where: { targetId: user.id, plan: 'pro' },
|
||||||
|
});
|
||||||
|
const { canceledCount } = collectEvents();
|
||||||
|
t.snapshot(
|
||||||
|
{ finalDBCount: count, canceledCount },
|
||||||
|
'should delete record and emit canceled on refund'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should ignore non-whitelisted productId and not write to DB', async t => {
|
||||||
|
const { db, collectEvents, mockSub, triggerWebhook } = t.context;
|
||||||
|
mockSub([
|
||||||
|
{
|
||||||
|
identifier: 'Weird',
|
||||||
|
isTrial: false,
|
||||||
|
isActive: true,
|
||||||
|
latestPurchaseDate: new Date('2025-07-01T00:00:00.000Z'),
|
||||||
|
expirationDate: new Date('2026-07-01T00:00:00.000Z'),
|
||||||
|
productId: 'unknown.sku',
|
||||||
|
store: 'app_store',
|
||||||
|
willRenew: true,
|
||||||
|
duration: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await triggerWebhook(user.id, {
|
||||||
|
id: 'evt_unknown',
|
||||||
|
type: 'INITIAL_PURCHASE',
|
||||||
|
store: 'app_store',
|
||||||
|
});
|
||||||
|
const dbCount = await db.subscription.count({ where: { targetId: user.id } });
|
||||||
|
const { activatedCount, canceledCount } = collectEvents();
|
||||||
|
t.snapshot(
|
||||||
|
{ dbCount, activatedCount, canceledCount },
|
||||||
|
'should ignore non-whitelisted productId and not write to DB'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should map via entitlement+duration when productId not whitelisted (P1M/P1Y only)', async t => {
|
||||||
|
const { db, collectEvents, mockSubSeq, triggerWebhook } = t.context;
|
||||||
|
|
||||||
|
mockSubSeq([
|
||||||
|
[
|
||||||
|
{
|
||||||
|
identifier: 'Pro',
|
||||||
|
isTrial: false,
|
||||||
|
isActive: true,
|
||||||
|
latestPurchaseDate: new Date('2025-08-01T00:00:00.000Z'),
|
||||||
|
expirationDate: new Date('2025-09-01T00:00:00.000Z'),
|
||||||
|
productId: 'unknown.sku',
|
||||||
|
store: 'app_store',
|
||||||
|
willRenew: true,
|
||||||
|
duration: 'P1M',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
identifier: 'AI',
|
||||||
|
isTrial: false,
|
||||||
|
isActive: true,
|
||||||
|
latestPurchaseDate: new Date('2025-10-01T00:00:00.000Z'),
|
||||||
|
expirationDate: new Date('2026-10-01T00:00:00.000Z'),
|
||||||
|
productId: 'unknown.sku',
|
||||||
|
store: 'play_store',
|
||||||
|
willRenew: true,
|
||||||
|
duration: 'P1Y',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
identifier: 'Pro',
|
||||||
|
isTrial: false,
|
||||||
|
isActive: true,
|
||||||
|
latestPurchaseDate: new Date('2025-11-01T00:00:00.000Z'),
|
||||||
|
expirationDate: new Date('2026-02-01T00:00:00.000Z'),
|
||||||
|
productId: 'unknown.sku',
|
||||||
|
store: 'app_store',
|
||||||
|
willRenew: true,
|
||||||
|
duration: 'P3M', // not supported -> ignore
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// pro monthly via fallback
|
||||||
|
await triggerWebhook(user.id, {
|
||||||
|
id: 'evt_fb1',
|
||||||
|
type: 'INITIAL_PURCHASE',
|
||||||
|
store: 'app_store',
|
||||||
|
});
|
||||||
|
const r1 = await db.subscription.findUnique({
|
||||||
|
where: { targetId_plan: { targetId: user.id, plan: 'pro' } },
|
||||||
|
select: { plan: true, recurring: true, provider: true },
|
||||||
|
});
|
||||||
|
const s1 = collectEvents();
|
||||||
|
|
||||||
|
// ai yearly via fallback
|
||||||
|
await triggerWebhook(user.id, {
|
||||||
|
id: 'evt_fb2',
|
||||||
|
type: 'INITIAL_PURCHASE',
|
||||||
|
store: 'play_store',
|
||||||
|
});
|
||||||
|
const r2 = await db.subscription.findUnique({
|
||||||
|
where: { targetId_plan: { targetId: user.id, plan: 'ai' } },
|
||||||
|
select: { plan: true, recurring: true, provider: true },
|
||||||
|
});
|
||||||
|
const s2 = collectEvents();
|
||||||
|
|
||||||
|
// unsupported duration ignored
|
||||||
|
await triggerWebhook(user.id, {
|
||||||
|
id: 'evt_fb3',
|
||||||
|
type: 'INITIAL_PURCHASE',
|
||||||
|
store: 'app_store',
|
||||||
|
});
|
||||||
|
const count = await db.subscription.count({ where: { targetId: user.id } });
|
||||||
|
const s3 = collectEvents();
|
||||||
|
|
||||||
|
t.snapshot(
|
||||||
|
{
|
||||||
|
proViaFallback: r1,
|
||||||
|
aiViaFallback: r2,
|
||||||
|
totalCount: count,
|
||||||
|
eventsCounts: {
|
||||||
|
afterFirst: { a: s1.activatedCount, c: s1.canceledCount },
|
||||||
|
afterSecond: { a: s2.activatedCount, c: s2.canceledCount },
|
||||||
|
afterThird: { a: s3.activatedCount, c: s3.canceledCount },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'should map via entitlement+duration fallback and ignore unsupported durations'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not dispatch webhook event when authorization header is missing or mismatched', async t => {
|
||||||
|
const { controller, event } = t.context;
|
||||||
|
const before = event.emitAsync.getCalls()?.length || 0;
|
||||||
|
const e = { id: '42', type: 'INITIAL_PURCHASE', app_user_id: user.id };
|
||||||
|
await controller.handleWebhook({ body: { event: e } } as any, undefined);
|
||||||
|
const after = event.emitAsync.getCalls()?.length || 0;
|
||||||
|
t.is(after - before, 0, 'should not emit event');
|
||||||
|
});
|
||||||
@@ -192,8 +192,10 @@ test.before(async t => {
|
|||||||
payment: {
|
payment: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
showLifetimePrice: true,
|
showLifetimePrice: true,
|
||||||
apiKey: '1',
|
stripe: {
|
||||||
webhookKey: '1',
|
apiKey: '1',
|
||||||
|
webhookKey: '1',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
AppModule,
|
AppModule,
|
||||||
|
|||||||
@@ -637,6 +637,11 @@ export const USER_FRIENDLY_ERRORS = {
|
|||||||
type: 'invalid_input',
|
type: 'invalid_input',
|
||||||
message: 'Workspace id is required to update team subscription.',
|
message: 'Workspace id is required to update team subscription.',
|
||||||
},
|
},
|
||||||
|
managed_by_app_store_or_play: {
|
||||||
|
type: 'action_forbidden',
|
||||||
|
message:
|
||||||
|
'This subscription is managed by App Store or Google Play. Please manage it in the corresponding store.',
|
||||||
|
},
|
||||||
|
|
||||||
// Copilot errors
|
// Copilot errors
|
||||||
copilot_session_not_found: {
|
copilot_session_not_found: {
|
||||||
|
|||||||
@@ -651,6 +651,12 @@ export class WorkspaceIdRequiredToUpdateTeamSubscription extends UserFriendlyErr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ManagedByAppStoreOrPlay extends UserFriendlyError {
|
||||||
|
constructor(message?: string) {
|
||||||
|
super('action_forbidden', 'managed_by_app_store_or_play', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class CopilotSessionNotFound extends UserFriendlyError {
|
export class CopilotSessionNotFound extends UserFriendlyError {
|
||||||
constructor(message?: string) {
|
constructor(message?: string) {
|
||||||
super('resource_not_found', 'copilot_session_not_found', message);
|
super('resource_not_found', 'copilot_session_not_found', message);
|
||||||
@@ -1189,6 +1195,7 @@ export enum ErrorNames {
|
|||||||
CANT_UPDATE_ONETIME_PAYMENT_SUBSCRIPTION,
|
CANT_UPDATE_ONETIME_PAYMENT_SUBSCRIPTION,
|
||||||
WORKSPACE_ID_REQUIRED_FOR_TEAM_SUBSCRIPTION,
|
WORKSPACE_ID_REQUIRED_FOR_TEAM_SUBSCRIPTION,
|
||||||
WORKSPACE_ID_REQUIRED_TO_UPDATE_TEAM_SUBSCRIPTION,
|
WORKSPACE_ID_REQUIRED_TO_UPDATE_TEAM_SUBSCRIPTION,
|
||||||
|
MANAGED_BY_APP_STORE_OR_PLAY,
|
||||||
COPILOT_SESSION_NOT_FOUND,
|
COPILOT_SESSION_NOT_FOUND,
|
||||||
COPILOT_SESSION_INVALID_INPUT,
|
COPILOT_SESSION_INVALID_INPUT,
|
||||||
COPILOT_SESSION_DELETED,
|
COPILOT_SESSION_DELETED,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Global, Module } from '@nestjs/common';
|
|||||||
import { GraphQLModule } from '@nestjs/graphql';
|
import { GraphQLModule } from '@nestjs/graphql';
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
|
|
||||||
|
import { NodeEnv } from '../../env';
|
||||||
import { Config } from '../config';
|
import { Config } from '../config';
|
||||||
import { mapAnyError } from '../nestjs/exception';
|
import { mapAnyError } from '../nestjs/exception';
|
||||||
import { GQLLoggerPlugin } from './logger-plugin';
|
import { GQLLoggerPlugin } from './logger-plugin';
|
||||||
@@ -30,7 +31,7 @@ export type GraphqlContext = {
|
|||||||
numberScalarMode: 'integer',
|
numberScalarMode: 'integer',
|
||||||
},
|
},
|
||||||
useGlobalPrefix: true,
|
useGlobalPrefix: true,
|
||||||
playground: true,
|
graphiql: env.NODE_ENV === NodeEnv.Development,
|
||||||
sortSchema: true,
|
sortSchema: true,
|
||||||
autoSchemaFile: join(
|
autoSchemaFile: join(
|
||||||
env.projectRoot,
|
env.projectRoot,
|
||||||
|
|||||||
@@ -50,6 +50,13 @@ export class AccessTokenResolver {
|
|||||||
return await this.models.accessToken.list(user.id);
|
return await this.models.accessToken.list(user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Query(() => [RevealedAccessToken])
|
||||||
|
async revealedAccessTokens(
|
||||||
|
@CurrentUser() user: CurrentUser
|
||||||
|
): Promise<RevealedAccessToken[]> {
|
||||||
|
return await this.models.accessToken.list(user.id, true);
|
||||||
|
}
|
||||||
|
|
||||||
@Mutation(() => RevealedAccessToken)
|
@Mutation(() => RevealedAccessToken)
|
||||||
async generateUserAccessToken(
|
async generateUserAccessToken(
|
||||||
@CurrentUser() user: CurrentUser,
|
@CurrentUser() user: CurrentUser,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface AuthConfig {
|
|||||||
ttr: number;
|
ttr: number;
|
||||||
};
|
};
|
||||||
allowSignup: boolean;
|
allowSignup: boolean;
|
||||||
|
allowSignupForOauth: boolean;
|
||||||
requireEmailDomainVerification: boolean;
|
requireEmailDomainVerification: boolean;
|
||||||
requireEmailVerification: boolean;
|
requireEmailVerification: boolean;
|
||||||
passwordRequirements: ConfigItem<{
|
passwordRequirements: ConfigItem<{
|
||||||
@@ -27,6 +28,10 @@ defineModuleConfig('auth', {
|
|||||||
desc: 'Whether allow new registrations.',
|
desc: 'Whether allow new registrations.',
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
|
allowSignupForOauth: {
|
||||||
|
desc: 'Whether allow new registrations via configured oauth.',
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
requireEmailDomainVerification: {
|
requireEmailDomainVerification: {
|
||||||
desc: 'Whether require email domain record verification before accessing restricted resources.',
|
desc: 'Whether require email domain record verification before accessing restricted resources.',
|
||||||
default: false,
|
default: false,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ declare global {
|
|||||||
interface AppConfigSchema {
|
interface AppConfigSchema {
|
||||||
mailer: {
|
mailer: {
|
||||||
SMTP: {
|
SMTP: {
|
||||||
|
name: string;
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -16,6 +17,7 @@ declare global {
|
|||||||
|
|
||||||
fallbackDomains: ConfigItem<string[]>;
|
fallbackDomains: ConfigItem<string[]>;
|
||||||
fallbackSMTP: {
|
fallbackSMTP: {
|
||||||
|
name: string;
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -28,6 +30,11 @@ declare global {
|
|||||||
}
|
}
|
||||||
|
|
||||||
defineModuleConfig('mailer', {
|
defineModuleConfig('mailer', {
|
||||||
|
'SMTP.name': {
|
||||||
|
desc: 'Name of the email server (e.g. your domain name)',
|
||||||
|
default: 'AFFiNE Server',
|
||||||
|
env: 'MAILER_SERVERNAME',
|
||||||
|
},
|
||||||
'SMTP.host': {
|
'SMTP.host': {
|
||||||
desc: 'Host of the email server (e.g. smtp.gmail.com)',
|
desc: 'Host of the email server (e.g. smtp.gmail.com)',
|
||||||
default: '',
|
default: '',
|
||||||
@@ -64,6 +71,10 @@ defineModuleConfig('mailer', {
|
|||||||
default: [],
|
default: [],
|
||||||
shape: z.array(z.string()),
|
shape: z.array(z.string()),
|
||||||
},
|
},
|
||||||
|
'fallbackSMTP.name': {
|
||||||
|
desc: 'Name of the fallback email server (e.g. your domain name)',
|
||||||
|
default: 'AFFiNE Server',
|
||||||
|
},
|
||||||
'fallbackSMTP.host': {
|
'fallbackSMTP.host': {
|
||||||
desc: 'Host of the email server (e.g. smtp.gmail.com)',
|
desc: 'Host of the email server (e.g. smtp.gmail.com)',
|
||||||
default: '',
|
default: '',
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ function configToSMTPOptions(
|
|||||||
config: AppConfig['mailer']['SMTP']
|
config: AppConfig['mailer']['SMTP']
|
||||||
): SMTPTransport.Options {
|
): SMTPTransport.Options {
|
||||||
return {
|
return {
|
||||||
|
name: config.name,
|
||||||
host: config.host,
|
host: config.host,
|
||||||
port: config.port,
|
port: config.port,
|
||||||
tls: {
|
tls: {
|
||||||
|
|||||||
@@ -15,13 +15,14 @@ export class AccessTokenModel extends BaseModel {
|
|||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
async list(userId: string) {
|
async list(userId: string, revealed: boolean = false) {
|
||||||
return await this.db.accessToken.findMany({
|
return await this.db.accessToken.findMany({
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
expiresAt: true,
|
expiresAt: true,
|
||||||
|
token: revealed,
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
@@ -459,27 +459,19 @@ export class CopilotSessionModel extends BaseModel {
|
|||||||
docId: options.docId,
|
docId: options.docId,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
},
|
},
|
||||||
select: { id: true, prompt: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const sessionIds = sessions.map(({ id }) => id);
|
const sessionIds = sessions.map(({ id }) => id);
|
||||||
// cleanup all messages
|
// cleanup all messages
|
||||||
await this.db.aiSessionMessage.deleteMany({
|
await this.db.aiSessionMessage.deleteMany({
|
||||||
where: { sessionId: { in: sessionIds } },
|
where: { sessionId: { in: sessionIds } },
|
||||||
});
|
});
|
||||||
|
|
||||||
// only mark action session as deleted
|
await this.db.aiSession.updateMany({
|
||||||
// chat session always can be reuse
|
where: { id: { in: sessionIds } },
|
||||||
const actionIds = sessions
|
data: { pinned: false, deletedAt: new Date() },
|
||||||
.filter(({ prompt }) => !!prompt.action)
|
});
|
||||||
.map(({ id }) => id);
|
|
||||||
|
|
||||||
// 标记 action session 为已删除
|
|
||||||
if (actionIds.length > 0) {
|
|
||||||
await this.db.aiSession.updateMany({
|
|
||||||
where: { id: { in: actionIds } },
|
|
||||||
data: { pinned: false, deletedAt: new Date() },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return sessionIds;
|
return sessionIds;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1930,16 +1930,9 @@ Now apply the \`updates\` to the \`content\`, following the intent in \`op\`, an
|
|||||||
const CHAT_PROMPT: Omit<Prompt, 'name'> = {
|
const CHAT_PROMPT: Omit<Prompt, 'name'> = {
|
||||||
model: 'gemini-2.5-flash',
|
model: 'gemini-2.5-flash',
|
||||||
optionalModels: [
|
optionalModels: [
|
||||||
'gpt-4.1',
|
|
||||||
'gpt-5',
|
|
||||||
'o3',
|
|
||||||
'o4-mini',
|
|
||||||
'gemini-2.5-flash',
|
'gemini-2.5-flash',
|
||||||
'gemini-2.5-pro',
|
'gemini-2.5-pro',
|
||||||
'claude-opus-4@20250514',
|
|
||||||
'claude-sonnet-4@20250514',
|
'claude-sonnet-4@20250514',
|
||||||
'claude-3-7-sonnet@20250219',
|
|
||||||
'claude-3-5-sonnet-v2@20241022',
|
|
||||||
],
|
],
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
@@ -2099,13 +2092,7 @@ Below is the user's query. Please respond in the user's preferred language witho
|
|||||||
'codeArtifact',
|
'codeArtifact',
|
||||||
'blobRead',
|
'blobRead',
|
||||||
],
|
],
|
||||||
proModels: [
|
proModels: ['gemini-2.5-pro', 'claude-sonnet-4@20250514'],
|
||||||
'gemini-2.5-pro',
|
|
||||||
'claude-opus-4@20250514',
|
|
||||||
'claude-sonnet-4@20250514',
|
|
||||||
'claude-3-7-sonnet@20250219',
|
|
||||||
'claude-3-5-sonnet-v2@20241022',
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ export class OAuthController {
|
|||||||
return connectedAccount.user;
|
return connectedAccount.user;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.config.auth.allowSignup) {
|
if (!this.config.auth.allowSignupForOauth) {
|
||||||
throw new SignUpForbidden();
|
throw new SignUpForbidden();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ export interface PaymentStartupConfig {
|
|||||||
webhookKey: string;
|
webhookKey: string;
|
||||||
};
|
};
|
||||||
} & Stripe.StripeConfig;
|
} & Stripe.StripeConfig;
|
||||||
|
revenuecat?: {
|
||||||
|
apiKey?: string;
|
||||||
|
webhookAuth?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
environment?: 'sandbox' | 'production';
|
||||||
|
productMap?: Record<string, { plan: string; recurring: string }>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaymentRuntimeConfig {
|
export interface PaymentRuntimeConfig {
|
||||||
@@ -20,9 +27,36 @@ declare global {
|
|||||||
payment: {
|
payment: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
showLifetimePrice: boolean;
|
showLifetimePrice: boolean;
|
||||||
|
/**
|
||||||
|
* @deprecated use payment.stripe.apiKey
|
||||||
|
*/
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
|
/**
|
||||||
|
* @deprecated use payment.stripe.webhookKey
|
||||||
|
*/
|
||||||
webhookKey: string;
|
webhookKey: string;
|
||||||
stripe: ConfigItem<{} & Stripe.StripeConfig>;
|
stripe: ConfigItem<
|
||||||
|
{
|
||||||
|
/** Preferred place for Stripe API key */
|
||||||
|
apiKey?: string;
|
||||||
|
/** Preferred place for Stripe Webhook key */
|
||||||
|
webhookKey?: string;
|
||||||
|
} & Stripe.StripeConfig
|
||||||
|
>;
|
||||||
|
revenuecat: ConfigItem<{
|
||||||
|
/** Whether enable RevenueCat integration */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** RevenueCat REST API Key */
|
||||||
|
apiKey?: string;
|
||||||
|
/** RevenueCat Project Id */
|
||||||
|
projectId?: string;
|
||||||
|
/** Authorization header value required by webhook */
|
||||||
|
webhookAuth?: string;
|
||||||
|
/** RC environment */
|
||||||
|
environment?: 'sandbox' | 'production';
|
||||||
|
/** Product whitelist mapping: productId -> { plan, recurring } */
|
||||||
|
productMap?: Record<string, { plan: string; recurring: string }>;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,18 +71,33 @@ defineModuleConfig('payment', {
|
|||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
apiKey: {
|
apiKey: {
|
||||||
desc: 'Stripe API key to enable payment service.',
|
desc: '[Deprecated] Stripe API key. Use payment.stripe.apiKey instead.',
|
||||||
default: '',
|
default: '',
|
||||||
env: 'STRIPE_API_KEY',
|
env: 'STRIPE_API_KEY',
|
||||||
},
|
},
|
||||||
webhookKey: {
|
webhookKey: {
|
||||||
desc: 'Stripe webhook key to enable payment service.',
|
desc: '[Deprecated] Stripe webhook key. Use payment.stripe.webhookKey instead.',
|
||||||
default: '',
|
default: '',
|
||||||
env: 'STRIPE_WEBHOOK_KEY',
|
env: 'STRIPE_WEBHOOK_KEY',
|
||||||
},
|
},
|
||||||
stripe: {
|
stripe: {
|
||||||
desc: 'Stripe sdk options',
|
desc: 'Stripe sdk options and credentials',
|
||||||
default: {},
|
default: {
|
||||||
|
apiKey: '',
|
||||||
|
webhookKey: '',
|
||||||
|
},
|
||||||
link: 'https://docs.stripe.com/api',
|
link: 'https://docs.stripe.com/api',
|
||||||
},
|
},
|
||||||
|
revenuecat: {
|
||||||
|
desc: 'RevenueCat integration configs',
|
||||||
|
default: {
|
||||||
|
enabled: false,
|
||||||
|
apiKey: '',
|
||||||
|
projectId: '',
|
||||||
|
webhookAuth: '',
|
||||||
|
environment: 'production',
|
||||||
|
productMap: {},
|
||||||
|
},
|
||||||
|
link: 'https://www.revenuecat.com/docs/',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ export class StripeWebhookController {
|
|||||||
@Public()
|
@Public()
|
||||||
@Post('/webhook')
|
@Post('/webhook')
|
||||||
async handleWebhook(@Req() req: RawBodyRequest<Request>) {
|
async handleWebhook(@Req() req: RawBodyRequest<Request>) {
|
||||||
const webhookKey = this.config.payment.webhookKey;
|
const nestedWebhookKey = this.config.payment.stripe?.webhookKey;
|
||||||
|
const legacyWebhookKey = this.config.payment.webhookKey;
|
||||||
|
const webhookKey = nestedWebhookKey || legacyWebhookKey || '';
|
||||||
// Retrieve the event by verifying the signature using the raw body and secret.
|
// Retrieve the event by verifying the signature using the raw body and secret.
|
||||||
const signature = req.headers['stripe-signature'];
|
const signature = req.headers['stripe-signature'];
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient, Provider } from '@prisma/client';
|
||||||
|
|
||||||
import { EventBus, JobQueue, OnJob } from '../../base';
|
import { EventBus, JobQueue, OnJob } from '../../base';
|
||||||
|
import { RevenueCatWebhookHandler } from './revenuecat';
|
||||||
import {
|
import {
|
||||||
SubscriptionPlan,
|
SubscriptionPlan,
|
||||||
SubscriptionRecurring,
|
SubscriptionRecurring,
|
||||||
|
SubscriptionStatus,
|
||||||
SubscriptionVariant,
|
SubscriptionVariant,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
@@ -13,6 +15,8 @@ declare global {
|
|||||||
interface Jobs {
|
interface Jobs {
|
||||||
'nightly.cleanExpiredOnetimeSubscriptions': {};
|
'nightly.cleanExpiredOnetimeSubscriptions': {};
|
||||||
'nightly.notifyAboutToExpireWorkspaceSubscriptions': {};
|
'nightly.notifyAboutToExpireWorkspaceSubscriptions': {};
|
||||||
|
'nightly.reconcileRevenueCatSubscriptions': {};
|
||||||
|
'nightly.revenuecat.syncUser': { userId: string };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,7 +25,8 @@ export class SubscriptionCronJobs {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly db: PrismaClient,
|
private readonly db: PrismaClient,
|
||||||
private readonly event: EventBus,
|
private readonly event: EventBus,
|
||||||
private readonly queue: JobQueue
|
private readonly queue: JobQueue,
|
||||||
|
private readonly rcHandler: RevenueCatWebhookHandler
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private getDateRange(after: number, base: number | Date = Date.now()) {
|
private getDateRange(after: number, base: number | Date = Date.now()) {
|
||||||
@@ -45,6 +50,12 @@ export class SubscriptionCronJobs {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await this.queue.add(
|
||||||
|
'nightly.reconcileRevenueCatSubscriptions',
|
||||||
|
{},
|
||||||
|
{ jobId: 'nightly-payment-reconcile-revenuecat-subscriptions' }
|
||||||
|
);
|
||||||
|
|
||||||
// FIXME(@forehalo): the strategy is totally wrong, for monthly plan. redesign required
|
// FIXME(@forehalo): the strategy is totally wrong, for monthly plan. redesign required
|
||||||
// await this.queue.add(
|
// await this.queue.add(
|
||||||
// 'nightly.notifyAboutToExpireWorkspaceSubscriptions',
|
// 'nightly.notifyAboutToExpireWorkspaceSubscriptions',
|
||||||
@@ -142,4 +153,41 @@ export class SubscriptionCronJobs {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OnJob('nightly.reconcileRevenueCatSubscriptions')
|
||||||
|
async reconcileRevenueCatSubscriptions() {
|
||||||
|
// Find active/trialing/past_due RC subscriptions and resync via RC REST
|
||||||
|
const subs = await this.db.subscription.findMany({
|
||||||
|
where: {
|
||||||
|
provider: Provider.revenuecat,
|
||||||
|
status: {
|
||||||
|
in: [
|
||||||
|
SubscriptionStatus.Active,
|
||||||
|
SubscriptionStatus.Trialing,
|
||||||
|
SubscriptionStatus.PastDue,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { targetId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// de-duplicate targetIds
|
||||||
|
const userIds = Array.from(new Set(subs.map(s => s.targetId)));
|
||||||
|
for (const userId of userIds) {
|
||||||
|
await this.queue.add(
|
||||||
|
'nightly.revenuecat.syncUser',
|
||||||
|
{ userId },
|
||||||
|
{
|
||||||
|
attempts: 3,
|
||||||
|
backoff: { type: 'exponential', delay: 60_000 },
|
||||||
|
jobId: `nightly-rc-sync-${userId}`,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnJob('nightly.revenuecat.syncUser')
|
||||||
|
async reconcileRevenueCatSubscriptionOfUser(payload: { userId: string }) {
|
||||||
|
await this.rcHandler.syncAppUser(payload.userId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ import {
|
|||||||
UserSubscriptionResolver,
|
UserSubscriptionResolver,
|
||||||
WorkspaceSubscriptionResolver,
|
WorkspaceSubscriptionResolver,
|
||||||
} from './resolver';
|
} from './resolver';
|
||||||
|
import {
|
||||||
|
RevenueCatService,
|
||||||
|
RevenueCatWebhookController,
|
||||||
|
RevenueCatWebhookHandler,
|
||||||
|
} from './revenuecat';
|
||||||
import { SubscriptionService } from './service';
|
import { SubscriptionService } from './service';
|
||||||
import { StripeFactory, StripeProvider } from './stripe';
|
import { StripeFactory, StripeProvider } from './stripe';
|
||||||
import { StripeWebhook } from './webhook';
|
import { StripeWebhook } from './webhook';
|
||||||
@@ -40,10 +45,12 @@ import { StripeWebhook } from './webhook';
|
|||||||
providers: [
|
providers: [
|
||||||
StripeFactory,
|
StripeFactory,
|
||||||
StripeProvider,
|
StripeProvider,
|
||||||
|
RevenueCatService,
|
||||||
SubscriptionService,
|
SubscriptionService,
|
||||||
SubscriptionResolver,
|
SubscriptionResolver,
|
||||||
UserSubscriptionResolver,
|
UserSubscriptionResolver,
|
||||||
StripeWebhook,
|
StripeWebhook,
|
||||||
|
RevenueCatWebhookHandler,
|
||||||
UserSubscriptionManager,
|
UserSubscriptionManager,
|
||||||
WorkspaceSubscriptionManager,
|
WorkspaceSubscriptionManager,
|
||||||
SelfhostTeamSubscriptionManager,
|
SelfhostTeamSubscriptionManager,
|
||||||
@@ -51,6 +58,10 @@ import { StripeWebhook } from './webhook';
|
|||||||
WorkspaceSubscriptionResolver,
|
WorkspaceSubscriptionResolver,
|
||||||
PaymentEventHandlers,
|
PaymentEventHandlers,
|
||||||
],
|
],
|
||||||
controllers: [StripeWebhookController, LicenseController],
|
controllers: [
|
||||||
|
StripeWebhookController,
|
||||||
|
LicenseController,
|
||||||
|
RevenueCatWebhookController,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class PaymentModule {}
|
export class PaymentModule {}
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ export interface Subscription {
|
|||||||
trialEnd: Date | null;
|
trialEnd: Date | null;
|
||||||
nextBillAt: Date | null;
|
nextBillAt: Date | null;
|
||||||
canceledAt: Date | null;
|
canceledAt: Date | null;
|
||||||
|
// read-only metadata for IAP integration
|
||||||
|
provider?: string | null;
|
||||||
|
iapStore?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Invoice {
|
export interface Invoice {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { PrismaClient, UserStripeCustomer } from '@prisma/client';
|
import { PrismaClient, Provider, UserStripeCustomer } from '@prisma/client';
|
||||||
import { pick } from 'lodash-es';
|
import { omit, pick } from 'lodash-es';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { SubscriptionPlanNotFound, URLHelper } from '../../../base';
|
import { SubscriptionPlanNotFound, URLHelper } from '../../../base';
|
||||||
@@ -132,8 +132,9 @@ export class SelfhostTeamSubscriptionManager extends SubscriptionManager {
|
|||||||
const [subscription] = await this.db.$transaction([
|
const [subscription] = await this.db.$transaction([
|
||||||
this.db.subscription.create({
|
this.db.subscription.create({
|
||||||
data: {
|
data: {
|
||||||
|
provider: Provider.stripe,
|
||||||
targetId: key,
|
targetId: key,
|
||||||
...subscriptionData,
|
...omit(subscriptionData, 'provider', 'iapStore'),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
this.db.license.create({
|
this.db.license.create({
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
EventBus,
|
EventBus,
|
||||||
InternalServerError,
|
InternalServerError,
|
||||||
InvalidCheckoutParameters,
|
InvalidCheckoutParameters,
|
||||||
|
ManagedByAppStoreOrPlay,
|
||||||
Mutex,
|
Mutex,
|
||||||
OnEvent,
|
OnEvent,
|
||||||
SubscriptionAlreadyExists,
|
SubscriptionAlreadyExists,
|
||||||
@@ -103,6 +104,14 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
|||||||
throw new InvalidCheckoutParameters();
|
throw new InvalidCheckoutParameters();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const active = await this.getActiveSubscription({
|
||||||
|
plan: lookupKey.plan,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
if (active?.provider === 'revenuecat') {
|
||||||
|
throw new ManagedByAppStoreOrPlay();
|
||||||
|
}
|
||||||
|
|
||||||
const subscription = await this.getSubscription({
|
const subscription = await this.getSubscription({
|
||||||
plan: lookupKey.plan,
|
plan: lookupKey.plan,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -256,7 +265,7 @@ export class UserSubscriptionManager extends SubscriptionManager {
|
|||||||
]),
|
]),
|
||||||
create: {
|
create: {
|
||||||
targetId: userId,
|
targetId: userId,
|
||||||
...subscriptionData,
|
...omit(subscriptionData, ['provider', 'iapStore']),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { PrismaClient, UserStripeCustomer } from '@prisma/client';
|
import { PrismaClient, Provider, UserStripeCustomer } from '@prisma/client';
|
||||||
import { omit, pick } from 'lodash-es';
|
import { omit, pick } from 'lodash-es';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -157,6 +157,7 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
|
|||||||
|
|
||||||
return this.db.subscription.upsert({
|
return this.db.subscription.upsert({
|
||||||
where: {
|
where: {
|
||||||
|
provider: Provider.stripe,
|
||||||
stripeSubscriptionId: stripeSubscription.id,
|
stripeSubscriptionId: stripeSubscription.id,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
@@ -171,7 +172,7 @@ export class WorkspaceSubscriptionManager extends SubscriptionManager {
|
|||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
targetId: workspaceId,
|
targetId: workspaceId,
|
||||||
...subscriptionData,
|
...omit(subscriptionData, 'provider', 'iapStore'),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,23 @@ export class SubscriptionType implements Partial<Subscription> {
|
|||||||
@Field(() => Date)
|
@Field(() => Date)
|
||||||
updatedAt!: Date;
|
updatedAt!: Date;
|
||||||
|
|
||||||
|
// read-only fields for display purpose
|
||||||
|
// provider: 'stripe' | 'revenuecat'
|
||||||
|
@Field(() => String, {
|
||||||
|
nullable: true,
|
||||||
|
description:
|
||||||
|
'Payment provider of this subscription. Read-only. One of: stripe | revenuecat',
|
||||||
|
})
|
||||||
|
provider?: string | null;
|
||||||
|
|
||||||
|
// iapStore: 'app_store' | 'play_store' | null when provider is stripe
|
||||||
|
@Field(() => String, {
|
||||||
|
nullable: true,
|
||||||
|
description:
|
||||||
|
'If provider is revenuecat, indicates underlying store. Read-only. One of: app_store | play_store',
|
||||||
|
})
|
||||||
|
iapStore?: string | null;
|
||||||
|
|
||||||
// deprecated fields
|
// deprecated fields
|
||||||
@Field(() => String, {
|
@Field(() => String, {
|
||||||
name: 'id',
|
name: 'id',
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { Body, Controller, Headers, Logger, Post } from '@nestjs/common';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { Config, EventBus } from '../../../base';
|
||||||
|
import { Public } from '../../../core/auth';
|
||||||
|
|
||||||
|
const RcEventSchema = z
|
||||||
|
.object({
|
||||||
|
type: z.enum([
|
||||||
|
'TEST',
|
||||||
|
'INITIAL_PURCHASE',
|
||||||
|
'NON_RENEWING_PURCHASE',
|
||||||
|
'RENEWAL',
|
||||||
|
'PRODUCT_CHANGE',
|
||||||
|
'CANCELLATION',
|
||||||
|
'BILLING_ISSUE',
|
||||||
|
'SUBSCRIBER_ALIAS',
|
||||||
|
'SUBSCRIPTION_PAUSED',
|
||||||
|
'UNCANCELLATION',
|
||||||
|
'TRANSFER',
|
||||||
|
'SUBSCRIPTION_EXTENDED',
|
||||||
|
'EXPIRATION',
|
||||||
|
'TEMPORARY_ENTITLEMENT_GRANT',
|
||||||
|
'INVOICE_ISSUANCE',
|
||||||
|
'VIRTUAL_CURRENCY_TRANSACTION',
|
||||||
|
]),
|
||||||
|
id: z.string(),
|
||||||
|
app_id: z.string(),
|
||||||
|
environment: z.enum(['PRODUCTION', 'SANDBOX']),
|
||||||
|
|
||||||
|
app_user_id: z.string().optional(),
|
||||||
|
store: z.string().optional(),
|
||||||
|
is_family_share: z.boolean().nullable().optional(),
|
||||||
|
period_type: z
|
||||||
|
.enum(['TRIAL', 'INTRO', 'NORMAL', 'PROMOTIONAL', 'PREPAID'])
|
||||||
|
.nullable()
|
||||||
|
.optional(),
|
||||||
|
original_transaction_id: z.string().nullable().optional(),
|
||||||
|
transaction_id: z.string().nullable().optional(),
|
||||||
|
purchase_token: z.string().nullable().optional(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
const RcWebhookPayloadSchema = z.object({ event: RcEventSchema }).passthrough();
|
||||||
|
|
||||||
|
export type RcEvent = z.infer<typeof RcEventSchema>;
|
||||||
|
type RcPayload = z.infer<typeof RcWebhookPayloadSchema>;
|
||||||
|
|
||||||
|
@Controller('/api/revenuecat')
|
||||||
|
export class RevenueCatWebhookController {
|
||||||
|
private readonly logger = new Logger(RevenueCatWebhookController.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly config: Config,
|
||||||
|
private readonly event: EventBus
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('/webhook')
|
||||||
|
async handleWebhook(
|
||||||
|
@Body() body: RcPayload,
|
||||||
|
@Headers('authorization') authorization?: string
|
||||||
|
) {
|
||||||
|
const { enabled, webhookAuth, environment } =
|
||||||
|
this.config.payment.revenuecat || {};
|
||||||
|
if (enabled) {
|
||||||
|
if (webhookAuth && authorization === webhookAuth) {
|
||||||
|
try {
|
||||||
|
const parsed = RcWebhookPayloadSchema.safeParse(body);
|
||||||
|
if (parsed.success) {
|
||||||
|
const event = parsed.data.event;
|
||||||
|
const { id, app_user_id: appUserId, type } = event;
|
||||||
|
if (
|
||||||
|
event.environment.toLowerCase() === environment?.toLowerCase()
|
||||||
|
) {
|
||||||
|
this.logger.log(
|
||||||
|
`[${id}] RevenueCat Webhook {${type}} received for appUserId=${appUserId}.`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
appUserId &&
|
||||||
|
(typeof event.is_family_share !== 'boolean' ||
|
||||||
|
!event.is_family_share)
|
||||||
|
) {
|
||||||
|
// immediately ack and process asynchronously
|
||||||
|
this.event
|
||||||
|
.emitAsync('revenuecat.webhook', { appUserId, event })
|
||||||
|
.catch((e: Error) => {
|
||||||
|
this.logger.error(
|
||||||
|
'Failed to handle RevenueCat Webhook event.',
|
||||||
|
e
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.logger.warn(
|
||||||
|
'RevenueCat webhook invalid payload received.',
|
||||||
|
parsed.error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error('RevenueCat webhook error', e as Error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.logger.warn('RevenueCat webhook unauthorized.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { type RcEvent, RevenueCatWebhookController } from './controller';
|
||||||
|
export { resolveProductMapping } from './map';
|
||||||
|
export { RevenueCatService, type Subscription } from './service';
|
||||||
|
export { RevenueCatWebhookHandler } from './webhook';
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { SubscriptionPlan, SubscriptionRecurring } from '../types';
|
||||||
|
import { Subscription } from './service';
|
||||||
|
|
||||||
|
export interface ProductMapping {
|
||||||
|
plan: SubscriptionPlan;
|
||||||
|
recurring: SubscriptionRecurring;
|
||||||
|
}
|
||||||
|
|
||||||
|
// default whitelist mapping per PRD
|
||||||
|
export const DEFAULT_PRODUCT_MAP: Record<string, ProductMapping> = {
|
||||||
|
'app.affine.pro.Monthly': {
|
||||||
|
plan: SubscriptionPlan.Pro,
|
||||||
|
recurring: SubscriptionRecurring.Monthly,
|
||||||
|
},
|
||||||
|
'app.affine.pro.Annual': {
|
||||||
|
plan: SubscriptionPlan.Pro,
|
||||||
|
recurring: SubscriptionRecurring.Yearly,
|
||||||
|
},
|
||||||
|
'app.affine.pro.ai.Annual': {
|
||||||
|
plan: SubscriptionPlan.AI,
|
||||||
|
recurring: SubscriptionRecurring.Yearly,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveFallbackFromEntitlement(
|
||||||
|
entitlement: string | null | undefined,
|
||||||
|
duration: string | null | undefined
|
||||||
|
): ProductMapping | null {
|
||||||
|
const ent = (entitlement || '').toLowerCase();
|
||||||
|
const dur = (duration || '').toUpperCase();
|
||||||
|
const isPro = ent === 'pro';
|
||||||
|
const isAI = ent === 'ai';
|
||||||
|
const isM = dur === 'P1M';
|
||||||
|
const isY = dur === 'P1Y';
|
||||||
|
if ((isPro || isAI) && (isM || isY)) {
|
||||||
|
return {
|
||||||
|
plan: isPro ? SubscriptionPlan.Pro : SubscriptionPlan.AI,
|
||||||
|
recurring: isM
|
||||||
|
? SubscriptionRecurring.Monthly
|
||||||
|
: SubscriptionRecurring.Yearly,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveProductMapping(
|
||||||
|
sub: Partial<Subscription>,
|
||||||
|
override?: Record<string, { plan: string; recurring: string }>
|
||||||
|
): ProductMapping | null {
|
||||||
|
const { productId, identifier, duration } = sub;
|
||||||
|
if (override && productId && productId in override) {
|
||||||
|
const m = override[productId];
|
||||||
|
const plan = m.plan as SubscriptionPlan;
|
||||||
|
const recurring = m.recurring as SubscriptionRecurring;
|
||||||
|
if (
|
||||||
|
[SubscriptionPlan.Pro, SubscriptionPlan.AI].includes(plan) &&
|
||||||
|
[SubscriptionRecurring.Monthly, SubscriptionRecurring.Yearly].includes(
|
||||||
|
recurring
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return { plan, recurring };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
(productId && DEFAULT_PRODUCT_MAP[productId]) ||
|
||||||
|
resolveFallbackFromEntitlement(identifier, duration) ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { Config } from '../../../base';
|
||||||
|
|
||||||
|
const Store = z.enum([
|
||||||
|
'amazon',
|
||||||
|
'app_store',
|
||||||
|
'mac_app_store',
|
||||||
|
'play_store',
|
||||||
|
'promotional',
|
||||||
|
'stripe',
|
||||||
|
'rc_billing',
|
||||||
|
'roku',
|
||||||
|
'paddle',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const zRcV2RawProduct = z
|
||||||
|
.object({
|
||||||
|
id: z.string().nonempty(),
|
||||||
|
store_identifier: z.string().nonempty(),
|
||||||
|
subscription: z
|
||||||
|
.object({ duration: z.string().nullable() })
|
||||||
|
.partial()
|
||||||
|
.nullable(),
|
||||||
|
app: z.object({ type: Store }).partial(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
const zRcV2RawEntitlementItem = z
|
||||||
|
.object({
|
||||||
|
lookup_key: z.string().nonempty(),
|
||||||
|
display_name: z.string().nonempty(),
|
||||||
|
products: z
|
||||||
|
.object({ items: z.array(zRcV2RawProduct).default([]) })
|
||||||
|
.partial()
|
||||||
|
.nullable(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
const zRcV2RawEntitlements = z
|
||||||
|
.object({ items: z.array(zRcV2RawEntitlementItem).default([]) })
|
||||||
|
.partial();
|
||||||
|
|
||||||
|
const zRcV2RawSubscription = z
|
||||||
|
.object({
|
||||||
|
object: z.enum(['subscription']),
|
||||||
|
entitlements: zRcV2RawEntitlements,
|
||||||
|
starts_at: z.number(),
|
||||||
|
current_period_ends_at: z.number().nullable(),
|
||||||
|
store: Store,
|
||||||
|
auto_renewal_status: z.enum([
|
||||||
|
'will_renew',
|
||||||
|
'will_not_renew',
|
||||||
|
'will_change_product',
|
||||||
|
'will_pause',
|
||||||
|
'requires_price_increase_consent',
|
||||||
|
'has_already_renewed',
|
||||||
|
]),
|
||||||
|
status: z.enum([
|
||||||
|
'trialing',
|
||||||
|
'active',
|
||||||
|
'expired',
|
||||||
|
'in_grace_period',
|
||||||
|
'in_billing_retry',
|
||||||
|
'paused',
|
||||||
|
'unknown',
|
||||||
|
'incomplete',
|
||||||
|
]),
|
||||||
|
gives_access: z.boolean(),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
const zRcV2RawEnvelope = z
|
||||||
|
.object({
|
||||||
|
app_user_id: z.string().optional(),
|
||||||
|
id: z.string().optional(),
|
||||||
|
subscriptions: z.array(zRcV2RawSubscription).default([]),
|
||||||
|
})
|
||||||
|
.passthrough();
|
||||||
|
|
||||||
|
// v2 minimal, simplified structure exposed to callers
|
||||||
|
export const Subscription = z.object({
|
||||||
|
identifier: z.string(),
|
||||||
|
isTrial: z.boolean(),
|
||||||
|
isActive: z.boolean(),
|
||||||
|
latestPurchaseDate: z.date().nullable(),
|
||||||
|
expirationDate: z.date().nullable(),
|
||||||
|
productId: z.string(),
|
||||||
|
store: Store,
|
||||||
|
willRenew: z.boolean(),
|
||||||
|
duration: z.string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Subscription = z.infer<typeof Subscription>;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RevenueCatService {
|
||||||
|
constructor(private readonly config: Config) {}
|
||||||
|
|
||||||
|
private get apiKey(): string {
|
||||||
|
const key = this.config.payment.revenuecat?.apiKey;
|
||||||
|
if (!key) {
|
||||||
|
throw new Error('RevenueCat API key is not configured');
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get projectId(): string {
|
||||||
|
const id = this.config.payment.revenuecat?.projectId;
|
||||||
|
if (!id) {
|
||||||
|
throw new Error('RevenueCat Project ID is not configured');
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSubscriptions(customerId: string): Promise<Subscription[] | null> {
|
||||||
|
const res = await fetch(
|
||||||
|
`https://api.revenuecat.com/v2/projects/${this.projectId}/customers/${customerId}/subscriptions`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.apiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(
|
||||||
|
`RevenueCat getSubscriber failed: ${res.status} ${res.statusText} - ${text}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const envParsed = zRcV2RawEnvelope.safeParse(await res.json());
|
||||||
|
|
||||||
|
if (envParsed.success) {
|
||||||
|
return envParsed.data.subscriptions
|
||||||
|
.flatMap(sub => {
|
||||||
|
const items = sub.entitlements.items ?? [];
|
||||||
|
return items.map(ent => {
|
||||||
|
const product = ent.products?.items?.[0];
|
||||||
|
if (!product) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
identifier: ent.lookup_key,
|
||||||
|
isTrial: sub.status === 'trialing',
|
||||||
|
isActive:
|
||||||
|
sub.gives_access === true ||
|
||||||
|
sub.status === 'active' ||
|
||||||
|
sub.status === 'trialing',
|
||||||
|
latestPurchaseDate: sub.starts_at
|
||||||
|
? new Date(sub.starts_at * 1000)
|
||||||
|
: null,
|
||||||
|
expirationDate: sub.current_period_ends_at
|
||||||
|
? new Date(sub.current_period_ends_at * 1000)
|
||||||
|
: null,
|
||||||
|
productId: product.store_identifier,
|
||||||
|
store: sub.store ?? product.app.type,
|
||||||
|
willRenew: sub.auto_renewal_status === 'will_renew',
|
||||||
|
duration: product.subscription?.duration ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.filter((s): s is Subscription => s !== null);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { IapStore, PrismaClient, Provider } from '@prisma/client';
|
||||||
|
|
||||||
|
import { Config, EventBus, OnEvent } from '../../../base';
|
||||||
|
import { SubscriptionStatus } from '../types';
|
||||||
|
import { RcEvent } from './controller';
|
||||||
|
import { resolveProductMapping } from './map';
|
||||||
|
import { RevenueCatService, Subscription } from './service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RevenueCatWebhookHandler {
|
||||||
|
private readonly logger = new Logger(RevenueCatWebhookHandler.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly rc: RevenueCatService,
|
||||||
|
private readonly db: PrismaClient,
|
||||||
|
private readonly config: Config,
|
||||||
|
private readonly event: EventBus
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@OnEvent('revenuecat.webhook')
|
||||||
|
async onWebhook(evt: { appUserId?: string; event: RcEvent }) {
|
||||||
|
if (!this.config.payment.revenuecat?.enabled) return;
|
||||||
|
|
||||||
|
const appUserId = evt.appUserId;
|
||||||
|
if (!appUserId) {
|
||||||
|
this.logger.warn('RevenueCat webhook missing appUserId');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.syncAppUser(appUserId, evt.event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exposed for reuse by reconcile job
|
||||||
|
async syncAppUser(appUserId: string, event?: RcEvent) {
|
||||||
|
// Pull latest state to be resilient to reorder/duplicate events
|
||||||
|
let subscriptions: Awaited<
|
||||||
|
ReturnType<RevenueCatService['getSubscriptions']>
|
||||||
|
>;
|
||||||
|
try {
|
||||||
|
subscriptions = await this.rc.getSubscriptions(appUserId);
|
||||||
|
if (!subscriptions) return;
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error(`Failed to fetch RC subscriber for ${appUserId}`, e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const productOverride = this.config.payment.revenuecat?.productMap;
|
||||||
|
|
||||||
|
for (const sub of subscriptions) {
|
||||||
|
const mapping = resolveProductMapping(sub, productOverride);
|
||||||
|
// ignore non-whitelisted and non-fallbackable products
|
||||||
|
if (!mapping) continue;
|
||||||
|
|
||||||
|
const { status, deleteInstead, canceledAt, iapStore } =
|
||||||
|
this.mapStatus(sub);
|
||||||
|
|
||||||
|
const rcExternalRef = this.pickExternalRef(event);
|
||||||
|
|
||||||
|
// Mutual exclusion: skip if Stripe already active for the same plan
|
||||||
|
const conflict = await this.db.subscription.findFirst({
|
||||||
|
where: {
|
||||||
|
targetId: appUserId,
|
||||||
|
plan: mapping.plan,
|
||||||
|
provider: Provider.stripe,
|
||||||
|
status: {
|
||||||
|
in: [SubscriptionStatus.Active, SubscriptionStatus.Trialing],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (conflict) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Skip RC upsert: Stripe active exists. user=${appUserId} plan=${mapping.plan}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleteInstead) {
|
||||||
|
// delete record and emit cancellation if any record removed
|
||||||
|
const result = await this.db.subscription.deleteMany({
|
||||||
|
where: {
|
||||||
|
targetId: appUserId,
|
||||||
|
plan: mapping.plan,
|
||||||
|
provider: Provider.revenuecat,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (result.count > 0) {
|
||||||
|
this.event.emit('user.subscription.canceled', {
|
||||||
|
userId: appUserId,
|
||||||
|
plan: mapping.plan,
|
||||||
|
recurring: mapping.recurring,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert by unique (targetId, plan) for idempotency
|
||||||
|
const start = sub.latestPurchaseDate || new Date();
|
||||||
|
const end = sub.expirationDate || null;
|
||||||
|
const nextBillAt = end; // period end serves as next bill anchor for IAP
|
||||||
|
|
||||||
|
await this.db.subscription.upsert({
|
||||||
|
where: {
|
||||||
|
targetId_plan: { targetId: appUserId, plan: mapping.plan },
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
recurring: mapping.recurring,
|
||||||
|
variant: null,
|
||||||
|
quantity: 1,
|
||||||
|
stripeSubscriptionId: null,
|
||||||
|
stripeScheduleId: null,
|
||||||
|
provider: Provider.revenuecat,
|
||||||
|
iapStore: iapStore,
|
||||||
|
rcEntitlement: sub.identifier ?? null,
|
||||||
|
rcProductId: sub.productId || null,
|
||||||
|
rcExternalRef: rcExternalRef,
|
||||||
|
status: status,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
nextBillAt,
|
||||||
|
canceledAt: canceledAt ?? null,
|
||||||
|
trialStart: null,
|
||||||
|
trialEnd: null,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
targetId: appUserId,
|
||||||
|
plan: mapping.plan,
|
||||||
|
recurring: mapping.recurring,
|
||||||
|
variant: null,
|
||||||
|
quantity: 1,
|
||||||
|
stripeSubscriptionId: null,
|
||||||
|
stripeScheduleId: null,
|
||||||
|
provider: Provider.revenuecat,
|
||||||
|
iapStore: iapStore,
|
||||||
|
rcEntitlement: sub.identifier ?? null,
|
||||||
|
rcProductId: sub.productId || null,
|
||||||
|
rcExternalRef: rcExternalRef,
|
||||||
|
status: status,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
nextBillAt,
|
||||||
|
canceledAt: canceledAt ?? null,
|
||||||
|
trialStart: null,
|
||||||
|
trialEnd: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
status === SubscriptionStatus.Active ||
|
||||||
|
status === SubscriptionStatus.Trialing
|
||||||
|
) {
|
||||||
|
this.event.emit('user.subscription.activated', {
|
||||||
|
userId: appUserId,
|
||||||
|
plan: mapping.plan,
|
||||||
|
recurring: mapping.recurring,
|
||||||
|
});
|
||||||
|
} else if (status !== SubscriptionStatus.PastDue) {
|
||||||
|
// Do not emit canceled for PastDue (still within retry/grace window)
|
||||||
|
this.event.emit('user.subscription.canceled', {
|
||||||
|
userId: appUserId,
|
||||||
|
plan: mapping.plan,
|
||||||
|
recurring: mapping.recurring,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private pickExternalRef(e?: RcEvent): string | null {
|
||||||
|
return (
|
||||||
|
(e &&
|
||||||
|
(e.original_transaction_id || e.purchase_token || e.transaction_id)) ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapStatus(sub: Subscription): {
|
||||||
|
status: SubscriptionStatus;
|
||||||
|
iapStore: IapStore | null;
|
||||||
|
deleteInstead: boolean;
|
||||||
|
canceledAt?: Date | null;
|
||||||
|
} {
|
||||||
|
const now = Date.now();
|
||||||
|
const exp = sub.expirationDate?.getTime();
|
||||||
|
|
||||||
|
// Determine iap store and external reference for observability
|
||||||
|
const iapStore = ['app_store', 'mac_app_store'].includes(sub.store)
|
||||||
|
? IapStore.app_store
|
||||||
|
: ['play_store'].includes(sub.store)
|
||||||
|
? IapStore.play_store
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (sub.isActive) {
|
||||||
|
if (sub.isTrial) {
|
||||||
|
return {
|
||||||
|
iapStore,
|
||||||
|
status: SubscriptionStatus.Trialing,
|
||||||
|
deleteInstead: false,
|
||||||
|
canceledAt: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// PastDue from subscriber is not directly indicated; treat active as Active
|
||||||
|
const canceledAt = sub.willRenew === false ? new Date() : null;
|
||||||
|
return {
|
||||||
|
iapStore,
|
||||||
|
status: SubscriptionStatus.Active,
|
||||||
|
deleteInstead: false,
|
||||||
|
canceledAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// inactive: if not expired yet (grace/pastdue), keep as PastDue; otherwise delete
|
||||||
|
if (exp && exp > now) {
|
||||||
|
return {
|
||||||
|
iapStore,
|
||||||
|
status: SubscriptionStatus.PastDue,
|
||||||
|
deleteInstead: false,
|
||||||
|
canceledAt: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
iapStore,
|
||||||
|
status: SubscriptionStatus.Canceled,
|
||||||
|
deleteInstead: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
InvalidLicenseSessionId,
|
InvalidLicenseSessionId,
|
||||||
InvalidSubscriptionParameters,
|
InvalidSubscriptionParameters,
|
||||||
LicenseRevealed,
|
LicenseRevealed,
|
||||||
|
ManagedByAppStoreOrPlay,
|
||||||
OnEvent,
|
OnEvent,
|
||||||
SameSubscriptionRecurring,
|
SameSubscriptionRecurring,
|
||||||
SubscriptionExpired,
|
SubscriptionExpired,
|
||||||
@@ -165,6 +166,11 @@ export class SubscriptionService {
|
|||||||
throw new SubscriptionNotExists({ plan: identity.plan });
|
throw new SubscriptionNotExists({ plan: identity.plan });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IAP read-only: RevenueCat-managed subscriptions cannot be modified on web
|
||||||
|
if (subscription.provider === 'revenuecat') {
|
||||||
|
throw new ManagedByAppStoreOrPlay();
|
||||||
|
}
|
||||||
|
|
||||||
if (!subscription.stripeSubscriptionId) {
|
if (!subscription.stripeSubscriptionId) {
|
||||||
throw new CantUpdateOnetimePaymentSubscription(
|
throw new CantUpdateOnetimePaymentSubscription(
|
||||||
'Onetime payment subscription cannot be canceled.'
|
'Onetime payment subscription cannot be canceled.'
|
||||||
@@ -211,6 +217,11 @@ export class SubscriptionService {
|
|||||||
throw new SubscriptionNotExists({ plan: identity.plan });
|
throw new SubscriptionNotExists({ plan: identity.plan });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IAP read-only: RevenueCat-managed subscriptions cannot be modified on web
|
||||||
|
if (subscription.provider === 'revenuecat') {
|
||||||
|
throw new ManagedByAppStoreOrPlay();
|
||||||
|
}
|
||||||
|
|
||||||
if (!subscription.canceledAt) {
|
if (!subscription.canceledAt) {
|
||||||
throw new SubscriptionHasNotBeenCanceled();
|
throw new SubscriptionHasNotBeenCanceled();
|
||||||
}
|
}
|
||||||
@@ -258,6 +269,11 @@ export class SubscriptionService {
|
|||||||
throw new SubscriptionNotExists({ plan: identity.plan });
|
throw new SubscriptionNotExists({ plan: identity.plan });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IAP read-only: RevenueCat-managed subscriptions cannot be modified on web
|
||||||
|
if (subscription.provider === 'revenuecat') {
|
||||||
|
throw new ManagedByAppStoreOrPlay();
|
||||||
|
}
|
||||||
|
|
||||||
if (!subscription.stripeSubscriptionId) {
|
if (!subscription.stripeSubscriptionId) {
|
||||||
throw new CantUpdateOnetimePaymentSubscription();
|
throw new CantUpdateOnetimePaymentSubscription();
|
||||||
}
|
}
|
||||||
@@ -312,6 +328,10 @@ export class SubscriptionService {
|
|||||||
throw new SubscriptionNotExists({ plan: identity.plan });
|
throw new SubscriptionNotExists({ plan: identity.plan });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (subscription.provider === 'revenuecat') {
|
||||||
|
throw new ManagedByAppStoreOrPlay();
|
||||||
|
}
|
||||||
|
|
||||||
if (!subscription.stripeSubscriptionId) {
|
if (!subscription.stripeSubscriptionId) {
|
||||||
throw new CantUpdateOnetimePaymentSubscription();
|
throw new CantUpdateOnetimePaymentSubscription();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,15 +39,20 @@ export class StripeFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setup() {
|
setup() {
|
||||||
// TODO@(@forehalo): use per-requests api key injection
|
// Prefer new keys under payment.stripe.*, fallback to legacy root keys for backward compatibility
|
||||||
this.#stripe = new Stripe(
|
const {
|
||||||
this.config.payment.apiKey ||
|
apiKey: nestedApiKey,
|
||||||
// NOTE(@forehalo):
|
webhookKey: _,
|
||||||
// we always fake a key if not set because `new Stripe` will complain if it's empty string
|
...config
|
||||||
// this will make code cleaner than providing `Stripe` instance as optional one.
|
} = this.config.payment.stripe || {};
|
||||||
'stripe-api-key',
|
// NOTE:
|
||||||
this.config.payment.stripe
|
// we always fake a key if not set because `new Stripe` will complain if it's empty string
|
||||||
);
|
// this will make code cleaner than providing `Stripe` instance as optional one.
|
||||||
|
const apiKey =
|
||||||
|
nestedApiKey || this.config.payment.apiKey || 'stripe-api-key';
|
||||||
|
|
||||||
|
// TODO@(@darkskygit): use per-requests api key injection
|
||||||
|
this.#stripe = new Stripe(apiKey, config);
|
||||||
if (this.config.payment.enabled) {
|
if (this.config.payment.enabled) {
|
||||||
this.server.enableFeature(ServerFeature.Payment);
|
this.server.enableFeature(ServerFeature.Payment);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { User, Workspace } from '@prisma/client';
|
import type { User, Workspace } from '@prisma/client';
|
||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
|
|
||||||
|
import type { RcEvent } from './revenuecat';
|
||||||
|
|
||||||
export enum SubscriptionRecurring {
|
export enum SubscriptionRecurring {
|
||||||
Monthly = 'monthly',
|
Monthly = 'monthly',
|
||||||
Yearly = 'yearly',
|
Yearly = 'yearly',
|
||||||
@@ -86,6 +88,12 @@ declare global {
|
|||||||
'stripe.customer.subscription.created': Stripe.CustomerSubscriptionCreatedEvent;
|
'stripe.customer.subscription.created': Stripe.CustomerSubscriptionCreatedEvent;
|
||||||
'stripe.customer.subscription.updated': Stripe.CustomerSubscriptionUpdatedEvent;
|
'stripe.customer.subscription.updated': Stripe.CustomerSubscriptionUpdatedEvent;
|
||||||
'stripe.customer.subscription.deleted': Stripe.CustomerSubscriptionDeletedEvent;
|
'stripe.customer.subscription.deleted': Stripe.CustomerSubscriptionDeletedEvent;
|
||||||
|
|
||||||
|
// RevenueCat integration
|
||||||
|
'revenuecat.webhook': {
|
||||||
|
appUserId?: string;
|
||||||
|
event: RcEvent;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -744,6 +744,7 @@ enum ErrorNames {
|
|||||||
LICENSE_REVEALED
|
LICENSE_REVEALED
|
||||||
LINK_EXPIRED
|
LINK_EXPIRED
|
||||||
MAILER_SERVICE_IS_NOT_CONFIGURED
|
MAILER_SERVICE_IS_NOT_CONFIGURED
|
||||||
|
MANAGED_BY_APP_STORE_OR_PLAY
|
||||||
MEMBER_NOT_FOUND_IN_SPACE
|
MEMBER_NOT_FOUND_IN_SPACE
|
||||||
MEMBER_QUOTA_EXCEEDED
|
MEMBER_QUOTA_EXCEEDED
|
||||||
MENTION_USER_DOC_ACCESS_DENIED
|
MENTION_USER_DOC_ACCESS_DENIED
|
||||||
@@ -1595,6 +1596,7 @@ type Query {
|
|||||||
|
|
||||||
"""query workspace embedding status"""
|
"""query workspace embedding status"""
|
||||||
queryWorkspaceEmbeddingStatus(workspaceId: String!): ContextWorkspaceEmbeddingStatus!
|
queryWorkspaceEmbeddingStatus(workspaceId: String!): ContextWorkspaceEmbeddingStatus!
|
||||||
|
revealedAccessTokens: [RevealedAccessToken!]!
|
||||||
|
|
||||||
"""server config"""
|
"""server config"""
|
||||||
serverConfig: ServerConfigType!
|
serverConfig: ServerConfigType!
|
||||||
@@ -1962,6 +1964,11 @@ type SubscriptionType {
|
|||||||
canceledAt: DateTime
|
canceledAt: DateTime
|
||||||
createdAt: DateTime!
|
createdAt: DateTime!
|
||||||
end: DateTime
|
end: DateTime
|
||||||
|
|
||||||
|
"""
|
||||||
|
If provider is revenuecat, indicates underlying store. Read-only. One of: app_store | play_store
|
||||||
|
"""
|
||||||
|
iapStore: String
|
||||||
id: String @deprecated(reason: "removed")
|
id: String @deprecated(reason: "removed")
|
||||||
nextBillAt: DateTime
|
nextBillAt: DateTime
|
||||||
|
|
||||||
@@ -1970,6 +1977,11 @@ type SubscriptionType {
|
|||||||
There won't actually be a subscription with plan 'Free'
|
There won't actually be a subscription with plan 'Free'
|
||||||
"""
|
"""
|
||||||
plan: SubscriptionPlan!
|
plan: SubscriptionPlan!
|
||||||
|
|
||||||
|
"""
|
||||||
|
Payment provider of this subscription. Read-only. One of: stripe | revenuecat
|
||||||
|
"""
|
||||||
|
provider: String
|
||||||
recurring: SubscriptionRecurring!
|
recurring: SubscriptionRecurring!
|
||||||
start: DateTime!
|
start: DateTime!
|
||||||
status: SubscriptionStatus!
|
status: SubscriptionStatus!
|
||||||
|
|||||||
@@ -59,6 +59,21 @@ export async function run() {
|
|||||||
const adapter = new SocketIoAdapter(app);
|
const adapter = new SocketIoAdapter(app);
|
||||||
app.useWebSocketAdapter(adapter);
|
app.useWebSocketAdapter(adapter);
|
||||||
|
|
||||||
|
if (env.dev) {
|
||||||
|
const { SwaggerModule, DocumentBuilder } = await import('@nestjs/swagger');
|
||||||
|
// Swagger API Docs
|
||||||
|
const docConfig = new DocumentBuilder()
|
||||||
|
.setTitle('AFFiNE API')
|
||||||
|
.setDescription(`AFFiNE Server ${env.version} API documentation`)
|
||||||
|
.setVersion(`${env.version}`)
|
||||||
|
.build();
|
||||||
|
const documentFactory = () => SwaggerModule.createDocument(app, docConfig);
|
||||||
|
SwaggerModule.setup('/api/docs', app, documentFactory, {
|
||||||
|
useGlobalPrefix: true,
|
||||||
|
swaggerOptions: { persistAuthorization: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const url = app.get(URLHelper);
|
const url = app.get(URLHelper);
|
||||||
const listeningHost = '0.0.0.0';
|
const listeningHost = '0.0.0.0';
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user