diff --git a/apps/backend/.gitignore b/apps/backend/.gitignore index 5697a92..ff1d15a 100644 --- a/apps/backend/.gitignore +++ b/apps/backend/.gitignore @@ -1,3 +1,6 @@ # deps node_modules/ -prisma.config.prod.ts \ No newline at end of file +prisma.config.prod.ts + +# build output +dist/ \ No newline at end of file diff --git a/apps/backend/prisma/plans.seed-data.ts b/apps/backend/prisma/plans.seed-data.ts index 5660c11..242bf38 100644 --- a/apps/backend/prisma/plans.seed-data.ts +++ b/apps/backend/prisma/plans.seed-data.ts @@ -4,7 +4,7 @@ export const planSeeds = [ name: 'Basic', price: '49.00', rules: { - version: 'v1', + version: 'v2', limits: { maxCourts: 2, maxBookingsPerDay: 80, @@ -22,6 +22,11 @@ export const planSeeds = [ advancedReports: false, whatsappReminders: false, }, + pricing: { + overrides: { + AR: { amount: 14999, currency: 'ARS' }, + }, + }, }, }, { @@ -29,7 +34,7 @@ export const planSeeds = [ name: 'Advanced', price: '99.00', rules: { - version: 'v1', + version: 'v2', limits: { maxCourts: 5, maxBookingsPerDay: 220, @@ -47,6 +52,11 @@ export const planSeeds = [ advancedReports: true, whatsappReminders: true, }, + pricing: { + overrides: { + AR: { amount: 29999, currency: 'ARS' }, + }, + }, }, }, { @@ -54,7 +64,7 @@ export const planSeeds = [ name: 'Enterprise', price: '199.00', rules: { - version: 'v1', + version: 'v2', limits: { maxCourts: 20, maxBookingsPerDay: 2000, @@ -72,6 +82,11 @@ export const planSeeds = [ advancedReports: true, whatsappReminders: true, }, + pricing: { + overrides: { + AR: { amount: 59999, currency: 'ARS' }, + }, + }, }, }, ] as const; diff --git a/apps/backend/src/modules/plan/handlers/list-plans.handler.ts b/apps/backend/src/modules/plan/handlers/list-plans.handler.ts index de5d328..fe2738b 100644 --- a/apps/backend/src/modules/plan/handlers/list-plans.handler.ts +++ b/apps/backend/src/modules/plan/handlers/list-plans.handler.ts @@ -1,4 +1,6 @@ +import { fetchGeoInfo } from '@/lib/geoip'; import { db } from '@/lib/prisma'; +import { resolvePlanPrice } from '@/modules/plan/services/plan-pricing.service'; import { parsePlanRules } from '@/modules/plan/services/plan-rules.service'; import type { AppContext } from '@/types/hono'; @@ -9,13 +11,26 @@ export async function listPlansHandler(c: AppContext) { }, }); + const countryParam = c.req.query('country'); + let countryCode: string | undefined; + + if (countryParam) { + countryCode = countryParam; + } else { + const ip = c.req.header('x-forwarded-for')?.split(',')[0]?.trim() || '127.0.0.1'; + const geo = await fetchGeoInfo(ip); + countryCode = geo?.countryCode || undefined; + } + return c.json( plans.map((plan) => { const rules = parsePlanRules(plan.rules); + const resolved = resolvePlanPrice(Number(plan.price), rules, countryCode); return { code: plan.code, name: plan.name, - price: Number(plan.price), + price: resolved.amount, + currency: resolved.currency, features: rules.features, limits: rules.limits, }; diff --git a/apps/backend/src/modules/plan/services/plan-pricing.service.ts b/apps/backend/src/modules/plan/services/plan-pricing.service.ts new file mode 100644 index 0000000..965a109 --- /dev/null +++ b/apps/backend/src/modules/plan/services/plan-pricing.service.ts @@ -0,0 +1,25 @@ +import type { PlanRules } from '@repo/api-contract'; + +export type PlanPrice = { + amount: number; + currency: string; +}; + +export function resolvePlanPrice( + defaultPrice: number, + rules: PlanRules, + countryCode?: string +): PlanPrice { + if (!countryCode) { + return { amount: defaultPrice, currency: 'USD' }; + } + + if (rules.version === 'v2' && rules.pricing?.overrides) { + const override = rules.pricing.overrides[countryCode]; + if (override) { + return { amount: override.amount, currency: override.currency }; + } + } + + return { amount: defaultPrice, currency: 'USD' }; +} diff --git a/apps/backend/test/plan/plan-pricing.service.test.ts b/apps/backend/test/plan/plan-pricing.service.test.ts new file mode 100644 index 0000000..8f44fb6 --- /dev/null +++ b/apps/backend/test/plan/plan-pricing.service.test.ts @@ -0,0 +1,87 @@ +import { expect, test } from 'bun:test'; + +import type { PlanRules } from '@repo/api-contract'; + +const DEFAULT_PRICE = 29.99; + +function makeV1Rules(): PlanRules { + return { + version: 'v1', + limits: { maxCourts: 3, maxBookingsPerDay: 50 }, + policies: { allowOverbooking: false }, + features: { + onlinePayments: false, + publicBookingPage: false, + advancedReports: false, + whatsappReminders: false, + }, + }; +} + +function makeV2Rules(overrides?: Record): PlanRules { + return { + version: 'v2', + limits: { maxCourts: 5, maxBookingsPerDay: 100 }, + policies: { allowOverbooking: false }, + features: { + onlinePayments: true, + publicBookingPage: true, + advancedReports: true, + whatsappReminders: true, + }, + ...(overrides ? { pricing: { overrides } } : {}), + }; +} + +const { resolvePlanPrice } = await import('@/modules/plan/services/plan-pricing.service'); + +test('returns default price in USD when no country code is provided', () => { + const result = resolvePlanPrice( + DEFAULT_PRICE, + makeV2Rules({ AR: { amount: 14999, currency: 'ARS' } }) + ); + expect(result).toEqual({ amount: DEFAULT_PRICE, currency: 'USD' }); +}); + +test('returns overridden price for matching country code', () => { + const rules = makeV2Rules({ AR: { amount: 14999, currency: 'ARS' } }); + const result = resolvePlanPrice(DEFAULT_PRICE, rules, 'AR'); + expect(result).toEqual({ amount: 14999, currency: 'ARS' }); +}); + +test('returns default price for country without override', () => { + const rules = makeV2Rules({ AR: { amount: 14999, currency: 'ARS' } }); + const result = resolvePlanPrice(DEFAULT_PRICE, rules, 'CL'); + expect(result).toEqual({ amount: DEFAULT_PRICE, currency: 'USD' }); +}); + +test('returns default price for v1 rules (no pricing config)', () => { + const result = resolvePlanPrice(DEFAULT_PRICE, makeV1Rules(), 'AR'); + expect(result).toEqual({ amount: DEFAULT_PRICE, currency: 'USD' }); +}); + +test('returns default price for v2 rules with empty overrides', () => { + const rules = makeV2Rules({}); + const result = resolvePlanPrice(DEFAULT_PRICE, rules, 'AR'); + expect(result).toEqual({ amount: DEFAULT_PRICE, currency: 'USD' }); +}); + +test('returns default price when v2 rules has no pricing section', () => { + const rules = makeV2Rules(); + const result = resolvePlanPrice(DEFAULT_PRICE, rules, 'AR'); + expect(result).toEqual({ amount: DEFAULT_PRICE, currency: 'USD' }); +}); + +test('selects correct override among multiple countries', () => { + const rules = makeV2Rules({ + AR: { amount: 24999, currency: 'ARS' }, + BR: { amount: 59.99, currency: 'USD' }, + }); + + expect(resolvePlanPrice(DEFAULT_PRICE, rules, 'AR')).toEqual({ amount: 24999, currency: 'ARS' }); + expect(resolvePlanPrice(DEFAULT_PRICE, rules, 'BR')).toEqual({ amount: 59.99, currency: 'USD' }); + expect(resolvePlanPrice(DEFAULT_PRICE, rules, 'US')).toEqual({ + amount: DEFAULT_PRICE, + currency: 'USD', + }); +}); diff --git a/apps/frontend/src/features/admin/plans-page.tsx b/apps/frontend/src/features/admin/plans-page.tsx index fa6b9ef..4715950 100644 --- a/apps/frontend/src/features/admin/plans-page.tsx +++ b/apps/frontend/src/features/admin/plans-page.tsx @@ -9,11 +9,18 @@ import { } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { AdminLayout } from '@/features/admin/admin-layout'; import { ApiClientError, apiClient } from '@/lib/api-client'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useLocation } from '@tanstack/react-router'; -import { BarChart3, Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react'; +import { BarChart3, Pencil, Plus, RefreshCw, Trash2, X } from 'lucide-react'; import { useState } from 'react'; type Plan = { @@ -25,6 +32,61 @@ type Plan = { complexCount: number; }; +type PriceOverride = { + country: string; + amount: string; + currency: string; +}; + +function initOverrides(rules: unknown): PriceOverride[] { + const r = rules as Record | null | undefined; + const pricing = r?.pricing as Record | undefined; + const overrides = pricing?.overrides as + | Record + | undefined; + if (!overrides) return []; + return Object.entries(overrides).map(([country, data]) => ({ + country, + amount: String(data.amount), + currency: data.currency, + })); +} + +function buildRulesData( + mode: 'create' | 'edit', + existingRules: unknown, + priceOverrides: PriceOverride[] +): unknown { + const base = + mode === 'edit' && typeof existingRules === 'object' && existingRules !== null + ? { ...(existingRules as Record) } + : {}; + + const validOverrides = priceOverrides.filter((o) => o.country.trim()); + + if (validOverrides.length > 0) { + const overrides: Record = {}; + for (const o of validOverrides) { + overrides[o.country.trim().toUpperCase()] = { + amount: Number.parseFloat(o.amount), + currency: o.currency, + }; + } + return { + ...base, + version: 'v2', + pricing: { overrides }, + }; + } + + if (mode === 'edit') { + const { pricing: _, ...clean } = base; + return Object.keys(clean).length > 0 ? clean : {}; + } + + return {}; +} + function PlanFormDialog({ mode, plan, @@ -38,6 +100,9 @@ function PlanFormDialog({ const [code, setCode] = useState(plan?.code ?? ''); const [name, setName] = useState(plan?.name ?? ''); const [price, setPrice] = useState(String(plan?.price ?? '')); + const [priceOverrides, setPriceOverrides] = useState(() => + mode === 'edit' && plan ? initOverrides(plan.rules) : [] + ); const createMutation = useMutation({ mutationFn: (data: { code: string; name: string; price: number; rules: object }) => @@ -49,7 +114,7 @@ function PlanFormDialog({ }); const updateMutation = useMutation({ - mutationFn: (data: { name?: string; price?: number }) => + mutationFn: (data: { name?: string; price?: number; rules?: unknown }) => apiClient.admin.updatePlan(plan!.code, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-plans'] }); @@ -62,22 +127,36 @@ function PlanFormDialog({ const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); + const rules = buildRulesData(mode, plan?.rules, priceOverrides); if (mode === 'create') { createMutation.mutate({ code, name, price: Number.parseFloat(price), - rules: {}, + rules: rules as object, }); } else { updateMutation.mutate({ name, price: Number.parseFloat(price), + rules, }); } }; + const addOverride = () => { + setPriceOverrides((prev) => [...prev, { country: '', amount: '', currency: 'ARS' }]); + }; + + const removeOverride = (index: number) => { + setPriceOverrides((prev) => prev.filter((_, i) => i !== index)); + }; + + const updateOverride = (index: number, field: keyof PriceOverride, value: string) => { + setPriceOverrides((prev) => prev.map((o, i) => (i === index ? { ...o, [field]: value } : o))); + }; + return ( !open && onClose()}> @@ -118,7 +197,9 @@ function PlanFormDialog({
- +
+
+
+ + +
+ + {priceOverrides.map((override, index) => ( +
+
+ + updateOverride(index, 'country', e.target.value)} + placeholder="AR" + maxLength={2} + className="uppercase" + /> +
+
+ + updateOverride(index, 'amount', e.target.value)} + placeholder="14999" + /> +
+
+ + +
+ +
+ ))} +
+ {error && (

{error instanceof ApiClientError @@ -292,42 +439,60 @@ export function PlansPage() { - {data?.map((plan) => ( - - - - {plan.code} - - - {plan.name} - - ${Number(plan.price).toFixed(2)} - - - {plan.complexCount} - - -

- - -
- - - ))} + {data?.map((plan) => { + const rules = plan.rules as Record | null; + const pricing = rules?.pricing as Record | undefined; + const overrides = pricing?.overrides as Record | undefined; + const overrideCountries = overrides ? Object.keys(overrides) : []; + + return ( + + + + {plan.code} + + + {plan.name} + +
+ ${Number(plan.price).toFixed(2)} USD + {overrideCountries.length > 0 && ( +
+ {overrideCountries.map((country) => ( + + {country} + + ))} +
+ )} +
+ + + {plan.complexCount} + + +
+ + +
+ + + ); + })} diff --git a/apps/frontend/src/features/onboard/components/steps/plan-select-step.tsx b/apps/frontend/src/features/onboard/components/steps/plan-select-step.tsx index 433d5fe..8947d69 100644 --- a/apps/frontend/src/features/onboard/components/steps/plan-select-step.tsx +++ b/apps/frontend/src/features/onboard/components/steps/plan-select-step.tsx @@ -58,7 +58,11 @@ export function PlanSelectStep({ form, plans }: PlanSelectStepProps) {

{plan.name}

- ${plan.price} + + {plan.currency === 'ARS' + ? `AR$${plan.price.toLocaleString('es-AR')}` + : `$${plan.price.toFixed(2)}`} + /mes
diff --git a/apps/frontend/src/features/onboard/components/steps/review-step.tsx b/apps/frontend/src/features/onboard/components/steps/review-step.tsx index fb5f636..004fd21 100644 --- a/apps/frontend/src/features/onboard/components/steps/review-step.tsx +++ b/apps/frontend/src/features/onboard/components/steps/review-step.tsx @@ -50,7 +50,14 @@ export function ReviewStep({ data, selectedPlan }: ReviewStepProps) {
{selectedPlan ? ( <> - +
{[ ['publicBookingPage', 'Página de booking pública'], diff --git a/bunfig.toml b/bunfig.toml index 1ef362d..9d07ee7 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,2 +1,3 @@ [test] +exclude = ["**/dist/**", "**/node_modules/**"] preload = ["./apps/backend/test/support/prisma.mock.ts"] diff --git a/packages/api-contract/src/plan.ts b/packages/api-contract/src/plan.ts index 27104ed..c2799df 100644 --- a/packages/api-contract/src/plan.ts +++ b/packages/api-contract/src/plan.ts @@ -33,7 +33,24 @@ export const planRulesV1Schema = z.object({ }), }) -export const planRulesSchema = z.discriminatedUnion('version', [planRulesV1Schema]) +export const planPriceOverrideSchema = z.object({ + amount: z.number().nonnegative(), + currency: z.string().length(3), +}) + +export const planRulesV2Schema = planRulesV1Schema.extend({ + version: z.literal('v2'), + pricing: z + .object({ + overrides: z.record(z.string(), planPriceOverrideSchema), + }) + .optional(), +}) + +export const planRulesSchema = z.discriminatedUnion('version', [ + planRulesV1Schema, + planRulesV2Schema, +]) export const planSchema = z.object({ code: z.string().trim().min(1).max(10), @@ -60,12 +77,14 @@ export const planWithFeaturesSchema = z.object({ code: z.string().trim().min(1).max(10), name: z.string().trim().min(1).max(30), price: z.number().nonnegative(), + currency: z.string().length(3), features: planFeatureFlagsSchema, limits: planLimitsSchema, }) export type PlanFeatureFlags = z.infer export type PlanRulesV1 = z.infer +export type PlanRulesV2 = z.infer export type PlanRules = z.infer export type Plan = z.infer export type PlanSummary = z.infer