From b3ff385fcb9eefb17a80ab22354425ce33e3400b Mon Sep 17 00:00:00 2001 From: Pedro Ivo Date: Mon, 17 Aug 2026 16:52:08 -0300 Subject: [PATCH] fix: verify paid entitlement in CLI checkout --- src/services/api/verbooCheckout.test.ts | 44 ++++++++++ src/services/api/verbooCheckout.ts | 42 ++++++++-- src/services/oauth/purchaseFlow.test.ts | 24 ++++++ src/services/oauth/purchaseFlow.tsx | 106 +++++++++++++++++++----- 4 files changed, 189 insertions(+), 27 deletions(-) diff --git a/src/services/api/verbooCheckout.test.ts b/src/services/api/verbooCheckout.test.ts index 637efe28d3..62c138f8de 100644 --- a/src/services/api/verbooCheckout.test.ts +++ b/src/services/api/verbooCheckout.test.ts @@ -5,6 +5,7 @@ import { confirmCardlessTrial, createCheckoutSession, getWhatsAppProfile, + hasGroupSubscriptionEntitlement, isWooviSubscriptionActive, startCardlessTrial, } from './verbooCheckout.js' @@ -15,6 +16,7 @@ const GROUP_ID = '11111111-1111-4111-8111-111111111111' const OTHER_GROUP_ID = '22222222-2222-4222-8222-222222222222' const VERIFICATION_ID = '33333333-3333-4333-8333-333333333333' const SUBSCRIPTION_ID = '44444444-4444-4444-8444-444444444444' +const ATTEMPT_ID = '55555555-5555-4555-8555-555555555555' afterEach(() => { axios.post = originalPost @@ -26,6 +28,7 @@ test('sends the explicit Woovi method and payer data to checkout', async () => { data: { data: { mode: 'woovi' as const, + attemptId: ATTEMPT_ID, wooviQrCode: '000201', wooviSubscriptionId: 'woovi-subscription', }, @@ -40,6 +43,7 @@ test('sends the explicit Woovi method and payer data to checkout', async () => { }), ).resolves.toEqual({ mode: 'woovi', + attemptId: ATTEMPT_ID, wooviQrCode: '000201', wooviSubscriptionId: 'woovi-subscription', }) @@ -58,6 +62,46 @@ test('sends the explicit Woovi method and payer data to checkout', async () => { ) }) +test('paid entitlement never treats an active trial as a completed purchase', () => { + const trial = { + id: SUBSCRIPTION_ID, + groupId: GROUP_ID, + source: 'stripe_trial', + status: 'trialing', + cancelAtPeriodEnd: false, + } + expect(hasGroupSubscriptionEntitlement([trial], GROUP_ID, 'access')).toBe(true) + expect(hasGroupSubscriptionEntitlement([trial], GROUP_ID, 'paid')).toBe(false) + expect( + hasGroupSubscriptionEntitlement( + [{ ...trial, source: 'stripe', status: 'active' }], + GROUP_ID, + 'paid', + ), + ).toBe(true) + expect( + hasGroupSubscriptionEntitlement( + [{ ...trial, source: 'stripe_trial', status: 'active' }], + GROUP_ID, + 'paid', + ), + ).toBe(true) + expect( + hasGroupSubscriptionEntitlement( + [{ ...trial, source: 'woovi', status: 'active' }], + GROUP_ID, + 'paid', + ), + ).toBe(true) + expect( + hasGroupSubscriptionEntitlement( + [{ ...trial, source: 'manual', status: 'active' }], + GROUP_ID, + 'paid', + ), + ).toBe(false) +}) + test('confirms only the Woovi subscription that became active', async () => { const get = mock(async () => ({ data: { diff --git a/src/services/api/verbooCheckout.ts b/src/services/api/verbooCheckout.ts index 191fd508d4..46e636c341 100644 --- a/src/services/api/verbooCheckout.ts +++ b/src/services/api/verbooCheckout.ts @@ -20,10 +20,17 @@ const httpUrlSchema = z ) const checkoutResultSchema = z.discriminatedUnion('mode', [ - z.object({ mode: z.literal('stripe'), url: httpUrlSchema }).passthrough(), + z + .object({ + mode: z.literal('stripe'), + attemptId: z.string().uuid(), + url: httpUrlSchema, + }) + .passthrough(), z .object({ mode: z.literal('woovi'), + attemptId: z.string().uuid(), wooviQrCode: z.string().min(1), wooviSubscriptionId: z.string().min(1), }) @@ -120,6 +127,7 @@ export type CheckoutInput = { export type WhatsAppProfile = z.infer export type CardlessTrialInput = z.input export type CardlessTrialResult = z.infer +export type GroupEntitlementRequirement = 'access' | 'paid' function authHeaders(accessToken: string): Record { return { @@ -258,12 +266,34 @@ export async function resendCardlessTrialCode( export async function isGroupSubscriptionActive( accessToken: string, groupId: string, - opts: { signal?: AbortSignal } = {}, + opts: { + signal?: AbortSignal + requirement?: GroupEntitlementRequirement + } = {}, ): Promise { const subscriptions = await fetchSubscriptions(accessToken, opts) - return subscriptions.some( - (sub) => - sub.groupId === groupId && - (sub.status === 'active' || sub.status === 'trialing'), + return hasGroupSubscriptionEntitlement( + subscriptions, + groupId, + opts.requirement ?? 'access', ) } + +export function hasGroupSubscriptionEntitlement( + subscriptions: Awaited>, + groupId: string, + requirement: GroupEntitlementRequirement, +): boolean { + return subscriptions.some((subscription) => { + if (subscription.groupId !== groupId) return false + if (requirement === 'access') { + return subscription.status === 'active' || subscription.status === 'trialing' + } + return ( + subscription.status === 'active' && + (subscription.source === 'stripe' || + subscription.source === 'stripe_trial' || + subscription.source === 'woovi') + ) + }) +} diff --git a/src/services/oauth/purchaseFlow.test.ts b/src/services/oauth/purchaseFlow.test.ts index e4331f1483..3589e4bd0c 100644 --- a/src/services/oauth/purchaseFlow.test.ts +++ b/src/services/oauth/purchaseFlow.test.ts @@ -6,6 +6,7 @@ import { filterCliPurchasablePlans, getPlanColumnCount, getPlanDetailOptions, + getStripeTrialConversionUrl, movePlanFocus, } from './purchaseFlow.js' import { isValidCPF, onlyDigits } from './purchaseValidation.js' @@ -85,6 +86,18 @@ test('offers both free trial and immediate paid purchase when eligible', () => { ]) }) +test('never advertises a trial for an annual offer', () => { + const annual = plan({ + billingInterval: 'year', + trialDays: 7, + trialEligible: true, + }) + expect(getPlanDetailOptions(annual).map((option) => option.value)).toEqual([ + 'buy', + 'back', + ]) +}) + test('keeps an active local trial available for paid conversion even when full', () => { const trialPlan = plan({ isMember: true, @@ -99,6 +112,17 @@ test('keeps an active local trial available for paid conversion even when full', ).toEqual([trialPlan]) }) +test('routes a Stripe trial to the dedicated full-price conversion journey', () => { + expect( + getStripeTrialConversionUrl( + '22222222-2222-4222-8222-222222222222', + 'year', + ), + ).toBe( + 'https://code.verboo.ai/pt/settings/billing?subscription=22222222-2222-4222-8222-222222222222&action=convert&billingInterval=year', + ) +}) + test('keeps legacy local trials available for paid conversion', () => { const trialPlan = plan({ isMember: true }) expect( diff --git a/src/services/oauth/purchaseFlow.tsx b/src/services/oauth/purchaseFlow.tsx index a92bd4b812..b07b8e870f 100644 --- a/src/services/oauth/purchaseFlow.tsx +++ b/src/services/oauth/purchaseFlow.tsx @@ -10,6 +10,7 @@ import { import { Select } from '../../components/CustomSelect/select.js' import { Spinner } from '../../components/Spinner.js' import TextInput from '../../components/TextInput.js' +import { VERBOO_FRONT_BASE_URL } from '../../constants/oauth.js' import { useTerminalSize } from '../../hooks/useTerminalSize.js' import { Box, render, Text, useInput } from '../../ink.js' import { AppStateProvider } from '../../state/AppState.js' @@ -23,6 +24,7 @@ import { resendCardlessTrialCode, startCardlessTrial, type CardlessTrialResult, + type GroupEntitlementRequirement, type PaymentMethod, type WhatsAppProfile, type WooviCheckoutData, @@ -79,6 +81,13 @@ function isCurrentLocalTrial(subscription?: SubscriptionResponse): boolean { ) } +export function getStripeTrialConversionUrl( + subscriptionId: string, + billingInterval: 'month' | 'year', +): string { + return `${VERBOO_FRONT_BASE_URL}/pt/settings/billing?subscription=${encodeURIComponent(subscriptionId)}&action=convert&billingInterval=${encodeURIComponent(billingInterval)}` +} + export function filterCliPurchasablePlans( groups: MarketplaceGroup[], subscriptions: SubscriptionResponse[], @@ -152,6 +161,7 @@ function hasCardlessTrial(group: MarketplaceGroup): boolean { return Boolean( group.trialEligible && group.trialDays && + group.billingInterval === 'month' && group.trialPaymentMethodRequired === false && group.paymentProvider !== 'woovi', ) @@ -161,6 +171,7 @@ function hasCardTrial(group: MarketplaceGroup): boolean { return Boolean( group.trialEligible && group.trialDays && + group.billingInterval === 'month' && group.trialPaymentMethodRequired !== false && group.paymentProvider !== 'woovi', ) @@ -636,6 +647,9 @@ export function PurchaseFlowView({ const columnCount = getPlanColumnCount(terminalColumns) const [step, setStep] = useState('splash') const [plans, setPlans] = useState([]) + const [subscriptionsByGroup, setSubscriptionsByGroup] = useState< + Map + >(new Map()) const [selectedPlan, setSelectedPlan] = useState( null, ) @@ -649,6 +663,10 @@ export function PurchaseFlowView({ const [manualCheckoutUrl, setManualCheckoutUrl] = useState( null, ) + const [manualEntitlementRequirement, setManualEntitlementRequirement] = + useState('paid') + const [successRequirement, setSuccessRequirement] = + useState('paid') const [whatsappProfile, setWhatsAppProfile] = useState(null) const [cardlessVerification, setCardlessVerification] = @@ -679,11 +697,15 @@ export function PurchaseFlowView({ [], ) - const complete = useCallback(() => { - setStep('success') - if (successTimerRef.current) clearTimeout(successTimerRef.current) - successTimerRef.current = setTimeout(() => onDone(true), 1_500) - }, [onDone]) + const complete = useCallback( + (requirement: GroupEntitlementRequirement) => { + setSuccessRequirement(requirement) + setStep('success') + if (successTimerRef.current) clearTimeout(successTimerRef.current) + successTimerRef.current = setTimeout(() => onDone(true), 1_500) + }, + [onDone], + ) React.useEffect( () => () => { @@ -721,6 +743,15 @@ export function PurchaseFlowView({ ]) if (plansRequestRef.current !== controller) return const eligible = filterCliPurchasablePlans(groups, subscriptions) + setSubscriptionsByGroup( + new Map( + subscriptions + .filter((subscription) => + ['active', 'trialing', 'past_due'].includes(subscription.status), + ) + .map((subscription) => [subscription.groupId, subscription]), + ), + ) if (eligible.length === 0) { const message = groups.length === 0 @@ -755,6 +786,7 @@ export function PurchaseFlowView({ async function pollEntitlement( groupId: string, displayStep: 'polling' | 'cardless-polling' = 'polling', + requirement: GroupEntitlementRequirement = 'paid', ) { pollingRef.current?.abort() const controller = new AbortController() @@ -769,9 +801,10 @@ export function PurchaseFlowView({ try { const active = await isGroupSubscriptionActive(accessToken, groupId, { signal: controller.signal, + requirement, }) if (active) { - if (pollingRef.current === controller) complete() + if (pollingRef.current === controller) complete(requirement) return } } catch (error) { @@ -804,7 +837,7 @@ export function PurchaseFlowView({ 'plan-detail', { label: 'Verificar novamente', - run: () => void pollEntitlement(groupId, displayStep), + run: () => void pollEntitlement(groupId, displayStep, requirement), }, ) } @@ -864,7 +897,7 @@ export function PurchaseFlowView({ setCardlessVerification(result) setStep('whatsapp-code') } else { - void startEntitlementPolling(group.id, 'cardless-polling') + void startEntitlementPolling(group.id, 'cardless-polling', 'access') } } catch (error) { const presentation = describePurchaseError( @@ -913,7 +946,7 @@ export function PurchaseFlowView({ code, ) if (result.mode === 'trial_activated') { - void startEntitlementPolling(group.id, 'cardless-polling') + void startEntitlementPolling(group.id, 'cardless-polling', 'access') } else { setCardlessVerification(result) setStep('whatsapp-code') @@ -986,6 +1019,7 @@ export function PurchaseFlowView({ group: MarketplaceGroup, paymentMethod: PaymentMethod, woovi?: WooviCheckoutData, + requirement: GroupEntitlementRequirement = 'paid', ) { setInlineMessage(null) setStep('checkout') @@ -995,7 +1029,7 @@ export function PurchaseFlowView({ woovi, }) if (result.mode === 'reactivated') { - void startEntitlementPolling(group.id) + void startEntitlementPolling(group.id, 'polling', requirement) return } if (result.mode === 'woovi') { @@ -1008,8 +1042,9 @@ export function PurchaseFlowView({ } setManualCheckoutUrl(result.url) + setManualEntitlementRequirement(requirement) if (await openBrowser(result.url)) { - void startEntitlementPolling(group.id) + void startEntitlementPolling(group.id, 'polling', requirement) } else { setStep('manual-browser') } @@ -1038,7 +1073,7 @@ export function PurchaseFlowView({ presentation.code === 'manual_access_active' ) { setInlineMessage(presentation.message) - void startEntitlementPolling(group.id) + void startEntitlementPolling(group.id, 'polling', requirement) return } if ( @@ -1053,7 +1088,7 @@ export function PurchaseFlowView({ } showError(presentation.message, 'plan-detail', { label: 'Tentar checkout novamente', - run: () => void runCheckout(group, paymentMethod, woovi), + run: () => void runCheckout(group, paymentMethod, woovi, requirement), }) } }, @@ -1061,14 +1096,33 @@ export function PurchaseFlowView({ ) const startPaidPurchase = useCallback( - (group: MarketplaceGroup) => { + async (group: MarketplaceGroup) => { setSelectedPlan(group) setInlineMessage(null) + const currentSubscription = subscriptionsByGroup.get(group.id) + if ( + currentSubscription?.source === 'stripe_trial' && + currentSubscription.status === 'trialing' + ) { + const conversionUrl = getStripeTrialConversionUrl( + currentSubscription.id, + group.billingInterval, + ) + setManualCheckoutUrl(conversionUrl) + setManualEntitlementRequirement('paid') + setStep('checkout') + if (await openBrowser(conversionUrl)) { + void startEntitlementPolling(group.id, 'polling', 'paid') + } else { + setStep('manual-browser') + } + return + } if (group.paymentProvider === 'both') setStep('payment-method') else if (group.paymentProvider === 'woovi') setStep('woovi-form') else void handleCheckout(group, 'stripe') }, - [handleCheckout], + [handleCheckout, startEntitlementPolling, subscriptionsByGroup], ) const cancelPlansLoading = useCallback(() => { @@ -1207,7 +1261,8 @@ export function PurchaseFlowView({ {getSlotsInfo(plan)} {paymentProviderLabel(plan)} - {plan.trialEligible && plan.trialDays ? ( + {(hasCardlessTrial(plan) || hasCardTrial(plan)) && + plan.trialDays ? ( {plan.trialDays} dias de teste {hasCardlessTrial(plan) @@ -1252,7 +1307,8 @@ export function PurchaseFlowView({ Modelos: {getModelNames(plan)} Assinantes: {getSlotsInfo(plan)} Pagamento: {paymentProviderLabel(plan)} - {plan.trialEligible && plan.trialDays ? ( + {(hasCardlessTrial(plan) || hasCardTrial(plan)) && + plan.trialDays ? ( Teste: {plan.trialDays} dias {hasCardlessTrial(plan) @@ -1267,9 +1323,9 @@ export function PurchaseFlowView({ onChange={(value: string) => { if (value === 'trial') void prepareCardlessTrial(plan) else if (value === 'card-trial') - void handleCheckout(plan, 'stripe') + void handleCheckout(plan, 'stripe', undefined, 'access') else if (value === 'pix') setStep('woovi-form') - else if (value === 'buy') startPaidPurchase(plan) + else if (value === 'buy') void startPaidPurchase(plan) else setStep('plans') }} /> @@ -1445,7 +1501,11 @@ export function PurchaseFlowView({ ]} onChange={(value: string) => { if (value === 'verify' && selectedPlan) { - void startEntitlementPolling(selectedPlan.id) + void startEntitlementPolling( + selectedPlan.id, + 'polling', + manualEntitlementRequirement, + ) } else if (value === 'back') setStep('plan-detail') else onDone(false) }} @@ -1473,7 +1533,11 @@ export function PurchaseFlowView({ case 'success': return ( - Assinatura confirmada! Modelos disponíveis. + + {successRequirement === 'access' + ? 'Trial ativado! Modelos disponíveis.' + : 'Assinatura paga confirmada! Modelos disponíveis.'} + ) case 'error':