-
Notifications
You must be signed in to change notification settings - Fork 3
feat(portal-plugin-billing): add new billing plugin with subscription management and Stripe integration #526
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| # See https://www.git-town.com/configuration-file for details | ||
|
|
||
| [branches] | ||
| main = "develop" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| { | ||
| "name": "@lumeweb/portal-plugin-billing", | ||
| "version": "0.0.0", | ||
| "private": true, | ||
| "type": "module", | ||
| "scripts": { | ||
| "dev": "vite", | ||
| "build": "vite build", | ||
| "serve": "vite preview", | ||
| "lint": "eslint ." | ||
| }, | ||
| "dependencies": { | ||
| "@lumeweb/advanced-rest-provider": "workspace:*", | ||
| "@lumeweb/portal-framework-auth": "workspace:*", | ||
| "@lumeweb/portal-framework-core": "workspace:*", | ||
| "@lumeweb/portal-framework-ui": "workspace:*", | ||
| "@lumeweb/portal-framework-ui-core": "workspace:*", | ||
| "@lumeweb/portal-plugin-dashboard": "workspace:*", | ||
| "@refinedev/core": "^4.47.0", | ||
| "@refinedev/react-router": "^2.0.2", | ||
| "@tanstack/react-query": "4.36.1", | ||
| "lucide-react": "^0.543.0", | ||
| "react": "^18.3.1", | ||
| "react-dom": "^18.3.1", | ||
| "react-hook-form": "7.54.0", | ||
| "react-router": "7.5.2" | ||
| }, | ||
| "peerDependencies": { | ||
| "react": "^18.3.1", | ||
| "react-dom": "^18.3.1" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import type { PluginConfig } from "@lumeweb/portal-framework-core/vite"; | ||
|
|
||
| import { dirname } from "path"; | ||
| import { fileURLToPath } from "url"; | ||
|
|
||
| const __dirname = dirname(fileURLToPath(import.meta.url)); | ||
|
|
||
| export default { | ||
| dir: __dirname, | ||
| exposes: { | ||
| ".": "./src/index", | ||
| "./account/subscriptions": "./src/ui/routes/account.subscriptions", | ||
| }, | ||
| name: "core:billing", | ||
| } satisfies PluginConfig; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| module.exports = require("@lumeweb/portal-framework-ui-core/postcss.config"); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { Capability } from './refineConfig'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| import type { RefineProps } from "@refinedev/core"; | ||
| import { | ||
| Framework, | ||
| mergeRefineConfig, | ||
| RefineConfigCapability, | ||
| } from "@lumeweb/portal-framework-core"; | ||
|
|
||
| export class Capability implements RefineConfigCapability { | ||
| readonly id: string = "billing:refine-config"; | ||
| status; | ||
| readonly type = "core:refine-config"; | ||
| version: string; | ||
| #apiUrl: string; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chainUnused private field #apiUrl. The Looking at the dashboard capability (see relevant code snippets), it uses Run this script to check if billing needs its own data provider setup: 🏁 Script executed: #!/bin/bash
# Search for billing-specific API endpoints or data provider usage
rg -nP --type=ts -C3 'billing.*api|data.*provider.*billing' -g '!node_modules'Length of output: 0 I need to investigate the codebase more thoroughly since the initial pattern search yielded no results. Let me examine the file structure and look for actual usage patterns. Length of output: 1526 🏁 Script executed: #!/bin/bash
# Search for setupDataProvider usage across the codebase
rg -nP --type=ts 'setupDataProvider' -C5 -g '!node_modules'Length of output: 3208 🏁 Script executed: #!/bin/bash
# Search for any usage or reference to #apiUrl in the codebase
rg -nP --type=ts '#apiUrl' -C3 -g '!node_modules'Length of output: 11437 🏁 Script executed: #!/bin/bash
# Look for dashboard capability file to understand the pattern
fd -t f "dashboard.*capability\|.*dashboard.*config" --type f | grep -E '\.(ts|js)$' | head -10Length of output: 0 I need to see the complete billing capability file to verify if Length of output: 1067 Remove unused The 🤖 Prompt for AI Agents |
||
| dependencies = ["dashboard:refine-config"]; | ||
|
|
||
| async destroy() {} | ||
|
|
||
| getConfig(existing?: Partial<RefineProps>) { | ||
| return mergeRefineConfig(existing, {}); | ||
| } | ||
|
Comment on lines
+18
to
+20
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Empty configuration passed to mergeRefineConfig. The Consider whether billing-specific resources or data providers should be configured here, similar to how the dashboard capability uses 🤖 Prompt for AI Agents |
||
|
|
||
| async initialize(framework: Framework) { | ||
| // Get the dashboard refine capability | ||
| const dashboardCapability = await framework.getCapability< | ||
| RefineConfigCapability & { apiUrl: string } | ||
| >("dashboard:refine-config"); | ||
|
|
||
| if (!dashboardCapability) { | ||
| throw new Error("Dashboard refine capability not found"); | ||
| } | ||
|
|
||
| // Get the API URL from the dashboard capability | ||
| this.#apiUrl = dashboardCapability.apiUrl; | ||
|
|
||
| if (!this.#apiUrl) { | ||
| throw new Error("API URL not found in dashboard capability"); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| export * from './useSubscription'; | ||
| export * from './useCustomerPortal'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| import { useCustomMutation, type HttpError } from "@refinedev/core"; | ||
| import type { CustomerPortalResponse } from "@/types/subscription"; | ||
| import type { UseCustomMutationProps } from "@refinedev/core"; | ||
| import { DATA_PROVIDER_NAME } from "@lumeweb/portal-framework-auth"; | ||
|
|
||
| interface UseCustomerPortalConfig { | ||
| return_url?: string; | ||
| mutationOptions?: UseCustomMutationProps< | ||
| CustomerPortalResponse, | ||
| HttpError, | ||
| unknown | ||
| >["mutationOptions"]; | ||
| } | ||
|
|
||
| interface UseCustomerPortalResult { | ||
| mutate: () => void; | ||
| isLoading: boolean; | ||
| data?: CustomerPortalResponse; | ||
| error?: HttpError | null; | ||
| } | ||
|
|
||
| export function useCustomerPortal( | ||
| config: UseCustomerPortalConfig = {}, | ||
| ): UseCustomerPortalResult { | ||
| const { return_url, mutationOptions } = config; | ||
|
|
||
| const mutation = useCustomMutation<CustomerPortalResponse>({ | ||
| mutationOptions, | ||
| }); | ||
|
|
||
| const mutate = () => { | ||
| mutation.mutate({ | ||
| url: "/account/billing/customer-portal", | ||
| method: "post", | ||
| values: { | ||
| return_url, | ||
| }, | ||
| dataProviderName: DATA_PROVIDER_NAME, | ||
| }); | ||
| }; | ||
|
|
||
| return { | ||
| mutate, | ||
| isLoading: mutation.isLoading, | ||
| data: mutation.data?.data, | ||
| error: mutation.error, | ||
| }; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| import { useCustom } from "@refinedev/core"; | ||
| import type { SubscriptionStatusResponse } from "@/types/subscription"; | ||
| import type { UseCustomProps } from "@refinedev/core"; | ||
| import { DATA_PROVIDER_NAME } from "@lumeweb/portal-framework-auth"; | ||
|
|
||
| interface UseSubscriptionConfig { | ||
| queryOptions?: UseCustomProps< | ||
| SubscriptionStatusResponse, | ||
| unknown, | ||
| unknown, | ||
| unknown, | ||
| SubscriptionStatusResponse | ||
| >["queryOptions"]; | ||
| } | ||
|
|
||
| export function useSubscription(config: UseSubscriptionConfig = {}) { | ||
| const { queryOptions } = config; | ||
|
|
||
| return useCustom<SubscriptionStatusResponse>({ | ||
| url: "/account/billing/subscription", | ||
| method: "get", | ||
| dataProviderName: DATA_PROVIDER_NAME, | ||
| queryOptions: { | ||
| // Apply user-provided query options last to allow overrides | ||
| ...queryOptions, | ||
| }, | ||
| }); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| export * from "./ui"; | ||
| export * from "./hooks"; | ||
| export * from "./types"; | ||
|
|
||
| import { | ||
| createNamespacedId, | ||
| Framework, | ||
| type Plugin, | ||
| } from "@lumeweb/portal-framework-core"; | ||
| import { Capability as RefineConfigCapability } from "./capabilities"; | ||
| import routes from "./routes"; | ||
|
|
||
| export default function (): Plugin { | ||
| return { | ||
| dependencies: [{ id: "core:dashboard" }], | ||
| capabilities: [new RefineConfigCapability()], | ||
| async destroy(_framework: Framework) { | ||
| console.log("Plugin Billing destroyed"); | ||
| }, | ||
| id: createNamespacedId("core", "billing"), | ||
| async initialize(_framework: Framework) { | ||
| console.log("Plugin Billing initialized"); | ||
| }, | ||
| routes, | ||
| } satisfies Plugin; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import type { RouteDefinition } from "@lumeweb/portal-framework-core"; | ||
| import { CreditCard } from "lucide-react"; | ||
|
|
||
| const routes = [ | ||
| { | ||
| component: "account/subscriptions", | ||
| id: "account_subscription", | ||
| navigation: { | ||
| icon: CreditCard, | ||
| label: "Subscription", | ||
| }, | ||
| path: "/subscription", | ||
| }, | ||
| ] satisfies RouteDefinition[]; | ||
|
|
||
| export default routes; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export * from './subscription'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| export interface SubscriptionStatusResponse { | ||
| is_subscribed: boolean; | ||
| gateway_type?: string; | ||
| plan_id?: number; | ||
| created_at?: string; | ||
| updated_at?: string; | ||
| } | ||
|
|
||
| export interface CustomerPortalResponse { | ||
| url: string; | ||
| } | ||
|
|
||
| export interface BillingPluginMeta { | ||
| stripe_pricing_table_id?: string; | ||
| stripe_publishable_key?: string; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| import React from "react"; | ||
|
|
||
| interface StripePricingTableProps { | ||
| pricingTableId: string; | ||
| publishableKey: string; | ||
| customerEmail?: string; | ||
| clientReferenceId?: string; | ||
| } | ||
|
|
||
| declare global { | ||
| namespace JSX { | ||
| interface IntrinsicElements { | ||
| "stripe-pricing-table": React.DetailedHTMLProps< | ||
| React.HTMLAttributes<HTMLElement>, | ||
| HTMLElement | ||
| >; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| export const StripePricingTable: React.FC<StripePricingTableProps> = ({ | ||
| pricingTableId, | ||
| publishableKey, | ||
| customerEmail, | ||
| clientReferenceId | ||
| }) => { | ||
| React.useEffect(() => { | ||
| const script = document.createElement("script"); | ||
| script.src = "https://js.stripe.com/v3/pricing-table.js"; | ||
| script.async = true; | ||
| document.body.appendChild(script); | ||
| return () => { | ||
| document.body.removeChild(script); | ||
| }; | ||
| }, []); | ||
|
Comment on lines
+27
to
+35
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fix script cleanup to prevent breaking other component instances. Removing the script element on unmount will break other mounted instances of Apply this diff to fix the cleanup logic: export const StripePricingTable: React.FC<StripePricingTableProps> = ({
pricingTableId,
publishableKey,
customerEmail,
clientReferenceId
}) => {
React.useEffect(() => {
+ // Check if script already exists
+ const existingScript = document.querySelector(
+ 'script[src="https://js.stripe.com/v3/pricing-table.js"]'
+ );
+
+ if (existingScript) {
+ return; // Script already loaded, no cleanup needed
+ }
+
const script = document.createElement("script");
script.src = "https://js.stripe.com/v3/pricing-table.js";
script.async = true;
document.body.appendChild(script);
- return () => {
- document.body.removeChild(script);
- };
+
+ // Don't remove the script on unmount since it may be used by other instances
+ // The script is safe to leave in the DOM
}, []);
return (
<stripe-pricing-table
pricing-table-id={pricingTableId}
publishable-key={publishableKey}
{...(customerEmail && { "customer-email": customerEmail })}
{...(clientReferenceId && { "client-reference-id": clientReferenceId })}
/>
);
};
🤖 Prompt for AI AgentsAdd error handling for script loading failures. The component doesn't handle cases where the Stripe script fails to load (network errors, CSP violations, etc.), which could result in a broken UI with no feedback to the user. Consider adding error handling: export const StripePricingTable: React.FC<StripePricingTableProps> = ({
pricingTableId,
publishableKey,
customerEmail,
clientReferenceId
}) => {
+ const [error, setError] = React.useState<string | null>(null);
+
React.useEffect(() => {
const existingScript = document.querySelector(
'script[src="https://js.stripe.com/v3/pricing-table.js"]'
);
if (existingScript) {
return;
}
const script = document.createElement("script");
script.src = "https://js.stripe.com/v3/pricing-table.js";
script.async = true;
+
+ script.onerror = () => {
+ setError("Failed to load Stripe pricing table. Please refresh the page.");
+ };
+
document.body.appendChild(script);
}, []);
+ if (error) {
+ return <div className="text-red-600">{error}</div>;
+ }
+
return (
<stripe-pricing-table
pricing-table-id={pricingTableId}
publishable-key={publishableKey}
{...(customerEmail && { "customer-email": customerEmail })}
{...(clientReferenceId && { "client-reference-id": clientReferenceId })}
/>
);
};
🤖 Prompt for AI Agents |
||
|
|
||
| return ( | ||
| <stripe-pricing-table | ||
| pricing-table-id={pricingTableId} | ||
| publishable-key={publishableKey} | ||
| {...(customerEmail && { "customer-email": customerEmail })} | ||
| {...(clientReferenceId && { "client-reference-id": clientReferenceId })} | ||
| /> | ||
| ); | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export * from './StripePricingTable'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| export * from './components'; | ||
| export * from './routes'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
Side‑effect import — ensure it won’t be treeshaken.
If @lumeweb/advanced-rest-provider must run at import time, verify its package.json has sideEffects true (or lists the entry) so bundlers keep it. Otherwise, expose an explicit init and call it here.
🏁 Script executed:
Length of output: 53
Side‑effect import will be tree‑shaken — add sideEffects declaration or use explicit init.
Verified:
@lumeweb/advanced-rest-provider(libs/advanced-rest/package.json) has nosideEffectsfield. Bundlers will remove this import during build.Fix one of:
"sideEffects": true(or list the entry point) to libs/advanced-rest/package.jsoninitAdvancedRestProvider()call (or equivalent) in this file and export that function for tree-shaking safety🤖 Prompt for AI Agents