diff --git a/apps/web/config/billing.config.ts b/apps/web/config/billing.config.ts index 10d35be9f..d0780c35f 100644 --- a/apps/web/config/billing.config.ts +++ b/apps/web/config/billing.config.ts @@ -1,174 +1,8 @@ -import { BillingProviderSchema, createBillingSchema } from '@kit/billing'; +/* +Replace this file with your own billing configuration file. +Copy it from billing.sample.config.ts and update the configuration to match your billing provider and products. +This file will never be overwritten by git updates + */ +import sampleSchema from './billing.sample.config'; -// The billing provider to use. This should be set in the environment variables -// and should match the provider in the database. We also add it here so we can validate -// your configuration against the selected provider at build time. -const provider = BillingProviderSchema.parse( - process.env.NEXT_PUBLIC_BILLING_PROVIDER, -); - -export default createBillingSchema({ - // also update config.billing_provider in the DB to match the selected - provider, - // products configuration - products: [ - { - id: 'lifetime', - name: 'Lifetime', - description: 'The perfect plan for a lifetime', - currency: 'USD', - features: ['Feature 1', 'Feature 2', 'Feature 3'], - plans: [ - { - name: 'Lifetime', - id: 'lifetime', - paymentType: 'one-time', - lineItems: [ - { - id: '324643', - name: 'Base', - description: 'Base plan', - cost: 999.99, - type: 'base', - }, - ], - }, - ], - }, - { - id: 'starter', - name: 'Starter', - description: 'The perfect plan to get started', - currency: 'USD', - badge: `Value`, - plans: [ - { - name: 'Starter Monthly', - id: 'starter-monthly', - trialPeriod: 7, - paymentType: 'recurring', - interval: 'month', - lineItems: [ - { - id: '55476', - name: 'Base', - description: 'Base plan', - cost: 9.99, - type: 'base', - }, - ], - }, - { - name: 'Starter Yearly', - id: 'starter-yearly', - paymentType: 'recurring', - interval: 'year', - lineItems: [ - { - id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe1', - name: 'Base', - description: 'Base plan', - cost: 99.99, - type: 'base', - }, - ], - }, - ], - features: ['Feature 1', 'Feature 2', 'Feature 3'], - }, - { - id: 'pro', - name: 'Pro', - badge: `Popular`, - highlighted: true, - description: 'The perfect plan for professionals', - currency: 'USD', - plans: [ - { - name: 'Pro Monthly', - id: 'pro-monthly', - paymentType: 'recurring', - interval: 'month', - lineItems: [ - { - id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe2', - name: 'Base', - description: 'Base plan', - cost: 19.99, - type: 'base', - }, - ], - }, - { - name: 'Pro Yearly', - id: 'pro-yearly', - paymentType: 'recurring', - interval: 'year', - lineItems: [ - { - id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe3', - name: 'Base', - description: 'Base plan', - cost: 199.99, - type: 'base', - }, - ], - }, - ], - features: [ - 'Feature 1', - 'Feature 2', - 'Feature 3', - 'Feature 4', - 'Feature 5', - ], - }, - { - id: 'enterprise', - name: 'Enterprise', - description: 'The perfect plan for enterprises', - currency: 'USD', - plans: [ - { - name: 'Enterprise Monthly', - id: 'enterprise-monthly', - paymentType: 'recurring', - interval: 'month', - lineItems: [ - { - id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe4', - name: 'Base', - description: 'Base plan', - cost: 29.99, - type: 'base', - }, - ], - }, - { - name: 'Enterprise Yearly', - id: 'enterprise-yearly', - paymentType: 'recurring', - interval: 'year', - lineItems: [ - { - id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe5', - name: 'Base', - description: 'Base plan', - cost: 299.99, - type: 'base', - }, - ], - }, - ], - features: [ - 'Feature 1', - 'Feature 2', - 'Feature 3', - 'Feature 4', - 'Feature 5', - 'Feature 6', - 'Feature 7', - ], - }, - ], -}); +export default sampleSchema; diff --git a/apps/web/config/billing.sample.config.ts b/apps/web/config/billing.sample.config.ts new file mode 100644 index 000000000..078b9f23e --- /dev/null +++ b/apps/web/config/billing.sample.config.ts @@ -0,0 +1,186 @@ +/** + * This is a sample billing configuration file. You should copy this file to `billing.config.ts` and then replace + * the configuration with your own billing provider and products. + */ +import { BillingProviderSchema, createBillingSchema } from '@kit/billing'; + +// The billing provider to use. This should be set in the environment variables +// and should match the provider in the database. We also add it here so we can validate +// your configuration against the selected provider at build time. +const provider = BillingProviderSchema.parse( + process.env.NEXT_PUBLIC_BILLING_PROVIDER, +); + +export default createBillingSchema({ + // also update config.billing_provider in the DB to match the selected + provider, + // products configuration + products: [ + { + id: 'lifetime', + name: 'Lifetime', + description: 'The perfect plan for a lifetime', + currency: 'USD', + features: ['Feature 1', 'Feature 2', 'Feature 3'], + plans: [ + { + name: 'Lifetime', + id: 'lifetime', + paymentType: 'one-time', + lineItems: [ + { + id: '324643', + name: 'Base', + cost: 999.99, + type: 'base', + }, + ], + }, + ], + }, + { + id: 'starter', + name: 'Starter', + description: 'The perfect plan to get started', + currency: 'USD', + badge: `Value`, + plans: [ + { + name: 'Starter Monthly', + id: 'starter-monthly', + trialPeriod: 7, + paymentType: 'recurring', + interval: 'month', + lineItems: [ + { + id: '55476', + name: 'Base', + cost: 9.99, + type: 'base', + }, + { + id: '324644', + name: 'Addon 1', + cost: 99.99, + type: 'metered', + unit: 'GB', + included: 10, + }, + { + id: '324645', + name: 'Addon 2', + cost: 9.99, + type: 'per-seat', + included: 5, + }, + ], + }, + { + name: 'Starter Yearly', + id: 'starter-yearly', + paymentType: 'recurring', + interval: 'year', + lineItems: [ + { + id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe1', + name: 'Base', + cost: 99.99, + type: 'base', + }, + ], + }, + ], + features: ['Feature 1', 'Feature 2', 'Feature 3'], + }, + { + id: 'pro', + name: 'Pro', + badge: `Popular`, + highlighted: true, + description: 'The perfect plan for professionals', + currency: 'USD', + plans: [ + { + name: 'Pro Monthly', + id: 'pro-monthly', + paymentType: 'recurring', + interval: 'month', + lineItems: [ + { + id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe2', + name: 'Base', + cost: 19.99, + type: 'base', + }, + ], + }, + { + name: 'Pro Yearly', + id: 'pro-yearly', + paymentType: 'recurring', + interval: 'year', + lineItems: [ + { + id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe3', + name: 'Base', + cost: 199.99, + type: 'base', + }, + ], + }, + ], + features: [ + 'Feature 1', + 'Feature 2', + 'Feature 3', + 'Feature 4', + 'Feature 5', + ], + }, + { + id: 'enterprise', + name: 'Enterprise', + description: 'The perfect plan for enterprises', + currency: 'USD', + plans: [ + { + name: 'Enterprise Monthly', + id: 'enterprise-monthly', + paymentType: 'recurring', + interval: 'month', + lineItems: [ + { + id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe4', + name: 'Base', + cost: 29.99, + type: 'base', + }, + ], + }, + { + name: 'Enterprise Yearly', + id: 'enterprise-yearly', + paymentType: 'recurring', + interval: 'year', + lineItems: [ + { + id: 'price_1NNwYHI1i3VnbZTqI2UzaHIe5', + name: 'Base', + cost: 299.99, + type: 'base', + }, + ], + }, + ], + features: [ + 'Feature 1', + 'Feature 2', + 'Feature 3', + 'Feature 4', + 'Feature 5', + 'Feature 6', + 'Feature 7', + ], + }, + ], +}); diff --git a/packages/billing/core/src/create-billing-schema.ts b/packages/billing/core/src/create-billing-schema.ts index 65744a349..82c7fbf3f 100644 --- a/packages/billing/core/src/create-billing-schema.ts +++ b/packages/billing/core/src/create-billing-schema.ts @@ -26,7 +26,9 @@ export const LineItemSchema = z .min(1), description: z .string({ - description: 'Description of the line item. Displayed to the user.', + description: + 'Description of the line item. Displayed to the user and will replace the auto-generated description inferred' + + ' from the line item. This is useful if you want to provide a more detailed description to the user.', }) .optional(), cost: z @@ -46,11 +48,33 @@ export const LineItemSchema = z description: 'Included amount of the line item. Displayed to the user.', }) .optional(), + tiers: z + .array( + z.object({ + upTo: z + .number({ + description: + 'Up to this amount the cost is the base cost. Displayed to the user.', + }) + .min(0), + cost: z + .number({ + description: + 'Cost of the line item after the upTo amount. Displayed to the user.', + }) + .min(0), + }), + ) + .optional(), }) - .refine((data) => data.type !== 'metered' || (data.unit && data.included), { - message: 'Metered line items must have a unit and included amount', - path: ['type', 'unit', 'included'], - }); + .refine( + (data) => + data.type !== 'metered' || (data.unit && data.included !== undefined), + { + message: 'Metered line items must have a unit and included amount', + path: ['type', 'unit', 'included'], + }, + ); export const PlanSchema = z .object({ diff --git a/packages/billing/gateway/src/components/line-item-details.tsx b/packages/billing/gateway/src/components/line-item-details.tsx index ca86bfa05..c7045b580 100644 --- a/packages/billing/gateway/src/components/line-item-details.tsx +++ b/packages/billing/gateway/src/components/line-item-details.tsx @@ -1,3 +1,4 @@ +import { Plus, PlusCircle } from 'lucide-react'; import { z } from 'zod'; import { LineItemSchema } from '@kit/billing'; @@ -5,6 +6,9 @@ import { formatCurrency } from '@kit/shared/utils'; import { If } from '@kit/ui/if'; import { Trans } from '@kit/ui/trans'; +const className = + 'flex text-secondary-foreground items-center justify-between text-sm'; + export function LineItemDetails( props: React.PropsWithChildren<{ lineItems: z.infer[]; @@ -12,22 +16,42 @@ export function LineItemDetails( selectedInterval?: string | undefined; }>, ) { - const className = - 'flex items-center justify-between text-sm border-b border-dashed pb-2 last:border-transparent'; - return ( -
+
{props.lineItems.map((item) => { + // If the item has a description, we render it as a simple text + // and pass the item as values to the translation so we can use + // the item properties in the translation. + if (item.description) { + return ( +
+ + + + + +
+ ); + } + switch (item.type) { case 'base': return (
- - - + + + + + + + - / + - - - + + + + + + @@ -63,14 +91,18 @@ export function LineItemDetails( case 'metered': return (
- - - + + + + + + + {item.included ? ( diff --git a/packages/billing/gateway/src/components/plan-picker.tsx b/packages/billing/gateway/src/components/plan-picker.tsx index bf5d05c7f..54a290e02 100644 --- a/packages/billing/gateway/src/components/plan-picker.tsx +++ b/packages/billing/gateway/src/components/plan-picker.tsx @@ -34,6 +34,7 @@ import { RadioGroupItem, RadioGroupItemLabel, } from '@kit/ui/radio-group'; +import { Separator } from '@kit/ui/separator'; import { Trans } from '@kit/ui/trans'; import { cn } from '@kit/ui/utils'; @@ -410,8 +411,10 @@ function PlanDetails({

-
- + + +
+ @@ -423,16 +426,16 @@ function PlanDetails({
- + {selectedProduct.features.map((item) => { return ( -
+
- +
diff --git a/packages/billing/gateway/src/components/pricing-table.tsx b/packages/billing/gateway/src/components/pricing-table.tsx index 3f544d762..88b508695 100644 --- a/packages/billing/gateway/src/components/pricing-table.tsx +++ b/packages/billing/gateway/src/components/pricing-table.tsx @@ -5,8 +5,14 @@ import { useState } from 'react'; import Link from 'next/link'; import { ArrowRight, CheckCircle, Sparkles } from 'lucide-react'; +import { z } from 'zod'; -import { BillingConfig, getBaseLineItem, getPlanIntervals } from '@kit/billing'; +import { + BillingConfig, + LineItemSchema, + getBaseLineItem, + getPlanIntervals, +} from '@kit/billing'; import { formatCurrency } from '@kit/shared/utils'; import { Badge } from '@kit/ui/badge'; import { Button } from '@kit/ui/button'; @@ -15,6 +21,8 @@ import { If } from '@kit/ui/if'; import { Trans } from '@kit/ui/trans'; import { cn } from '@kit/ui/utils'; +import { LineItemDetails } from './line-item-details'; + interface Paths { signUp: string; } @@ -23,9 +31,11 @@ export function PricingTable({ config, paths, CheckoutButtonRenderer, + displayPlanDetails = true, }: { config: BillingConfig; paths: Paths; + displayPlanDetails?: boolean; CheckoutButtonRenderer?: React.ComponentType<{ planId: string; @@ -83,6 +93,7 @@ export function PricingTable({ baseLineItem={basePlan} product={product} paths={paths} + displayPlanDetails={displayPlanDetails} CheckoutButton={CheckoutButtonRenderer} /> ); @@ -95,6 +106,7 @@ export function PricingTable({ function PricingItem( props: React.PropsWithChildren<{ className?: string; + displayPlanDetails: boolean; paths: { signUp: string; @@ -109,6 +121,7 @@ function PricingItem( plan: { id: string; + lineItems: z.infer[]; interval?: string; name?: string; href?: string; @@ -132,13 +145,19 @@ function PricingItem( ) { const highlighted = props.product.highlighted ?? false; + // we want to exclude the base plan from the list of line items + // since we are displaying the base plan separately as the main price + const lineItemsToDisplay = props.plan.lineItems.filter((item) => { + return item.type !== 'base'; + }); + return (
-
+
+
+ +
+
+ + +
+
+ +
+ + +
+
@@ -286,18 +323,16 @@ function ListItem({ }>) { return (
  • -
    - -
    +