From 10cd327289302e7ed9167ad7c5ee5eb92d5c2c23 Mon Sep 17 00:00:00 2001 From: Pedro Ivo Date: Sat, 19 Sep 2026 19:56:23 -0300 Subject: [PATCH 1/2] fix: preserve purchase attempts and add CLI journey diagnostics --- package.json | 2 +- src/services/api/revenueJourney.ts | 20 ++++++ src/services/api/verbooApiError.ts | 8 +-- src/services/api/verbooCheckout.test.ts | 37 +++++++++- src/services/api/verbooCheckout.ts | 89 +++++++++++++++++++++---- src/services/oauth/purchaseErrors.ts | 7 ++ src/services/oauth/purchaseFlow.tsx | 57 +++++++++++----- 7 files changed, 184 insertions(+), 36 deletions(-) create mode 100644 src/services/api/revenueJourney.ts diff --git a/package.json b/package.json index c8027866bf..5bb9f862e0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@verboo/code", - "version": "0.15.27", + "version": "0.15.28", "description": "Verboo Code — coding agent for the Verboo platform", "type": "module", "bin": { diff --git a/src/services/api/revenueJourney.ts b/src/services/api/revenueJourney.ts new file mode 100644 index 0000000000..0905729071 --- /dev/null +++ b/src/services/api/revenueJourney.ts @@ -0,0 +1,20 @@ +import { randomUUID } from 'node:crypto' +import { logForDebugging } from '../../utils/debug.js' + +// CLI diagnostics contain only finite labels and internal random references. +// The same IDs are sent to the API's authenticated Revenue Journey observer. +const codes = new Set(['purchase_attempt_failed', 'purchase_attempt_expired', 'purchase_attempt_review', 'checkout_intent_required', 'checkout_in_progress', 'checkout_request_conflict', 'new_acquisition_disabled', 'group_full', 'already_subscribed', 'manual_access_active', 'payment_past_due', 'invalid_request', 'rate_limited', 'network_error', 'request_timeout', 'request_cancelled', 'contract_error']) +const journeys = new Map() +export function commercialOperation(stage: 'checkout_request' | 'trial_activation' | 'email_verification' | 'catalog' | 'payment_return', operationId: string = randomUUID()) { + let journeyId = journeys.get(operationId) + if (!journeyId) {journeyId = randomUUID();journeys.set(operationId, journeyId)} + const write = (outcome: 'started' | 'succeeded' | 'failed', error?: unknown) => { + try { + const e = error as {code?: string; kind?: string; status?: number} | undefined + const code = e?.code && codes.has(e.code) ? e.code : e?.kind === 'contract' ? 'contract_error' : error ? 'unknown' : '' + logForDebugging(JSON.stringify({event_domain: 'revenue_journey',schema_version: 2,journey_id: journeyId,operation_id: operationId,stage,outcome,error_code: code,...(e?.status ? {http_status: e.status} : {})})) + } catch { /* Diagnostics cannot block a purchase. */ } + } + write('started') + return {headers: {'X-Verboo-Journey-Id': journeyId, 'X-Verboo-Operation-Id': operationId}, complete: () => write('succeeded'), fail: (error: unknown) => write('failed', error)} +} diff --git a/src/services/api/verbooApiError.ts b/src/services/api/verbooApiError.ts index f1906303c8..f8fa3c4a3d 100644 --- a/src/services/api/verbooApiError.ts +++ b/src/services/api/verbooApiError.ts @@ -71,15 +71,15 @@ export function toVerbooApiError( } return new VerbooApiError({ message: fallbackMessage, - kind: 'network', - code: 'network_error', + kind: error.code === 'ERR_CANCELED' ? 'request' : 'network', + code: error.code === 'ERR_CANCELED' ? 'request_cancelled' : ['ECONNABORTED','ETIMEDOUT'].includes(error.code ?? '') ? 'request_timeout' : 'network_error', }) } return new VerbooApiError({ message: fallbackMessage, - kind: 'network', - code: 'network_error', + kind: 'request', + code: 'unknown', }) } diff --git a/src/services/api/verbooCheckout.test.ts b/src/services/api/verbooCheckout.test.ts index 62c138f8de..63dc280cd0 100644 --- a/src/services/api/verbooCheckout.test.ts +++ b/src/services/api/verbooCheckout.test.ts @@ -50,10 +50,12 @@ test('sends the explicit Woovi method and payer data to checkout', async () => { expect(post).toHaveBeenCalledWith( `https://code.verboo.ai/api/me/groups/${GROUP_ID}/checkout`, - { + expect.objectContaining({ paymentMethod: 'woovi', + requestId: expect.any(String), + purchaseIntent: 'new', woovi: { taxId: '52998224725', phone: '11999999999' }, - }, + }), expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer access-token', @@ -273,3 +275,34 @@ test('rejects invalid Woovi payer data before sending a request', async () => { ).rejects.toMatchObject({ code: 'invalid_request' }) expect(post).not.toHaveBeenCalled() }) + +test('the exact purchase attempt confirms payment, never an existing group membership',async()=>{ + const {isPurchaseAttemptSucceeded}=await import('./verbooCheckout.js') + const get=mock(async()=>({data:{data:{id:ATTEMPT_ID,groupId:GROUP_ID,status:'pending'}}})) + axios.get=get as typeof axios.get + expect(await isPurchaseAttemptSucceeded('token',ATTEMPT_ID,GROUP_ID)).toBe(false) + get.mockResolvedValueOnce({data:{data:{id:ATTEMPT_ID,groupId:GROUP_ID,status:'succeeded'}}}) + expect(await isPurchaseAttemptSucceeded('token',ATTEMPT_ID,GROUP_ID)).toBe(true) + get.mockResolvedValueOnce({data:{data:{id:ATTEMPT_ID,groupId:OTHER_GROUP_ID,status:'succeeded'}}}) + await expect(isPurchaseAttemptSucceeded('token',ATTEMPT_ID,GROUP_ID)).rejects.toMatchObject({kind:'contract'}) + expect(get.mock.calls.every(call=>String(call[0]).endsWith('/purchase-attempts/'+ATTEMPT_ID))).toBe(true) +}) + +test('terminal attempts stop polling without inferring access',async()=>{ + const {isPurchaseAttemptSucceeded}=await import('./verbooCheckout.js') + for (const status of ['failed','expired','review']) { + axios.get=(async()=>({data:{data:{id:ATTEMPT_ID,groupId:GROUP_ID,status}}})) as typeof axios.get + await expect(isPurchaseAttemptSucceeded('token',ATTEMPT_ID,GROUP_ID)).rejects.toMatchObject({code:`purchase_attempt_${status}`}) + } +}) +test('manual retry preserves request and journey across token refresh for the same actor',async()=>{ + const requests: unknown[][]=[] + axios.post=(async(...args:unknown[])=>{requests.push(args);return {data:{data:{mode:'stripe',attemptId:ATTEMPT_ID,url:'https://checkout.stripe.com/original'}}}}) as typeof axios.post + const jwt=(sub:string,version:number)=>'header.'+Buffer.from(JSON.stringify({sub,version})).toString('base64url')+'.signature' + await createCheckoutSession(jwt(GROUP_ID,1),GROUP_ID,{paymentMethod:'stripe',billingInterval:'month'}) + await createCheckoutSession(jwt(GROUP_ID,2),GROUP_ID,{paymentMethod:'stripe',billingInterval:'month'}) + expect(requests[0][1]).toEqual(requests[1][1]) + expect((requests[0][2] as {headers:Record}).headers['X-Verboo-Journey-Id']).toBe((requests[1][2] as {headers:Record}).headers['X-Verboo-Journey-Id']) + await createCheckoutSession(jwt(OTHER_GROUP_ID,1),GROUP_ID,{paymentMethod:'stripe',billingInterval:'month'}) + expect((requests[2][1] as {requestId:string}).requestId).not.toBe((requests[0][1] as {requestId:string}).requestId) +}) diff --git a/src/services/api/verbooCheckout.ts b/src/services/api/verbooCheckout.ts index 46e636c341..d475d050af 100644 --- a/src/services/api/verbooCheckout.ts +++ b/src/services/api/verbooCheckout.ts @@ -1,11 +1,13 @@ -import axios from 'axios' +import axios, { type AxiosRequestConfig } from 'axios' +import { setTimeout as pause } from 'node:timers/promises' +import { randomUUID, createHash } from 'node:crypto' +import { commercialOperation } from './revenueJourney.js' import { z } from 'zod' import { VERBOO_API_BASE_URL } from '../../constants/oauth.js' -import { logForDebugging } from '../../utils/debug.js' -import { logError } from '../../utils/log.js' import { isValidCPF } from '../oauth/purchaseValidation.js' import { + VerbooApiError, parseApiEnvelope, parseRequest, toVerbooApiError, @@ -41,6 +43,9 @@ const checkoutResultSchema = z.discriminatedUnion('mode', [ const checkoutInputSchema = z .object({ paymentMethod: z.enum(['stripe', 'woovi']), + billingInterval: z.enum(['month', 'year']).optional(), + requestId: z.string().uuid().optional(), + purchaseIntent: z.enum(['new', 'additional']).optional(), woovi: z .object({ taxId: z @@ -121,6 +126,9 @@ export type CheckoutResult = z.infer export type PaymentMethod = 'stripe' | 'woovi' export type WooviCheckoutData = { taxId: string; phone: string } export type CheckoutInput = { + billingInterval?: 'month' | 'year' + requestId?: string + purchaseIntent?: 'new' | 'additional' paymentMethod: PaymentMethod woovi?: WooviCheckoutData } @@ -142,39 +150,96 @@ async function postAndParse( body: unknown, schema: z.ZodType, contractName: string, + operationId?: string, ): Promise { + const observation = commercialOperation(endpoint.endsWith('/checkout') ? 'checkout_request' : 'trial_activation', operationId) try { const response = await axios.post(endpoint, body, { - headers: authHeaders(accessToken), - timeout: 15_000, + headers: {...authHeaders(accessToken), ...observation.headers}, + timeout: 10_000, }) - return parseApiEnvelope(schema, response.data, contractName) + const result = parseApiEnvelope(schema, response.data, contractName) + observation.complete() + return result } catch (error) { const apiError = toVerbooApiError( error, `Não foi possível concluir ${contractName}.`, ) - logError(apiError) - logForDebugging( - `[Checkout] ${apiError.code ?? apiError.kind}: ${apiError.message}`, - ) + observation.fail(apiError) throw apiError } } +const checkoutRequests = new Map() +function checkoutRequest(accessToken: string, groupId: string, input: CheckoutInput): string { + if (input.requestId) return input.requestId + let actor = accessToken + try { + const claims = JSON.parse(Buffer.from(accessToken.split('.')[1] ?? '', 'base64url').toString('utf8')) + if (typeof claims.sub === 'string' && z.string().uuid().safeParse(claims.sub).success) actor = claims.sub + } catch { /* Opaque tokens remain bound to their exact authenticated token. */ } + const binding = createHash('sha256').update(JSON.stringify([actor, groupId, input])).digest('hex') + let id = checkoutRequests.get(binding) + if (!id) {id = randomUUID();checkoutRequests.set(binding, id)} + return id +} + +async function commercialGet(url: string, config: AxiosRequestConfig) { + const started = Date.now() + for (let attempt = 1; ; attempt++) { + try { return await axios.get(url, {...config, timeout: Math.min(10_000, 35_000-(Date.now()-started))}) } + catch (error) { + const status = axios.isAxiosError(error) ? error.response?.status : undefined + const retryable = axios.isAxiosError(error) && error.code !== 'ERR_CANCELED' && (!status || [408,429,500,502,503,504].includes(status)) + const header = axios.isAxiosError(error) ? error.response?.headers?.['retry-after'] : undefined + const seconds = header === undefined ? NaN : Number(header) + const retryAfter = Number.isFinite(seconds) ? seconds*1000 : typeof header==='string' ? Date.parse(header)-Date.now() : 0 + const delay = Math.max(250*attempt, Number.isFinite(retryAfter) ? retryAfter : 0) + if (!retryable || attempt===3 || config.signal?.aborted || Date.now()-started+delay+10_000>35_000) throw error + await pause(delay, undefined, {signal:config.signal as AbortSignal | undefined}) + } + } +} + +export async function getPurchaseOptions(accessToken: string, groupId: string, billingInterval: 'month' | 'year') { + const observation = commercialOperation('catalog') + try { + const response = await commercialGet(`${VERBOO_API_BASE_URL}/api/me/groups/${groupId}/purchase-options`, {headers: {...authHeaders(accessToken), ...observation.headers},params: {billingInterval},timeout: 10_000}) + const result = parseApiEnvelope(z.object({version: z.literal(1),groupId: z.string().uuid(),billingInterval: z.enum(['month', 'year']),recommendation: z.enum(['checkout', 'choose', 'change', 'manage', 'convert', 'resume', 'recover', 'support'])}),response.data,'opções de compra') + if (result.groupId !== groupId || result.billingInterval !== billingInterval) throw new VerbooApiError({kind:'contract',code:'contract_error',message:'Opções de compra incompatíveis.'}) + observation.complete();return result + } catch (error) {observation.fail(error);throw toVerbooApiError(error,'Não foi possível consultar as opções de compra.')} +} +export async function isPurchaseAttemptSucceeded(accessToken: string, attemptId: string, groupId: string, signal?: AbortSignal): Promise { + const observation = commercialOperation('payment_return', attemptId) + try { + const response = await commercialGet(`${VERBOO_API_BASE_URL}/api/me/purchase-attempts/${attemptId}`, {headers: {...authHeaders(accessToken), ...observation.headers},signal,timeout:10_000}) + const attempt = parseApiEnvelope(z.object({id:z.string().uuid(),groupId:z.string().uuid(),status:z.enum(['pending','requires_action','succeeded','failed','expired','review'])}),response.data,'confirmação da compra') + if (attempt.id !== attemptId || attempt.groupId !== groupId) throw new VerbooApiError({kind:'contract',code:'contract_error',message:'Identidade de compra incompatível.'}) + if (['failed','expired','review'].includes(attempt.status)) throw new VerbooApiError({kind:'request',code:`purchase_attempt_${attempt.status}`,message:'A compra exige revisão no billing.'}) + observation.complete();return attempt.status === 'succeeded' + } catch (error) {observation.fail(error);throw toVerbooApiError(error,'Não foi possível confirmar esta compra.')} +} + export async function createCheckoutSession( accessToken: string, groupId: string, input: CheckoutInput, ): Promise { - parseRequest(z.string().uuid(), groupId, 'checkout') - const request = parseRequest(checkoutInputSchema, input, 'checkout') + const requestId = checkoutRequest(accessToken,groupId,input) + let request: z.infer + try { + parseRequest(z.string().uuid(), groupId, 'checkout') + request = parseRequest(checkoutInputSchema, {...input, requestId, purchaseIntent: input.purchaseIntent ?? 'new'}, 'checkout') + } catch (error) { commercialOperation('checkout_request', requestId).fail(error); throw error } return postAndParse( `${VERBOO_API_BASE_URL}/api/me/groups/${groupId}/checkout`, accessToken, request, checkoutResultSchema, 'o checkout', + requestId, ) } diff --git a/src/services/oauth/purchaseErrors.ts b/src/services/oauth/purchaseErrors.ts index 47dcca7cfe..300c24abcc 100644 --- a/src/services/oauth/purchaseErrors.ts +++ b/src/services/oauth/purchaseErrors.ts @@ -1,6 +1,13 @@ import { VerbooApiError } from '../api/verbooApiError.js' const BUSINESS_MESSAGES: Record = { + purchase_attempt_failed: 'Esta compra não foi concluída. Consulte a tentativa no billing antes de tentar novamente.', + purchase_attempt_expired: 'Esta tentativa expirou. Consulte o billing para iniciar uma nova compra.', + purchase_attempt_review: 'Esta compra está em conciliação. Consulte o billing ou o suporte; não é necessário iniciar outro pagamento.', + checkout_intent_required: 'Você já tem um contrato. Continue no billing para trocar ou confirmar uma assinatura adicional.', + checkout_in_progress: 'Sua compra está em processamento. Retome a mesma tentativa no billing.', + checkout_request_conflict: 'Esta tentativa pertence a outra seleção. Consulte o billing antes de iniciar outra compra.', + trial_unavailable: 'Este teste já foi utilizado ou não está mais disponível.', group_full: 'Este plano atingiu o limite de assinantes.', manual_access_active: diff --git a/src/services/oauth/purchaseFlow.tsx b/src/services/oauth/purchaseFlow.tsx index 5765a1f28e..16fcf05d0d 100644 --- a/src/services/oauth/purchaseFlow.tsx +++ b/src/services/oauth/purchaseFlow.tsx @@ -18,6 +18,8 @@ import { AppStateProvider } from '../../state/AppState.js' import { openBrowser } from '../../utils/browser.js' import { createCheckoutSession, + getPurchaseOptions, + isPurchaseAttemptSucceeded, confirmCardlessTrial, getWhatsAppProfile, isGroupSubscriptionActive, @@ -109,7 +111,7 @@ export function filterCliPurchasablePlans( const subscription = subscriptionsByGroup.get(group.id) const convertingLocalTrial = isCurrentLocalTrial(subscription) if (subscription?.status === 'past_due') return false - if ((group.isMember || subscription) && !convertingLocalTrial) return false + if ((group.isMember || subscription) && !convertingLocalTrial && subscription?.source !== 'managed_seat') return false const full = group.subscriberLimit != null && @@ -542,7 +544,8 @@ export function WooviPaymentView({ error instanceof VerbooApiError && (error.status === 401 || error.status === 403 || - error.kind === 'contract') + error.kind === 'contract' || + error.code?.startsWith('purchase_attempt_')) ) { onError(error) return @@ -630,6 +633,7 @@ type Step = | 'checkout' | 'polling' | 'manual-browser' + | 'browser-management' | 'woovi-qr' | 'success' | 'error' @@ -675,6 +679,9 @@ export function PurchaseFlowView({ useState(null) const [cardlessVerification, setCardlessVerification] = useState(null) + const purchaseAttemptRef = React.useRef(undefined) + const tokenRef = React.useRef(accessToken) + tokenRef.current = accessToken const plansRequestRef = React.useRef(null) const pollingRef = React.useRef(null) const successTimerRef = React.useRef | null>( @@ -791,6 +798,7 @@ export function PurchaseFlowView({ groupId: string, displayStep: 'polling' | 'cardless-polling' = 'polling', requirement: GroupEntitlementRequirement = 'paid', + attemptId?: string, ) { pollingRef.current?.abort() const controller = new AbortController() @@ -803,12 +811,12 @@ export function PurchaseFlowView({ Date.now() - startedAt < POLL_TIMEOUT_MS ) { try { - const active = await isGroupSubscriptionActive(accessToken, groupId, { + const active = attemptId ? await isPurchaseAttemptSucceeded(accessToken, attemptId, groupId, controller.signal) : await isGroupSubscriptionActive(accessToken, groupId, { signal: controller.signal, requirement, }) if (active) { - if (pollingRef.current === controller) complete(requirement) + if (pollingRef.current === controller && tokenRef.current === accessToken) complete(requirement) return } } catch (error) { @@ -841,7 +849,7 @@ export function PurchaseFlowView({ 'plan-detail', { label: 'Verificar novamente', - run: () => void pollEntitlement(groupId, displayStep, requirement), + run: () => void pollEntitlement(groupId, displayStep, requirement, attemptId), }, ) } @@ -1041,12 +1049,22 @@ export function PurchaseFlowView({ setInlineMessage(null) setStep('checkout') try { + const options = await getPurchaseOptions(accessToken, group.id, group.billingInterval) + if (tokenRef.current !== accessToken) return + if (options.recommendation !== 'checkout') { + const query = new URLSearchParams({action: 'change-plan',plan: group.id,billingInterval: group.billingInterval}) + const url = `${VERBOO_FRONT_BASE_URL}/pt/settings/billing?${query}` + setManualCheckoutUrl(url);setStep('browser-management');await openBrowser(url);return + } const result = await createCheckoutSession(accessToken, group.id, { paymentMethod, + billingInterval: group.billingInterval, woovi, }) + if (tokenRef.current !== accessToken) return + purchaseAttemptRef.current = result.mode === 'reactivated' ? undefined : result.attemptId if (result.mode === 'reactivated') { - void startEntitlementPolling(group.id, 'polling', requirement) + void startEntitlementPolling(group.id, 'polling', requirement, purchaseAttemptRef.current) return } if (result.mode === 'woovi') { @@ -1061,7 +1079,7 @@ export function PurchaseFlowView({ setManualCheckoutUrl(result.url) setManualEntitlementRequirement(requirement) if (await openBrowser(result.url)) { - void startEntitlementPolling(group.id, 'polling', requirement) + void startEntitlementPolling(group.id, 'polling', requirement, purchaseAttemptRef.current) } else { setStep('manual-browser') } @@ -1087,10 +1105,11 @@ export function PurchaseFlowView({ } if ( presentation.code === 'already_subscribed' || + presentation.code === 'checkout_intent_required' || presentation.code === 'manual_access_active' ) { - setInlineMessage(presentation.message) - void startEntitlementPolling(group.id, 'polling', requirement) + setManualCheckoutUrl(`${VERBOO_FRONT_BASE_URL}/pt/settings/billing?action=change-plan&plan=${group.id}&billingInterval=${group.billingInterval}`) + setStep('browser-management') return } if ( @@ -1126,13 +1145,8 @@ export function PurchaseFlowView({ group.billingInterval, ) setManualCheckoutUrl(conversionUrl) - setManualEntitlementRequirement('paid') - setStep('checkout') - if (await openBrowser(conversionUrl)) { - void startEntitlementPolling(group.id, 'polling', 'paid') - } else { - setStep('manual-browser') - } + setStep('browser-management') + await openBrowser(conversionUrl) return } if (group.paymentProvider === 'both') setStep('payment-method') @@ -1500,6 +1514,14 @@ export function PurchaseFlowView({ ) + case 'browser-management': + return + Continue a troca ou a gestão da assinatura no navegador. + {manualCheckoutUrl} + A compra será confirmada no billing após a verificação do pagamento. +