Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
27 changes: 27 additions & 0 deletions src/services/api/revenueJourney.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
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<string, Map<string, {journeyId: string; operationId: string}>>()
export function commercialOperation(stage: 'checkout_request' | 'trial_activation' | 'email_verification' | 'catalog' | 'payment_return', operationId: string = randomUUID(), actorScope = 'anonymous') {
let owners = journeys.get(operationId)
if (!owners) { owners = new Map(); journeys.set(operationId, owners) }
let context = owners.get(actorScope)
if (!context) {
context = {journeyId: randomUUID(), operationId: owners.size ? randomUUID() : operationId}
owners.set(actorScope, context)
}
const journeyId = context.journeyId
operationId = context.operationId
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)}
}
8 changes: 4 additions & 4 deletions src/services/api/verbooApiError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
})
}

Expand Down
52 changes: 50 additions & 2 deletions src/services/api/verbooCheckout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -273,3 +275,49 @@ 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<string,string>}).headers['X-Verboo-Journey-Id']).toBe((requests[1][2] as {headers:Record<string,string>}).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)
})


test('a different account cannot reuse the previous purchase observation identity', async()=>{
const {isPurchaseAttemptSucceeded}=await import('./verbooCheckout.js')
const headers: Record<string,string>[]=[]
axios.get=(async(_url, config)=>{headers.push(config.headers); return {data:{data:{id:ATTEMPT_ID,groupId:GROUP_ID,status:'pending'}}}}) as typeof axios.get
const jwt=(sub:string,version:number)=>'header.'+Buffer.from(JSON.stringify({sub,version})).toString('base64url')+'.signature'
await isPurchaseAttemptSucceeded(jwt(GROUP_ID,1),ATTEMPT_ID,GROUP_ID)
await isPurchaseAttemptSucceeded(jwt(GROUP_ID,2),ATTEMPT_ID,GROUP_ID)
await isPurchaseAttemptSucceeded(jwt(OTHER_GROUP_ID,1),ATTEMPT_ID,GROUP_ID)
for (const key of ['X-Verboo-Journey-Id','X-Verboo-Operation-Id']) {
expect(headers[0][key]).toBe(headers[1][key])
expect(headers[2][key]).not.toBe(headers[0][key])
}
})
93 changes: 81 additions & 12 deletions src/services/api/verbooCheckout.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -121,6 +126,9 @@ export type CheckoutResult = z.infer<typeof checkoutResultSchema>
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
}
Expand All @@ -136,45 +144,106 @@ function authHeaders(accessToken: string): Record<string, string> {
}
}

function actorScope(accessToken: string): string {
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. */ }
return createHash('sha256').update(actor).digest('hex')
}

async function postAndParse<T>(
endpoint: string,
accessToken: string,
body: unknown,
schema: z.ZodType<T>,
contractName: string,
operationId?: string,
): Promise<T> {
const observation = commercialOperation(endpoint.endsWith('/checkout') ? 'checkout_request' : 'trial_activation', operationId, actorScope(accessToken))
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<string, string>()
function checkoutRequest(accessToken: string, groupId: string, input: CheckoutInput): string {
if (input.requestId) return input.requestId
const binding = createHash('sha256').update(JSON.stringify([actorScope(accessToken), 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', undefined, actorScope(accessToken))
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<boolean> {
const observation = commercialOperation('payment_return', attemptId, actorScope(accessToken))
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<CheckoutResult> {
parseRequest(z.string().uuid(), groupId, 'checkout')
const request = parseRequest(checkoutInputSchema, input, 'checkout')
const requestId = checkoutRequest(accessToken,groupId,input)
let request: z.infer<typeof checkoutInputSchema>
try {
parseRequest(z.string().uuid(), groupId, 'checkout')
request = parseRequest(checkoutInputSchema, {...input, requestId, purchaseIntent: input.purchaseIntent ?? 'new'}, 'checkout')
} catch (error) { commercialOperation('checkout_request', requestId, actorScope(accessToken)).fail(error); throw error }
return postAndParse(
`${VERBOO_API_BASE_URL}/api/me/groups/${groupId}/checkout`,
accessToken,
request,
checkoutResultSchema,
'o checkout',
requestId,
)
}

Expand Down
7 changes: 7 additions & 0 deletions src/services/oauth/purchaseErrors.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { VerbooApiError } from '../api/verbooApiError.js'

const BUSINESS_MESSAGES: Record<string, string> = {
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:
Expand Down
Loading
Loading