diff --git a/.changeset/many-parrots-give.md b/.changeset/many-parrots-give.md new file mode 100644 index 00000000..6851d33d --- /dev/null +++ b/.changeset/many-parrots-give.md @@ -0,0 +1,5 @@ +--- +'@oaknetwork/payments-sdk': minor +--- + +Update payment sdk with the recent changes from Crowdsplit diff --git a/packages/payments/__tests__/config.ts b/packages/payments/__tests__/config.ts index 2bc7bc1a..91a994a1 100644 --- a/packages/payments/__tests__/config.ts +++ b/packages/payments/__tests__/config.ts @@ -41,6 +41,8 @@ export function getConfigFromEnv(): OakClientConfig { }; } +export const INTEGRATION_TEST_TIMEOUT = 30000; + export function getTestEnvironment(): TestEnvironment { return { paymentCustomerId: process.env.PAYMENT_CUSTOMER_ID, diff --git a/packages/payments/__tests__/integration/disputeService.test.ts b/packages/payments/__tests__/integration/disputeService.test.ts new file mode 100644 index 00000000..5d018b4d --- /dev/null +++ b/packages/payments/__tests__/integration/disputeService.test.ts @@ -0,0 +1,104 @@ +import { createOakClient, createDisputeService } from '../../src'; +import { DisputeService } from '../../src/services/disputeService'; +import { getConfigFromEnv, INTEGRATION_TEST_TIMEOUT } from '../config'; + +describe('DisputeService - Integration', () => { + let disputes: DisputeService; + + beforeAll(() => { + const client = createOakClient({ + ...getConfigFromEnv(), + retryOptions: { + maxNumberOfRetries: 2, + delay: 500, + backoffFactor: 2, + }, + }); + disputes = createDisputeService(client); + }); + + // --------------------------------------------------------------- + // Service shape + // --------------------------------------------------------------- + describe('service interface', () => { + it('should expose list method', () => { + expect(typeof disputes.list).toBe('function'); + }); + + it('should expose updateEvidence method', () => { + expect(typeof disputes.updateEvidence).toBe('function'); + }); + + it('should expose submit method', () => { + expect(typeof disputes.submit).toBe('function'); + }); + + it('should expose close method', () => { + expect(typeof disputes.close).toBe('function'); + }); + }); + + // --------------------------------------------------------------- + // list() + // --------------------------------------------------------------- + describe('list', () => { + it( + 'should return a response when listing disputes', + async () => { + const response = await disputes.list(); + // May return empty list or data - both are valid + expect(response).toBeDefined(); + if (response.ok) { + expect(response.value).toBeDefined(); + } + }, + INTEGRATION_TEST_TIMEOUT, + ); + }); + + // --------------------------------------------------------------- + // updateEvidence() - error path + // --------------------------------------------------------------- + describe('updateEvidence', () => { + it( + 'should return an error for a non-existent dispute', + async () => { + const response = await disputes.updateEvidence('non-existent-id', { + text_evidences: [ + { key: 'customer_name', value: 'Test Customer' }, + ], + }); + expect(response.ok).toBe(false); + }, + INTEGRATION_TEST_TIMEOUT, + ); + }); + + // --------------------------------------------------------------- + // submit() - error path + // --------------------------------------------------------------- + describe('submit', () => { + it( + 'should return an error for a non-existent dispute', + async () => { + const response = await disputes.submit('non-existent-id'); + expect(response.ok).toBe(false); + }, + INTEGRATION_TEST_TIMEOUT, + ); + }); + + // --------------------------------------------------------------- + // close() - error path + // --------------------------------------------------------------- + describe('close', () => { + it( + 'should return an error for a non-existent dispute', + async () => { + const response = await disputes.close('non-existent-id'); + expect(response.ok).toBe(false); + }, + INTEGRATION_TEST_TIMEOUT, + ); + }); +}); diff --git a/packages/payments/__tests__/integration/missingServices.test.ts b/packages/payments/__tests__/integration/missingServices.test.ts new file mode 100644 index 00000000..7e5fc006 --- /dev/null +++ b/packages/payments/__tests__/integration/missingServices.test.ts @@ -0,0 +1,404 @@ +import { + createOakClient, + createCustomerService, + createFileService, + createTaxService, + createTransferService, + createPlanService, + createSubscriptionService, + createDisputeService, + createPayoutService, + createPaymentService, +} from '../../src'; +import { CustomerService } from '../../src/services/customerService'; +import { FileService } from '../../src/services/fileService'; +import { TaxService } from '../../src/services/taxService'; +import { TransferService } from '../../src/services/transferService'; +import { PlanService } from '../../src/services/planService'; +import { SubscriptionService } from '../../src/services/subscriptionService'; +import { DisputeService } from '../../src/services/disputeService'; +import { PayoutService } from '../../src/services/payoutService'; +import { PaymentService } from '../../src/services/paymentService'; +import { getConfigFromEnv, INTEGRATION_TEST_TIMEOUT } from '../config'; + +describe('All New Services - Integration', () => { + let customers: CustomerService; + let files: FileService; + let taxes: TaxService; + let transfers: TransferService; + let plans: PlanService; + let subscriptions: SubscriptionService; + let disputes: DisputeService; + let payouts: PayoutService; + let payments: PaymentService; + + let existingCustomerId: string | undefined; + let approvedCustomerId: string | undefined; + + beforeAll(async () => { + const client = createOakClient({ + ...getConfigFromEnv(), + retryOptions: { + maxNumberOfRetries: 2, + delay: 500, + backoffFactor: 2, + }, + }); + customers = createCustomerService(client); + files = createFileService(client); + taxes = createTaxService(client); + transfers = createTransferService(client); + plans = createPlanService(client); + subscriptions = createSubscriptionService(client); + disputes = createDisputeService(client); + payouts = createPayoutService(client); + payments = createPaymentService(client); + + // Find existing customer + const listRes = await customers.list({ limit: 1 }); + if (listRes.ok && listRes.value.data.customer_list.length > 0) { + const first = listRes.value.data.customer_list[0]; + existingCustomerId = (first.id ?? first.customer_id) as string; + } + + // Find approved customer for payment tests + const approvedRes = await customers.list({ + target_role: 'customer', + provider_registration_status: 'approved', + provider: 'stripe', + }); + if (approvedRes.ok && approvedRes.value.data.customer_list.length > 0) { + approvedCustomerId = (approvedRes.value.data.customer_list[0].id ?? + approvedRes.value.data.customer_list[0].customer_id) as string; + } + }, INTEGRATION_TEST_TIMEOUT); + + // --------------------------------------------------------------- + // PaymentService - capture, sandboxPaid, sandboxSettle + // --------------------------------------------------------------- + describe('PaymentService - capture/sandbox', () => { + it( + 'should return error for capture with invalid ID', + async () => { + const response = await payments.capture('non-existent-id'); + expect(response.ok).toBe(false); + if (!response.ok) { + expect(response.error).toBeDefined(); + } + }, + INTEGRATION_TEST_TIMEOUT, + ); + + it( + 'should create payment then sandbox paid it', + async () => { + if (!approvedCustomerId) { + throw new Error('No approved customer — create one with Stripe KYC'); + } + + const createRes = await payments.create({ + provider: 'stripe', + source: { + amount: 1500, + currency: 'usd', + payment_method: { type: 'card' }, + capture_method: 'automatic', + customer: { id: approvedCustomerId }, + }, + confirm: false, + metadata: { order_id: `test-sandbox-${Date.now()}` }, + } as any); + + expect(createRes.ok).toBe(true); + if (!createRes.ok) return; + + const paymentId = createRes.value.data.id; + expect(paymentId).toBeDefined(); + expect(typeof paymentId).toBe('string'); + + const sandboxRes = await payments.sandboxPaid(paymentId); + expect(sandboxRes).toBeDefined(); + if (sandboxRes.ok) { + expect(sandboxRes.value.data.id).toEqual(paymentId); + } + }, + INTEGRATION_TEST_TIMEOUT, + ); + + it( + 'should return error for sandboxSettle with invalid ID', + async () => { + const response = await payments.sandboxSettle('non-existent-id'); + expect(response.ok).toBe(false); + if (!response.ok) { + expect(response.error).toBeDefined(); + } + }, + INTEGRATION_TEST_TIMEOUT, + ); + }); + + // --------------------------------------------------------------- + // CustomerService - files + populatePlatform + // --------------------------------------------------------------- + describe('CustomerService - files & platform', () => { + it( + 'should get files for an existing customer and verify response shape', + async () => { + if (!existingCustomerId) { + throw new Error('No customer available'); + } + const response = await customers.getFiles(existingCustomerId); + expect(response).toBeDefined(); + if (response.ok) { + expect(response.value).toBeDefined(); + expect(response.value.data).toBeDefined(); + } + }, + INTEGRATION_TEST_TIMEOUT, + ); + + it( + 'should return error for getFiles with non-existent customer', + async () => { + const response = await customers.getFiles('non-existent-id'); + expect(response.ok).toBe(false); + if (!response.ok) { + expect(response.error).toBeDefined(); + } + }, + INTEGRATION_TEST_TIMEOUT, + ); + + it( + 'should return error for populatePlatform with invalid customer', + async () => { + const response = await customers.populatePlatform('non-existent-id', { + provider: 'stripe', + }); + expect(response.ok).toBe(false); + if (!response.ok) { + expect(response.error).toBeDefined(); + } + }, + INTEGRATION_TEST_TIMEOUT, + ); + }); + + // --------------------------------------------------------------- + // TransferService - sendWebhook + // --------------------------------------------------------------- + describe('TransferService - sendWebhook', () => { + it( + 'should call the transfer webhook endpoint', + async () => { + const response = await transfers.sendWebhook({}); + expect(response).toBeDefined(); + }, + INTEGRATION_TEST_TIMEOUT, + ); + }); + + // --------------------------------------------------------------- + // SubscriptionService + // --------------------------------------------------------------- + describe('SubscriptionService', () => { + it( + 'should return error for subscribe with invalid plan', + async () => { + const response = await subscriptions.subscribe({ + plan_id: 'non-existent-plan', + source_customer_id: 'fake-source', + destination_customer_id: 'fake-dest', + payment_method_id: 'fake-pm', + payment_method_type: 'CARD', + payment_method_provider: 'PAGAR_ME', + fee_bearer: 'connected_account', + }); + expect(response.ok).toBe(false); + if (!response.ok) { + expect(response.error).toBeDefined(); + } + }, + INTEGRATION_TEST_TIMEOUT, + ); + + it( + 'should return error for get with non-existent subscription', + async () => { + const response = await subscriptions.get('non-existent-id'); + expect(response.ok).toBe(false); + if (!response.ok) { + expect(response.error).toBeDefined(); + } + }, + INTEGRATION_TEST_TIMEOUT, + ); + + it( + 'should return error for cancel with non-existent subscription', + async () => { + const response = await subscriptions.cancel('non-existent-id'); + expect(response.ok).toBe(false); + }, + INTEGRATION_TEST_TIMEOUT, + ); + + it( + 'should return error for pay with non-existent subscription', + async () => { + const response = await subscriptions.pay('non-existent-id', { + customer_id: 'fake-customer-id', + payment_method_id: 'fake-pm-id', + payment_method_type: 'CARD', + payment_method_provider: 'PAGAR_ME', + }); + expect(response.ok).toBe(false); + if (!response.ok) { + expect(response.error).toBeDefined(); + } + }, + INTEGRATION_TEST_TIMEOUT, + ); + }); + + // --------------------------------------------------------------- + // DisputeService + // --------------------------------------------------------------- + describe('DisputeService', () => { + it( + 'should list disputes', + async () => { + const response = await disputes.list(); + expect(response).toBeDefined(); + if (response.ok) { + expect(response.value).toBeDefined(); + } + }, + INTEGRATION_TEST_TIMEOUT, + ); + + it( + 'should return error for updateEvidence with non-existent dispute', + async () => { + const response = await disputes.updateEvidence('non-existent-id', { + text_evidences: [{ key: 'customer_name', value: 'Test Customer' }], + }); + expect(response.ok).toBe(false); + if (!response.ok) { + expect(response.error).toBeDefined(); + } + }, + INTEGRATION_TEST_TIMEOUT, + ); + + it( + 'should return error for submit with non-existent dispute', + async () => { + const response = await disputes.submit('non-existent-id'); + expect(response.ok).toBe(false); + }, + INTEGRATION_TEST_TIMEOUT, + ); + + it( + 'should return error for close with non-existent dispute', + async () => { + const response = await disputes.close('non-existent-id'); + expect(response.ok).toBe(false); + }, + INTEGRATION_TEST_TIMEOUT, + ); + }); + + // --------------------------------------------------------------- + // PayoutService + // --------------------------------------------------------------- + describe('PayoutService', () => { + it( + 'should return error for payout with non-existent customer', + async () => { + const response = await payouts.create({ + payment_method_id: 'non-existent-pm', + amount: 1000, + currency: 'USD', + customer_id: 'non-existent-customer', + }); + expect(response.ok).toBe(false); + if (!response.ok) { + expect(response.error).toBeDefined(); + } + }, + INTEGRATION_TEST_TIMEOUT, + ); + }); + + // --------------------------------------------------------------- + // FileService + // --------------------------------------------------------------- + describe('FileService', () => { + it( + 'should list merchant files', + async () => { + const response = await files.list(); + expect(response).toBeDefined(); + if (response.ok) { + expect(response.value).toBeDefined(); + } + }, + INTEGRATION_TEST_TIMEOUT, + ); + + it( + 'should return error for get with non-existent file', + async () => { + const response = await files.get('non-existent-file-id'); + expect(response.ok).toBe(false); + if (!response.ok) { + expect(response.error).toBeDefined(); + } + }, + INTEGRATION_TEST_TIMEOUT, + ); + + it( + 'should return error for delete with non-existent file', + async () => { + const response = await files.delete('non-existent-file-id'); + expect(response.ok).toBe(false); + }, + INTEGRATION_TEST_TIMEOUT, + ); + }); + + // --------------------------------------------------------------- + // TaxService + // --------------------------------------------------------------- + describe('TaxService', () => { + it( + 'should call tax calculation endpoint', + async () => { + const response = await taxes.calculate({ provider: 'stripe' }); + expect(response).toBeDefined(); + }, + INTEGRATION_TEST_TIMEOUT, + ); + }); + + // --------------------------------------------------------------- + // PlanService - verify new query params + // --------------------------------------------------------------- + describe('PlanService - filters', () => { + it( + 'should list plans with active filter', + async () => { + const response = await plans.list({ active: true }); + expect(response).toBeDefined(); + if (response.ok) { + expect(response.value.data).toBeDefined(); + } + }, + INTEGRATION_TEST_TIMEOUT, + ); + }); +}); diff --git a/packages/payments/__tests__/integration/paymentServiceCapture.test.ts b/packages/payments/__tests__/integration/paymentServiceCapture.test.ts new file mode 100644 index 00000000..86c639f1 --- /dev/null +++ b/packages/payments/__tests__/integration/paymentServiceCapture.test.ts @@ -0,0 +1,179 @@ +import { + createOakClient, + Payment, + createPaymentService, + createCustomerService, +} from '../../src'; +import { PaymentService } from '../../src/services/paymentService'; +import { CustomerService } from '../../src/services/customerService'; +import { getConfigFromEnv, INTEGRATION_TEST_TIMEOUT } from '../config'; + +/** + * Build a Stripe card payment request with manual capture. + */ +const buildStripeManualCaptureRequest = ( + customerId: string, +): Payment.Request => { + const request: Record = { + provider: 'stripe', + source: { + amount: 1500, + currency: 'usd', + payment_method: { type: 'card' }, + capture_method: 'manual', + customer: { id: customerId }, + }, + confirm: true, + metadata: { + order_id: `test-capture-${Date.now()}`, + }, + }; + return request as unknown as Payment.Request; +}; + +/** + * Build a PagarMe PIX payment request (no capture_method). + */ +const buildPagarMePixPaymentRequest = ( + customerId: string, +): Payment.Request => + ({ + provider: 'pagar_me', + source: { + amount: 100, + currency: 'brl', + customer: { id: customerId }, + payment_method: { type: 'pix', expiry_date: '2030-01-01' }, + }, + confirm: true, + }) as unknown as Payment.Request; + +describe('PaymentService - Capture & Sandbox', () => { + let payments: PaymentService; + let customers: CustomerService; + let approvedCustomerId: string | undefined; + + beforeAll(async () => { + const client = createOakClient({ + ...getConfigFromEnv(), + retryOptions: { + maxNumberOfRetries: 2, + delay: 500, + backoffFactor: 2, + }, + }); + customers = createCustomerService(client); + payments = createPaymentService(client); + + // Find an approved customer + const listRes = await customers.list({ + target_role: 'customer', + provider_registration_status: 'approved', + provider: 'stripe', + }); + + if (listRes.ok && listRes.value.data.customer_list.length > 0) { + approvedCustomerId = (listRes.value.data.customer_list[0].id ?? + listRes.value.data.customer_list[0].customer_id) as string; + } + }, INTEGRATION_TEST_TIMEOUT); + + // --------------------------------------------------------------- + // capture() + // --------------------------------------------------------------- + describe('capture', () => { + it( + 'should have a capture method on the service', + () => { + expect(typeof payments.capture).toBe('function'); + }, + ); + + it( + 'should return an error for an invalid payment ID', + async () => { + const response = await payments.capture('non-existent-id'); + expect(response.ok).toBe(false); + }, + INTEGRATION_TEST_TIMEOUT, + ); + }); + + // --------------------------------------------------------------- + // sandboxPaid() + // --------------------------------------------------------------- + describe('sandboxPaid', () => { + it( + 'should have a sandboxPaid method on the service', + () => { + expect(typeof payments.sandboxPaid).toBe('function'); + }, + ); + + it( + 'should mark a sandbox payment as paid', + async () => { + if (!approvedCustomerId) { + throw new Error('No approved customer available'); + } + + // Create a Stripe payment (confirmed=false so it stays in a sandbox-actionable state) + const createRes = await payments.create({ + provider: 'stripe', + source: { + amount: 1500, + currency: 'usd', + payment_method: { type: 'card' }, + capture_method: 'automatic', + customer: { id: approvedCustomerId }, + }, + confirm: false, + metadata: { order_id: `test-sandbox-paid-${Date.now()}` }, + } as unknown as Payment.Request); + expect(createRes.ok).toBe(true); + if (!createRes.ok) return; + + const paymentId = createRes.value.data.id; + const response = await payments.sandboxPaid(paymentId); + + // sandboxPaid may succeed or return error depending on payment state; + // we verify the method exists and hits the right endpoint + expect(response).toBeDefined(); + if (response.ok) { + expect(response.value.data.id).toEqual(paymentId); + } + }, + INTEGRATION_TEST_TIMEOUT, + ); + + it( + 'should return an error for an invalid payment ID', + async () => { + const response = await payments.sandboxPaid('non-existent-id'); + expect(response.ok).toBe(false); + }, + INTEGRATION_TEST_TIMEOUT, + ); + }); + + // --------------------------------------------------------------- + // sandboxSettle() + // --------------------------------------------------------------- + describe('sandboxSettle', () => { + it( + 'should have a sandboxSettle method on the service', + () => { + expect(typeof payments.sandboxSettle).toBe('function'); + }, + ); + + it( + 'should return an error for an invalid payment ID', + async () => { + const response = await payments.sandboxSettle('non-existent-id'); + expect(response.ok).toBe(false); + }, + INTEGRATION_TEST_TIMEOUT, + ); + }); +}); diff --git a/packages/payments/__tests__/integration/payoutService.test.ts b/packages/payments/__tests__/integration/payoutService.test.ts new file mode 100644 index 00000000..008c1ae1 --- /dev/null +++ b/packages/payments/__tests__/integration/payoutService.test.ts @@ -0,0 +1,64 @@ +import { createOakClient, createPayoutService } from '../../src'; +import { PayoutService } from '../../src/services/payoutService'; +import { getConfigFromEnv, INTEGRATION_TEST_TIMEOUT } from '../config'; + +describe('PayoutService - Integration', () => { + let payouts: PayoutService; + + beforeAll(() => { + const client = createOakClient({ + ...getConfigFromEnv(), + retryOptions: { + maxNumberOfRetries: 2, + delay: 500, + backoffFactor: 2, + }, + }); + payouts = createPayoutService(client); + }); + + // --------------------------------------------------------------- + // Service shape + // --------------------------------------------------------------- + describe('service interface', () => { + it('should expose create method', () => { + expect(typeof payouts.create).toBe('function'); + }); + }); + + // --------------------------------------------------------------- + // create() - error path (invalid customer) + // --------------------------------------------------------------- + describe('create', () => { + it( + 'should return an error for an invalid customer', + async () => { + const response = await payouts.create({ + payment_method_id: 'non-existent-pm', + amount: 1000, + currency: 'USD', + customer_id: 'non-existent-customer', + }); + expect(response.ok).toBe(false); + }, + INTEGRATION_TEST_TIMEOUT, + ); + + it( + 'should return an error for invalid currency', + async () => { + const response = await payouts.create({ + payment_method_id: 'non-existent-pm', + amount: 1000, + currency: 'USD', + customer_id: 'non-existent-customer', + }); + expect(response.ok).toBe(false); + if (!response.ok) { + expect(response.error).toBeDefined(); + } + }, + INTEGRATION_TEST_TIMEOUT, + ); + }); +}); diff --git a/packages/payments/__tests__/integration/subscriptionService.test.ts b/packages/payments/__tests__/integration/subscriptionService.test.ts new file mode 100644 index 00000000..e02e321a --- /dev/null +++ b/packages/payments/__tests__/integration/subscriptionService.test.ts @@ -0,0 +1,129 @@ +import { + createOakClient, + createSubscriptionService, + createPlanService, +} from '../../src'; +import { SubscriptionService } from '../../src/services/subscriptionService'; +import { PlanService } from '../../src/services/planService'; +import { getConfigFromEnv, INTEGRATION_TEST_TIMEOUT } from '../config'; + +describe('SubscriptionService - Integration', () => { + let subscriptions: SubscriptionService; + let plans: PlanService; + + /** Plan hash_id created during setup. */ + let testPlanId: string | undefined; + + beforeAll(async () => { + const client = createOakClient({ + ...getConfigFromEnv(), + retryOptions: { + maxNumberOfRetries: 2, + delay: 500, + backoffFactor: 2, + }, + }); + subscriptions = createSubscriptionService(client); + plans = createPlanService(client); + + // Try to get an existing plan, or note that none exist + const listRes = await plans.list({ per_page: 1 }); + if (listRes.ok && listRes.value.data.data.length > 0) { + testPlanId = listRes.value.data.data[0].hash_id; + } + }, INTEGRATION_TEST_TIMEOUT); + + // --------------------------------------------------------------- + // Service shape + // --------------------------------------------------------------- + describe('service interface', () => { + it('should expose subscribe method', () => { + expect(typeof subscriptions.subscribe).toBe('function'); + }); + + it('should expose cancel method', () => { + expect(typeof subscriptions.cancel).toBe('function'); + }); + + it('should expose list method', () => { + expect(typeof subscriptions.list).toBe('function'); + }); + + it('should expose get method', () => { + expect(typeof subscriptions.get).toBe('function'); + }); + + it('should expose pay method', () => { + expect(typeof subscriptions.pay).toBe('function'); + }); + }); + + // --------------------------------------------------------------- + // get() - error path + // --------------------------------------------------------------- + describe('get', () => { + it( + 'should return an error for a non-existent subscription', + async () => { + const response = await subscriptions.get('non-existent-id'); + expect(response.ok).toBe(false); + }, + INTEGRATION_TEST_TIMEOUT, + ); + }); + + // --------------------------------------------------------------- + // cancel() - error path + // --------------------------------------------------------------- + describe('cancel', () => { + it( + 'should return an error for a non-existent subscription', + async () => { + const response = await subscriptions.cancel('non-existent-id'); + expect(response.ok).toBe(false); + }, + INTEGRATION_TEST_TIMEOUT, + ); + }); + + // --------------------------------------------------------------- + // pay() - error path + // --------------------------------------------------------------- + describe('pay', () => { + it( + 'should return an error for a non-existent subscription', + async () => { + const response = await subscriptions.pay('non-existent-id', { + customer_id: 'fake-customer-id', + payment_method_id: 'fake-pm-id', + payment_method_type: 'CARD', + payment_method_provider: 'PAGAR_ME', + }); + expect(response.ok).toBe(false); + }, + INTEGRATION_TEST_TIMEOUT, + ); + }); + + // --------------------------------------------------------------- + // subscribe() - error path (invalid plan) + // --------------------------------------------------------------- + describe('subscribe', () => { + it( + 'should return an error for an invalid plan', + async () => { + const response = await subscriptions.subscribe({ + plan_id: 'non-existent-plan', + source_customer_id: 'fake-source', + destination_customer_id: 'fake-dest', + payment_method_id: 'fake-pm', + payment_method_type: 'CARD', + payment_method_provider: 'PAGAR_ME', + fee_bearer: 'connected_account', + }); + expect(response.ok).toBe(false); + }, + INTEGRATION_TEST_TIMEOUT, + ); + }); +}); diff --git a/packages/payments/__tests__/unit/newServices.test.ts b/packages/payments/__tests__/unit/newServices.test.ts new file mode 100644 index 00000000..7eb75575 --- /dev/null +++ b/packages/payments/__tests__/unit/newServices.test.ts @@ -0,0 +1,710 @@ +import { + createSubscriptionService, + createDisputeService, + createPayoutService, + createFileService, + createTaxService, + createPaymentService, + createCustomerService, + createTransferService, +} from "../../src/services"; +import { httpClient } from "../../src/utils/httpClient"; +import { ApiError, SDKError } from "../../src/utils/errorHandler"; +import type { OakClient } from "../../src/types"; +import { err, ok } from "../../src/types"; +import { ENVIRONMENT_URLS } from "../../src/types/environment"; + +const SANDBOX_URL = ENVIRONMENT_URLS.sandbox; + +jest.mock("../../src/utils/httpClient", () => ({ + httpClient: { + post: jest.fn(), + get: jest.fn(), + put: jest.fn(), + patch: jest.fn(), + delete: jest.fn(), + postMultipart: jest.fn(), + }, +})); + +const mockedHttpClient = httpClient as jest.Mocked; + +const retryOptions = { maxNumberOfRetries: 0, delay: 0, backoffFactor: 2 }; + +const makeClient = (): OakClient => ({ + config: { + environment: "sandbox", + clientId: "id", + baseUrl: SANDBOX_URL, + }, + retryOptions, + getAccessToken: jest.fn().mockResolvedValue(ok("token")), + grantToken: jest.fn(), +}); + +const makeClientWithTokenError = (): OakClient => { + const tokenError = new SDKError("Token error"); + return { + config: { + environment: "sandbox", + clientId: "id", + baseUrl: SANDBOX_URL, + }, + retryOptions, + getAccessToken: jest.fn().mockResolvedValue(err(tokenError)), + grantToken: jest.fn(), + }; +}; + +const getAuthConfig = (client: OakClient) => + expect.objectContaining({ + headers: { Authorization: "Bearer token" }, + retryOptions: client.retryOptions, + }); + +const expectSuccess = async (options: { + client: OakClient; + call: () => Promise; + httpMethod: keyof typeof mockedHttpClient; + expectedArgs: unknown[]; +}) => { + const response = { ok: true }; + mockedHttpClient[options.httpMethod].mockResolvedValue(ok(response) as never); + + const result = await options.call(); + + expect(result).toEqual(ok(response)); + expect(mockedHttpClient[options.httpMethod]).toHaveBeenCalledWith( + ...options.expectedArgs, + ); + expect(options.client.getAccessToken).toHaveBeenCalled(); +}; + +const expectFailure = async (options: { + call: () => Promise; + httpMethod: keyof typeof mockedHttpClient; + errorMessage: string; +}) => { + const apiError = new ApiError(options.errorMessage, 400, { + msg: options.errorMessage, + }); + mockedHttpClient[options.httpMethod].mockResolvedValue( + err(apiError) as never, + ); + + const result = await options.call(); + expect(result).toEqual(err(expect.any(ApiError))); +}; + +const expectTokenFailure = async (call: () => Promise) => { + const result = await call(); + expect(result).toEqual(err(expect.any(SDKError))); +}; + +describe("New services (Unit)", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // --------------------------------------------------------------- + // PaymentService - capture, sandboxPaid, sandboxSettle + // --------------------------------------------------------------- + it("paymentService.capture success", async () => { + const client = makeClient(); + const service = createPaymentService(client); + await expectSuccess({ + client, + call: () => service.capture("pay-1"), + httpMethod: "post", + expectedArgs: [ + `${SANDBOX_URL}/api/v1/payments/pay-1/capture`, + {}, + getAuthConfig(client), + ], + }); + }); + + it("paymentService.capture failure", async () => { + const client = makeClient(); + const service = createPaymentService(client); + await expectFailure({ + call: () => service.capture("pay-1"), + httpMethod: "post", + errorMessage: "Failed to capture payment", + }); + }); + + it("paymentService.capture token error", async () => { + const client = makeClientWithTokenError(); + const service = createPaymentService(client); + await expectTokenFailure(() => service.capture("pay-1")); + }); + + it("paymentService.sandboxPaid success", async () => { + const client = makeClient(); + const service = createPaymentService(client); + await expectSuccess({ + client, + call: () => service.sandboxPaid("pay-1"), + httpMethod: "post", + expectedArgs: [ + `${SANDBOX_URL}/api/v1/payments/pay-1/sandbox/paid`, + {}, + getAuthConfig(client), + ], + }); + }); + + it("paymentService.sandboxPaid token error", async () => { + const client = makeClientWithTokenError(); + const service = createPaymentService(client); + await expectTokenFailure(() => service.sandboxPaid("pay-1")); + }); + + it("paymentService.sandboxSettle success", async () => { + const client = makeClient(); + const service = createPaymentService(client); + await expectSuccess({ + client, + call: () => service.sandboxSettle("pay-1"), + httpMethod: "post", + expectedArgs: [ + `${SANDBOX_URL}/api/v1/payments/pay-1/sandbox/settle`, + {}, + getAuthConfig(client), + ], + }); + }); + + it("paymentService.sandboxSettle token error", async () => { + const client = makeClientWithTokenError(); + const service = createPaymentService(client); + await expectTokenFailure(() => service.sandboxSettle("pay-1")); + }); + + // --------------------------------------------------------------- + // CustomerService - uploadFiles, getFiles, populatePlatform + // --------------------------------------------------------------- + it("customerService.uploadFiles success", async () => { + const client = makeClient(); + const service = createCustomerService(client); + await expectSuccess({ + client, + call: () => service.uploadFiles("cust-1", {}), + httpMethod: "postMultipart", + expectedArgs: [ + `${SANDBOX_URL}/api/v1/customers/cust-1/files`, + {}, + getAuthConfig(client), + ], + }); + }); + + it("customerService.uploadFiles token error", async () => { + const client = makeClientWithTokenError(); + const service = createCustomerService(client); + await expectTokenFailure(() => service.uploadFiles("cust-1", {})); + }); + + it("customerService.getFiles success", async () => { + const client = makeClient(); + const service = createCustomerService(client); + await expectSuccess({ + client, + call: () => service.getFiles("cust-1"), + httpMethod: "get", + expectedArgs: [ + `${SANDBOX_URL}/api/v1/customers/cust-1/files`, + getAuthConfig(client), + ], + }); + }); + + it("customerService.getFiles failure", async () => { + const client = makeClient(); + const service = createCustomerService(client); + await expectFailure({ + call: () => service.getFiles("cust-1"), + httpMethod: "get", + errorMessage: "Failed to get files", + }); + }); + + it("customerService.populatePlatform success", async () => { + const client = makeClient(); + const service = createCustomerService(client); + await expectSuccess({ + client, + call: () => + service.populatePlatform("cust-1", { provider: "stripe" }), + httpMethod: "post", + expectedArgs: [ + `${SANDBOX_URL}/api/v1/customers/cust-1/platforms`, + { provider: "stripe" }, + getAuthConfig(client), + ], + }); + }); + + it("customerService.populatePlatform token error", async () => { + const client = makeClientWithTokenError(); + const service = createCustomerService(client); + await expectTokenFailure(() => + service.populatePlatform("cust-1", { provider: "stripe" }), + ); + }); + + // --------------------------------------------------------------- + // TransferService - sendWebhook + // --------------------------------------------------------------- + it("transferService.sendWebhook success", async () => { + const client = makeClient(); + const service = createTransferService(client); + await expectSuccess({ + client, + call: () => service.sendWebhook({ test: true }), + httpMethod: "post", + expectedArgs: [ + `${SANDBOX_URL}/api/v1/transfer/webhook`, + { test: true }, + getAuthConfig(client), + ], + }); + }); + + it("transferService.sendWebhook failure", async () => { + const client = makeClient(); + const service = createTransferService(client); + await expectFailure({ + call: () => service.sendWebhook({}), + httpMethod: "post", + errorMessage: "Failed to send webhook", + }); + }); + + it("transferService.sendWebhook token error", async () => { + const client = makeClientWithTokenError(); + const service = createTransferService(client); + await expectTokenFailure(() => service.sendWebhook({})); + }); + + // --------------------------------------------------------------- + // SubscriptionService + // --------------------------------------------------------------- + it("subscriptionService.subscribe success", async () => { + const client = makeClient(); + const service = createSubscriptionService(client); + await expectSuccess({ + client, + call: () => + service.subscribe({ + plan_id: "plan-1", + source_customer_id: "src-1", + destination_customer_id: "dst-1", + payment_method_id: "pm-1", + payment_method_type: "CARD", + payment_method_provider: "PAGAR_ME", + fee_bearer: "connected_account", + }), + httpMethod: "post", + expectedArgs: [ + `${SANDBOX_URL}/api/v1/subscription/subscribe`, + expect.any(Object), + getAuthConfig(client), + ], + }); + }); + + it("subscriptionService.subscribe failure", async () => { + const client = makeClient(); + const service = createSubscriptionService(client); + await expectFailure({ + call: () => + service.subscribe({ + plan_id: "x", + source_customer_id: "x", + destination_customer_id: "x", + payment_method_id: "x", + payment_method_type: "CARD", + payment_method_provider: "PAGAR_ME", + fee_bearer: "connected_account", + }), + httpMethod: "post", + errorMessage: "Failed to subscribe", + }); + }); + + it("subscriptionService.subscribe token error", async () => { + const client = makeClientWithTokenError(); + const service = createSubscriptionService(client); + await expectTokenFailure(() => + service.subscribe({ + plan_id: "x", + source_customer_id: "x", + destination_customer_id: "x", + payment_method_id: "x", + payment_method_type: "CARD", + payment_method_provider: "PAGAR_ME", + fee_bearer: "connected_account", + }), + ); + }); + + it("subscriptionService.cancel success", async () => { + const client = makeClient(); + const service = createSubscriptionService(client); + await expectSuccess({ + client, + call: () => service.cancel("sub-1"), + httpMethod: "patch", + expectedArgs: [ + `${SANDBOX_URL}/api/v1/subscription/subscriptions/sub-1/cancel`, + undefined, + getAuthConfig(client), + ], + }); + }); + + it("subscriptionService.cancel token error", async () => { + const client = makeClientWithTokenError(); + const service = createSubscriptionService(client); + await expectTokenFailure(() => service.cancel("sub-1")); + }); + + it("subscriptionService.get success", async () => { + const client = makeClient(); + const service = createSubscriptionService(client); + await expectSuccess({ + client, + call: () => service.get("sub-1"), + httpMethod: "get", + expectedArgs: [ + `${SANDBOX_URL}/api/v1/subscription/sub-1`, + getAuthConfig(client), + ], + }); + }); + + it("subscriptionService.get token error", async () => { + const client = makeClientWithTokenError(); + const service = createSubscriptionService(client); + await expectTokenFailure(() => service.get("sub-1")); + }); + + it("subscriptionService.list success", async () => { + const client = makeClient(); + const service = createSubscriptionService(client); + const response = { ok: true }; + mockedHttpClient.get.mockResolvedValue(ok(response) as never); + const result = await service.list({ customer_id: "cust-1" }); + expect(result).toEqual(ok(response)); + expect(client.getAccessToken).toHaveBeenCalled(); + }); + + it("subscriptionService.list token error", async () => { + const client = makeClientWithTokenError(); + const service = createSubscriptionService(client); + await expectTokenFailure(() => + service.list({ customer_id: "cust-1" }), + ); + }); + + it("subscriptionService.pay success", async () => { + const client = makeClient(); + const service = createSubscriptionService(client); + await expectSuccess({ + client, + call: () => + service.pay("sub-1", { + customer_id: "cust-1", + payment_method_id: "pm-1", + payment_method_type: "CARD", + payment_method_provider: "PAGAR_ME", + }), + httpMethod: "post", + expectedArgs: [ + `${SANDBOX_URL}/api/v1/subscription/sub-1/payment`, + expect.any(Object), + getAuthConfig(client), + ], + }); + }); + + it("subscriptionService.pay token error", async () => { + const client = makeClientWithTokenError(); + const service = createSubscriptionService(client); + await expectTokenFailure(() => + service.pay("sub-1", { + customer_id: "cust-1", + payment_method_id: "pm-1", + payment_method_type: "CARD", + payment_method_provider: "PAGAR_ME", + }), + ); + }); + + // --------------------------------------------------------------- + // DisputeService + // --------------------------------------------------------------- + it("disputeService.list success", async () => { + const client = makeClient(); + const service = createDisputeService(client); + await expectSuccess({ + client, + call: () => service.list(), + httpMethod: "get", + expectedArgs: [ + `${SANDBOX_URL}/api/v1/disputes`, + getAuthConfig(client), + ], + }); + }); + + it("disputeService.list token error", async () => { + const client = makeClientWithTokenError(); + const service = createDisputeService(client); + await expectTokenFailure(() => service.list()); + }); + + it("disputeService.updateEvidence success", async () => { + const client = makeClient(); + const service = createDisputeService(client); + await expectSuccess({ + client, + call: () => + service.updateEvidence("d-1", { + text_evidences: [{ key: "customer_name", value: "Test" }], + }), + httpMethod: "put", + expectedArgs: [ + `${SANDBOX_URL}/api/v1/disputes/d-1/evidence`, + expect.any(Object), + getAuthConfig(client), + ], + }); + }); + + it("disputeService.updateEvidence failure", async () => { + const client = makeClient(); + const service = createDisputeService(client); + await expectFailure({ + call: () => service.updateEvidence("d-1", {}), + httpMethod: "put", + errorMessage: "Failed to update evidence", + }); + }); + + it("disputeService.submit success", async () => { + const client = makeClient(); + const service = createDisputeService(client); + await expectSuccess({ + client, + call: () => service.submit("d-1"), + httpMethod: "put", + expectedArgs: [ + `${SANDBOX_URL}/api/v1/disputes/d-1/submit`, + undefined, + getAuthConfig(client), + ], + }); + }); + + it("disputeService.close success", async () => { + const client = makeClient(); + const service = createDisputeService(client); + await expectSuccess({ + client, + call: () => service.close("d-1"), + httpMethod: "put", + expectedArgs: [ + `${SANDBOX_URL}/api/v1/disputes/d-1/close`, + undefined, + getAuthConfig(client), + ], + }); + }); + + it("disputeService.close token error", async () => { + const client = makeClientWithTokenError(); + const service = createDisputeService(client); + await expectTokenFailure(() => service.close("d-1")); + }); + + // --------------------------------------------------------------- + // PayoutService + // --------------------------------------------------------------- + it("payoutService.create success", async () => { + const client = makeClient(); + const service = createPayoutService(client); + await expectSuccess({ + client, + call: () => + service.create({ + payment_method_id: "pm-1", + amount: 1000, + currency: "USD", + customer_id: "cust-1", + }), + httpMethod: "post", + expectedArgs: [ + `${SANDBOX_URL}/api/v1/outbound_payments`, + expect.any(Object), + getAuthConfig(client), + ], + }); + }); + + it("payoutService.create failure", async () => { + const client = makeClient(); + const service = createPayoutService(client); + await expectFailure({ + call: () => + service.create({ + payment_method_id: "x", + amount: 1000, + currency: "USD", + customer_id: "x", + }), + httpMethod: "post", + errorMessage: "Failed to create payout", + }); + }); + + it("payoutService.create token error", async () => { + const client = makeClientWithTokenError(); + const service = createPayoutService(client); + await expectTokenFailure(() => + service.create({ + payment_method_id: "x", + amount: 1000, + currency: "USD", + customer_id: "x", + }), + ); + }); + + // --------------------------------------------------------------- + // FileService + // --------------------------------------------------------------- + it("fileService.upload success", async () => { + const client = makeClient(); + const service = createFileService(client); + await expectSuccess({ + client, + call: () => service.upload({}), + httpMethod: "postMultipart", + expectedArgs: [ + `${SANDBOX_URL}/api/v1/files`, + {}, + getAuthConfig(client), + ], + }); + }); + + it("fileService.upload token error", async () => { + const client = makeClientWithTokenError(); + const service = createFileService(client); + await expectTokenFailure(() => service.upload({})); + }); + + it("fileService.list success", async () => { + const client = makeClient(); + const service = createFileService(client); + await expectSuccess({ + client, + call: () => service.list(), + httpMethod: "get", + expectedArgs: [ + `${SANDBOX_URL}/api/v1/files`, + getAuthConfig(client), + ], + }); + }); + + it("fileService.list token error", async () => { + const client = makeClientWithTokenError(); + const service = createFileService(client); + await expectTokenFailure(() => service.list()); + }); + + it("fileService.get success", async () => { + const client = makeClient(); + const service = createFileService(client); + await expectSuccess({ + client, + call: () => service.get("f-1"), + httpMethod: "get", + expectedArgs: [ + `${SANDBOX_URL}/api/v1/files/f-1`, + getAuthConfig(client), + ], + }); + }); + + it("fileService.get failure", async () => { + const client = makeClient(); + const service = createFileService(client); + await expectFailure({ + call: () => service.get("f-1"), + httpMethod: "get", + errorMessage: "File not found", + }); + }); + + it("fileService.delete success", async () => { + const client = makeClient(); + const service = createFileService(client); + await expectSuccess({ + client, + call: () => service.delete("f-1"), + httpMethod: "delete", + expectedArgs: [ + `${SANDBOX_URL}/api/v1/files/f-1`, + getAuthConfig(client), + ], + }); + }); + + it("fileService.delete token error", async () => { + const client = makeClientWithTokenError(); + const service = createFileService(client); + await expectTokenFailure(() => service.delete("f-1")); + }); + + // --------------------------------------------------------------- + // TaxService + // --------------------------------------------------------------- + it("taxService.calculate success", async () => { + const client = makeClient(); + const service = createTaxService(client); + await expectSuccess({ + client, + call: () => service.calculate({ provider: "stripe" }), + httpMethod: "post", + expectedArgs: [ + `${SANDBOX_URL}/api/v1/taxes/calculate`, + { provider: "stripe" }, + getAuthConfig(client), + ], + }); + }); + + it("taxService.calculate failure", async () => { + const client = makeClient(); + const service = createTaxService(client); + await expectFailure({ + call: () => service.calculate({ provider: "stripe" }), + httpMethod: "post", + errorMessage: "Failed to calculate taxes", + }); + }); + + it("taxService.calculate token error", async () => { + const client = makeClientWithTokenError(); + const service = createTaxService(client); + await expectTokenFailure(() => + service.calculate({ provider: "stripe" }), + ); + }); + +}); diff --git a/packages/payments/__tests__/unit/services.test.ts b/packages/payments/__tests__/unit/services.test.ts index 9d11e858..9dab03b7 100644 --- a/packages/payments/__tests__/unit/services.test.ts +++ b/packages/payments/__tests__/unit/services.test.ts @@ -204,7 +204,7 @@ describe("Oak services (Unit)", () => { service.balance("cust-1", { provider: "stripe", role: "customer" }), httpMethod: "get", expectedArgs: [ - `${SANDBOX_URL}/api/v1/customers/cust-1/balance?provider=stripe&role=customer`, + `${SANDBOX_URL}/api/v1/customers/cust-1/balances?provider=stripe&role=customer`, authConfig, ], }); diff --git a/packages/payments/__tests__/unit/webhookVerification.test.ts b/packages/payments/__tests__/unit/webhookVerification.test.ts index d0db8ed2..d10850fb 100644 --- a/packages/payments/__tests__/unit/webhookVerification.test.ts +++ b/packages/payments/__tests__/unit/webhookVerification.test.ts @@ -15,42 +15,67 @@ describe("webhookVerification", () => { return hmac.digest("hex"); }; + // Helper to generate CrowdSplit-Signature header format + const generateCrowdSplitSignature = ( + data: string, + webhookSecret: string, + timestamp: string, + ): string => { + const base = `${timestamp}.${data}`; + const hmac = createHmac("sha256", webhookSecret); + hmac.update(base, "utf8"); + const sig = hmac.digest("hex"); + return `t=${timestamp},v1=${sig}`; + }; + describe("verifyWebhookSignature", () => { - it("should verify valid signature", () => { - const signature = generateSignature(payload, secret); + it("should verify valid CrowdSplit-Signature format (t=,v1=)", () => { + const timestamp = Math.floor(Date.now() / 1000).toString(); + const signature = generateCrowdSplitSignature(payload, secret, timestamp); const result = verifyWebhookSignature(payload, signature, secret); expect(result).toBe(true); }); - it("should reject invalid signature", () => { - const invalidSignature = "invalid-signature-12345"; - const result = verifyWebhookSignature(payload, invalidSignature, secret); + it("should reject invalid CrowdSplit-Signature", () => { + const signature = "t=12345,v1=invalidsignature"; + const result = verifyWebhookSignature(payload, signature, secret); expect(result).toBe(false); }); - it("should reject signature with wrong secret", () => { - const signature = generateSignature(payload, "wrong-secret"); + it("should reject CrowdSplit-Signature with wrong secret", () => { + const timestamp = Math.floor(Date.now() / 1000).toString(); + const signature = generateCrowdSplitSignature(payload, "wrong-secret", timestamp); const result = verifyWebhookSignature(payload, signature, secret); expect(result).toBe(false); }); - it("should reject signature with tampered payload", () => { - const signature = generateSignature(payload, secret); + it("should reject CrowdSplit-Signature with tampered payload", () => { + const timestamp = Math.floor(Date.now() / 1000).toString(); + const signature = generateCrowdSplitSignature(payload, secret, timestamp); const tamperedPayload = JSON.stringify({ event: "payment.created", id: "pay_999", }); - const result = verifyWebhookSignature( - tamperedPayload, - signature, - secret, - ); + const result = verifyWebhookSignature(tamperedPayload, signature, secret); expect(result).toBe(false); }); - it("should handle empty payload", () => { + it("should verify legacy raw signature format (fallback)", () => { + const signature = generateSignature(payload, secret); + const result = verifyWebhookSignature(payload, signature, secret); + expect(result).toBe(true); + }); + + it("should reject invalid legacy signature", () => { + const invalidSignature = "invalid-signature-12345"; + const result = verifyWebhookSignature(payload, invalidSignature, secret); + expect(result).toBe(false); + }); + + it("should handle empty payload with CrowdSplit-Signature", () => { const emptyPayload = ""; - const signature = generateSignature(emptyPayload, secret); + const timestamp = "1234567890"; + const signature = generateCrowdSplitSignature(emptyPayload, secret, timestamp); const result = verifyWebhookSignature(emptyPayload, signature, secret); expect(result).toBe(true); }); @@ -60,6 +85,12 @@ describe("webhookVerification", () => { const result = verifyWebhookSignature(payload, shortSignature, secret); expect(result).toBe(false); }); + + it("should handle CrowdSplit-Signature with empty timestamp", () => { + const signature = "t=,v1=somesig"; + const result = verifyWebhookSignature(payload, signature, secret); + expect(result).toBe(false); + }); }); describe("parseWebhookPayload", () => { @@ -68,8 +99,9 @@ describe("webhookVerification", () => { id: string; } - it("should parse and verify valid webhook", () => { - const signature = generateSignature(payload, secret); + it("should parse and verify valid webhook with CrowdSplit-Signature", () => { + const timestamp = Math.floor(Date.now() / 1000).toString(); + const signature = generateCrowdSplitSignature(payload, secret, timestamp); const result = parseWebhookPayload(payload, signature, secret); expect(result.ok).toBe(true); @@ -96,7 +128,8 @@ describe("webhookVerification", () => { it("should reject invalid JSON", () => { const invalidPayload = "{ invalid json }"; - const signature = generateSignature(invalidPayload, secret); + const timestamp = Math.floor(Date.now() / 1000).toString(); + const signature = generateCrowdSplitSignature(invalidPayload, secret, timestamp); const result = parseWebhookPayload( invalidPayload, signature, @@ -121,7 +154,8 @@ describe("webhookVerification", () => { }, }, }); - const signature = generateSignature(complexPayload, secret); + const timestamp = Math.floor(Date.now() / 1000).toString(); + const signature = generateCrowdSplitSignature(complexPayload, secret, timestamp); const result = parseWebhookPayload(complexPayload, signature, secret); expect(result.ok).toBe(true); diff --git a/packages/payments/src/services/customerService.ts b/packages/payments/src/services/customerService.ts index 8ecaf1c0..86dd5b2a 100644 --- a/packages/payments/src/services/customerService.ts +++ b/packages/payments/src/services/customerService.ts @@ -1,5 +1,4 @@ import type { Customer, OakClient, Result } from "../types"; -import { err } from "../types"; import { httpClient } from "../utils/httpClient"; import { buildQueryString } from "./helpers"; import { withAuth } from "../utils/withAuth"; @@ -22,6 +21,21 @@ export interface CustomerService { customer_id: string, filter: Customer.BalanceFilter, ): Promise>; + + /** Upload files for a customer (multipart/form-data). */ + uploadFiles( + customerId: string, + files: unknown, + ): Promise>; + + /** List files for a customer. */ + getFiles(customerId: string): Promise>; + + /** Populate KYC data for a customer on a specific provider platform. */ + populatePlatform( + customerId: string, + data: Customer.PlatformRequest, + ): Promise>; } /** @@ -98,20 +112,61 @@ export const createCustomerService = (client: OakClient): CustomerService => ({ id: string, sync: Customer.Sync, ): Promise> { - const token = await client.getAccessToken(); - if (!token.ok) { - return err(token.error); - } - - return httpClient.post( - `${client.config.baseUrl}/api/v1/customers/${id}/sync`, - sync, - { - headers: { - Authorization: `Bearer ${token.value}`, + return withAuth(client, (token) => + httpClient.post( + buildUrl(client.config.baseUrl, "api/v1/customers", id, "sync"), + sync, + { + headers: { + Authorization: `Bearer ${token}`, + }, + retryOptions: client.retryOptions, + }, + ), + ); + }, + + async uploadFiles( + customerId: string, + files: unknown, + ): Promise> { + return withAuth(client, (token) => + httpClient.postMultipart( + buildUrl(client.config.baseUrl, "api/v1/customers", customerId, "files"), + files, + { + headers: { Authorization: `Bearer ${token}` }, + retryOptions: client.retryOptions, + }, + ), + ); + }, + + async getFiles(customerId: string): Promise> { + return withAuth(client, (token) => + httpClient.get( + buildUrl(client.config.baseUrl, "api/v1/customers", customerId, "files"), + { + headers: { Authorization: `Bearer ${token}` }, + retryOptions: client.retryOptions, }, - retryOptions: client.retryOptions, - }, + ), + ); + }, + + async populatePlatform( + customerId: string, + data: Customer.PlatformRequest, + ): Promise> { + return withAuth(client, (token) => + httpClient.post( + buildUrl(client.config.baseUrl, "api/v1/customers", customerId, "platforms"), + data, + { + headers: { Authorization: `Bearer ${token}` }, + retryOptions: client.retryOptions, + }, + ), ); }, @@ -119,21 +174,18 @@ export const createCustomerService = (client: OakClient): CustomerService => ({ customer_id: string, filter: Customer.BalanceFilter, ): Promise> { - const token = await client.getAccessToken(); - if (!token.ok) { - return err(token.error); - } - const queryString = buildQueryString(filter); - return httpClient.get( - `${client.config.baseUrl}/api/v1/customers/${customer_id}/balance${queryString}`, - { - headers: { - Authorization: `Bearer ${token.value}`, + return withAuth(client, (token) => + httpClient.get( + `${buildUrl(client.config.baseUrl, "api/v1/customers", customer_id, "balances")}${queryString}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + retryOptions: client.retryOptions, }, - retryOptions: client.retryOptions, - }, + ), ); }, }); diff --git a/packages/payments/src/services/disputeService.ts b/packages/payments/src/services/disputeService.ts new file mode 100644 index 00000000..2931d973 --- /dev/null +++ b/packages/payments/src/services/disputeService.ts @@ -0,0 +1,78 @@ +import type { Dispute, OakClient, Result } from "../types"; +import { httpClient } from "../utils/httpClient"; +import { withAuth } from "../utils/withAuth"; +import { buildUrl } from "../utils/buildUrl"; + +export interface DisputeService { + /** List all disputes for the merchant. */ + list(): Promise>; + /** Update evidence for a dispute. */ + updateEvidence( + disputeId: string, + evidence: Dispute.EvidenceRequest, + ): Promise>; + /** Submit a dispute for review. */ + submit(disputeId: string): Promise>; + /** Close a dispute. */ + close(disputeId: string): Promise>; +} + +/** + * @param client - Configured OakClient instance + * @returns DisputeService instance + */ +export const createDisputeService = (client: OakClient): DisputeService => ({ + async list(): Promise> { + return withAuth(client, (token) => + httpClient.get( + buildUrl(client.config.baseUrl, "api/v1/disputes"), + { + headers: { Authorization: `Bearer ${token}` }, + retryOptions: client.retryOptions, + }, + ), + ); + }, + + async updateEvidence( + disputeId: string, + evidence: Dispute.EvidenceRequest, + ): Promise> { + return withAuth(client, (token) => + httpClient.put( + buildUrl(client.config.baseUrl, "api/v1/disputes", disputeId, "evidence"), + evidence, + { + headers: { Authorization: `Bearer ${token}` }, + retryOptions: client.retryOptions, + }, + ), + ); + }, + + async submit(disputeId: string): Promise> { + return withAuth(client, (token) => + httpClient.put( + buildUrl(client.config.baseUrl, "api/v1/disputes", disputeId, "submit"), + undefined, + { + headers: { Authorization: `Bearer ${token}` }, + retryOptions: client.retryOptions, + }, + ), + ); + }, + + async close(disputeId: string): Promise> { + return withAuth(client, (token) => + httpClient.put( + buildUrl(client.config.baseUrl, "api/v1/disputes", disputeId, "close"), + undefined, + { + headers: { Authorization: `Bearer ${token}` }, + retryOptions: client.retryOptions, + }, + ), + ); + }, +}); diff --git a/packages/payments/src/services/fileService.ts b/packages/payments/src/services/fileService.ts new file mode 100644 index 00000000..04cf9180 --- /dev/null +++ b/packages/payments/src/services/fileService.ts @@ -0,0 +1,66 @@ +import type { File, OakClient, Result } from "../types"; +import { httpClient } from "../utils/httpClient"; +import { withAuth } from "../utils/withAuth"; +import { buildUrl } from "../utils/buildUrl"; + +export interface FileService { + upload(files: unknown): Promise>; + list(): Promise>; + get(fileId: string): Promise>; + delete(fileId: string): Promise>; +} + +/** + * @param client - Configured OakClient instance + * @returns FileService instance + */ +export const createFileService = (client: OakClient): FileService => ({ + async upload(files: unknown): Promise> { + return withAuth(client, (token) => + httpClient.postMultipart( + buildUrl(client.config.baseUrl, "api/v1/files"), + files, + { + headers: { Authorization: `Bearer ${token}` }, + retryOptions: client.retryOptions, + }, + ), + ); + }, + + async list(): Promise> { + return withAuth(client, (token) => + httpClient.get( + buildUrl(client.config.baseUrl, "api/v1/files"), + { + headers: { Authorization: `Bearer ${token}` }, + retryOptions: client.retryOptions, + }, + ), + ); + }, + + async get(fileId: string): Promise> { + return withAuth(client, (token) => + httpClient.get( + buildUrl(client.config.baseUrl, "api/v1/files", fileId), + { + headers: { Authorization: `Bearer ${token}` }, + retryOptions: client.retryOptions, + }, + ), + ); + }, + + async delete(fileId: string): Promise> { + return withAuth(client, (token) => + httpClient.delete( + buildUrl(client.config.baseUrl, "api/v1/files", fileId), + { + headers: { Authorization: `Bearer ${token}` }, + retryOptions: client.retryOptions, + }, + ), + ); + }, +}); diff --git a/packages/payments/src/services/index.ts b/packages/payments/src/services/index.ts index 47eeb926..e328b1c9 100644 --- a/packages/payments/src/services/index.ts +++ b/packages/payments/src/services/index.ts @@ -30,3 +30,18 @@ export type { WebhookService } from "./webhookService"; export { createRefundService } from "./refundService"; export type { RefundService } from "./refundService"; + +export { createSubscriptionService } from "./subscriptionService"; +export type { SubscriptionService } from "./subscriptionService"; + +export { createDisputeService } from "./disputeService"; +export type { DisputeService } from "./disputeService"; + +export { createPayoutService } from "./payoutService"; +export type { PayoutService } from "./payoutService"; + +export { createFileService } from "./fileService"; +export type { FileService } from "./fileService"; + +export { createTaxService } from "./taxService"; +export type { TaxService } from "./taxService"; diff --git a/packages/payments/src/services/paymentMethodService.ts b/packages/payments/src/services/paymentMethodService.ts index 90cc2566..cca95a7b 100644 --- a/packages/payments/src/services/paymentMethodService.ts +++ b/packages/payments/src/services/paymentMethodService.ts @@ -17,6 +17,11 @@ export interface PaymentMethodService { customerId: string, query?: PaymentMethod.ListQuery, ): Promise>; + update( + customerId: string, + paymentMethodId: string, + paymentMethod: PaymentMethod.Request, + ): Promise>; delete( customerId: string, paymentMethodId: string, @@ -82,6 +87,25 @@ export const createPaymentMethodService = ( ); }, + async update( + customerId: string, + paymentMethodId: string, + paymentMethod: PaymentMethod.Request, + ): Promise> { + return withAuth(client, (token) => + httpClient.put( + buildUrl(client.config.baseUrl, "api/v1/customers", customerId, "payment_methods", paymentMethodId), + paymentMethod, + { + headers: { + Authorization: `Bearer ${token}`, + }, + retryOptions: client.retryOptions, + }, + ), + ); + }, + async delete( customerId: string, paymentMethodId: string, diff --git a/packages/payments/src/services/paymentService.ts b/packages/payments/src/services/paymentService.ts index d8b0cf90..cf71c0d0 100644 --- a/packages/payments/src/services/paymentService.ts +++ b/packages/payments/src/services/paymentService.ts @@ -7,6 +7,12 @@ export interface PaymentService { create(payment: Payment.Request): Promise>; confirm(paymentId: string): Promise>; cancel(paymentId: string): Promise>; + /** Capture an authorized payment (manual capture flow). */ + capture(paymentId: string): Promise>; + /** Mark a payment as paid in sandbox environment. */ + sandboxPaid(paymentId: string): Promise>; + /** Simulate settlement for a payment in sandbox environment. */ + sandboxSettle(paymentId: string): Promise>; } /** @@ -58,4 +64,49 @@ export const createPaymentService = (client: OakClient): PaymentService => ({ ), ); }, + + async capture(paymentId: string): Promise> { + return withAuth(client, (token) => + httpClient.post( + buildUrl(client.config.baseUrl, "api/v1/payments", paymentId, "capture"), + {}, + { + headers: { + Authorization: `Bearer ${token}`, + }, + retryOptions: client.retryOptions, + }, + ), + ); + }, + + async sandboxPaid(paymentId: string): Promise> { + return withAuth(client, (token) => + httpClient.post( + buildUrl(client.config.baseUrl, "api/v1/payments", paymentId, "sandbox", "paid"), + {}, + { + headers: { + Authorization: `Bearer ${token}`, + }, + retryOptions: client.retryOptions, + }, + ), + ); + }, + + async sandboxSettle(paymentId: string): Promise> { + return withAuth(client, (token) => + httpClient.post( + buildUrl(client.config.baseUrl, "api/v1/payments", paymentId, "sandbox", "settle"), + {}, + { + headers: { + Authorization: `Bearer ${token}`, + }, + retryOptions: client.retryOptions, + }, + ), + ); + }, }); diff --git a/packages/payments/src/services/payoutService.ts b/packages/payments/src/services/payoutService.ts new file mode 100644 index 00000000..d6fff2c8 --- /dev/null +++ b/packages/payments/src/services/payoutService.ts @@ -0,0 +1,27 @@ +import type { Payout, OakClient, Result } from "../types"; +import { httpClient } from "../utils/httpClient"; +import { withAuth } from "../utils/withAuth"; +import { buildUrl } from "../utils/buildUrl"; + +export interface PayoutService { + create(request: Payout.Request): Promise>; +} + +/** + * @param client - Configured OakClient instance + * @returns PayoutService instance + */ +export const createPayoutService = (client: OakClient): PayoutService => ({ + async create(request: Payout.Request): Promise> { + return withAuth(client, (token) => + httpClient.post( + buildUrl(client.config.baseUrl, "api/v1/outbound_payments"), + request, + { + headers: { Authorization: `Bearer ${token}` }, + retryOptions: client.retryOptions, + }, + ), + ); + }, +}); diff --git a/packages/payments/src/services/subscriptionService.ts b/packages/payments/src/services/subscriptionService.ts new file mode 100644 index 00000000..4837b748 --- /dev/null +++ b/packages/payments/src/services/subscriptionService.ts @@ -0,0 +1,119 @@ +import type { Subscription, OakClient, Result } from "../types"; +import { httpClient } from "../utils/httpClient"; +import { buildQueryString } from "./helpers"; +import { withAuth } from "../utils/withAuth"; +import { buildUrl } from "../utils/buildUrl"; + +export interface SubscriptionService { + subscribe( + request: Subscription.SubscribeRequest, + ): Promise>; + cancel(subscriptionId: string): Promise>; + list( + params: Subscription.ListQuery, + ): Promise>; + get(subscriptionId: string): Promise>; + pay( + subscriptionId: string, + request: Subscription.PaymentRequest, + ): Promise>; +} + +/** + * @param client - Configured OakClient instance + * @returns SubscriptionService instance + */ +export const createSubscriptionService = ( + client: OakClient, +): SubscriptionService => ({ + async subscribe( + request: Subscription.SubscribeRequest, + ): Promise> { + return withAuth(client, (token) => + httpClient.post( + buildUrl(client.config.baseUrl, "api/v1/subscription/subscribe"), + request, + { + headers: { Authorization: `Bearer ${token}` }, + retryOptions: client.retryOptions, + }, + ), + ); + }, + + async cancel( + subscriptionId: string, + ): Promise> { + return withAuth(client, (token) => + httpClient.patch( + buildUrl( + client.config.baseUrl, + "api/v1/subscription/subscriptions", + subscriptionId, + "cancel", + ), + undefined, + { + headers: { Authorization: `Bearer ${token}` }, + retryOptions: client.retryOptions, + }, + ), + ); + }, + + async list( + params: Subscription.ListQuery, + ): Promise> { + const queryString = buildQueryString(params); + return withAuth(client, (token) => + httpClient.get( + `${buildUrl(client.config.baseUrl, "api/v1/subscription/list")}${queryString}`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + retryOptions: client.retryOptions, + }, + ), + ); + }, + + async get( + subscriptionId: string, + ): Promise> { + return withAuth(client, (token) => + httpClient.get( + buildUrl( + client.config.baseUrl, + "api/v1/subscription", + subscriptionId, + ), + { + headers: { Authorization: `Bearer ${token}` }, + retryOptions: client.retryOptions, + }, + ), + ); + }, + + async pay( + subscriptionId: string, + request: Subscription.PaymentRequest, + ): Promise> { + return withAuth(client, (token) => + httpClient.post( + buildUrl( + client.config.baseUrl, + "api/v1/subscription", + subscriptionId, + "payment", + ), + request, + { + headers: { Authorization: `Bearer ${token}` }, + retryOptions: client.retryOptions, + }, + ), + ); + }, +}); diff --git a/packages/payments/src/services/taxService.ts b/packages/payments/src/services/taxService.ts new file mode 100644 index 00000000..ee0f0c61 --- /dev/null +++ b/packages/payments/src/services/taxService.ts @@ -0,0 +1,29 @@ +import type { Tax, OakClient, Result } from "../types"; +import { httpClient } from "../utils/httpClient"; +import { withAuth } from "../utils/withAuth"; +import { buildUrl } from "../utils/buildUrl"; + +export interface TaxService { + calculate(request: Tax.CalculateRequest): Promise>; +} + +/** + * @param client - Configured OakClient instance + * @returns TaxService instance + */ +export const createTaxService = (client: OakClient): TaxService => ({ + async calculate( + request: Tax.CalculateRequest, + ): Promise> { + return withAuth(client, (token) => + httpClient.post( + buildUrl(client.config.baseUrl, "api/v1/taxes/calculate"), + request, + { + headers: { Authorization: `Bearer ${token}` }, + retryOptions: client.retryOptions, + }, + ), + ); + }, +}); diff --git a/packages/payments/src/services/transferService.ts b/packages/payments/src/services/transferService.ts index 99d8c585..e3a61c28 100644 --- a/packages/payments/src/services/transferService.ts +++ b/packages/payments/src/services/transferService.ts @@ -1,10 +1,12 @@ import type { Transfer, OakClient, Result } from "../types"; +import { ApiResponse } from "../types"; import { httpClient } from "../utils/httpClient"; import { withAuth } from "../utils/withAuth"; import { buildUrl } from "../utils/buildUrl"; export interface TransferService { create(transfer: Transfer.Request): Promise>; + sendWebhook(data: unknown): Promise>>; } /** @@ -26,4 +28,19 @@ export const createTransferService = (client: OakClient): TransferService => ({ ), ); }, + + async sendWebhook(data: unknown): Promise>> { + return withAuth(client, (token) => + httpClient.post>( + buildUrl(client.config.baseUrl, "api/v1/transfer/webhook"), + data, + { + headers: { + Authorization: `Bearer ${token}`, + }, + retryOptions: client.retryOptions, + }, + ), + ); + }, }); diff --git a/packages/payments/src/types/buy.ts b/packages/payments/src/types/buy.ts index d947870d..ab1a7101 100644 --- a/packages/payments/src/types/buy.ts +++ b/packages/payments/src/types/buy.ts @@ -3,7 +3,7 @@ import { ApiResponse } from "./common"; export namespace Buy { export interface PaymentMethod { type: "customer_wallet"; - chain?: "ethereum" | "polygon" | "arbitrum" | "solana"; + chain?: "ethereum" | "polygon" | "arbitrum" | "solana" | "celo"; evm_address: string; } @@ -48,10 +48,36 @@ export namespace Buy { provider: "bridge"; source: Source; destination: Destination; + provider_data?: { + developer_fee_percent?: number; + }; + metadata?: Metadata; + } + + export interface BrlaPaymentMethod { + id?: string; + type?: "wallet"; + wallet_details?: { + address: string; + }; + } + + export interface Brla { + provider: "brla"; + source: { + amount: number; + currency: "brl"; + customer_id?: string; + }; + destination: { + currency: "brla"; + customer_id?: string; + payment_method: BrlaPaymentMethod; + }; metadata?: Metadata; } - export type Request = Bridge; + export type Request = Bridge | Brla; export type Response = ApiResponse; } diff --git a/packages/payments/src/types/customer.ts b/packages/payments/src/types/customer.ts index 6cdb73bf..232b32f0 100644 --- a/packages/payments/src/types/customer.ts +++ b/packages/payments/src/types/customer.ts @@ -3,6 +3,20 @@ import { ApiResponse } from "./common"; export namespace Customer { export type DocumentType = "personal_tax_id" | "company_tax_id"; + export type Status = + | "INITIATED" + | "APPROVED" + | "REJECTED" + | "IN_REVIEW" + | "INCOMPLETE" + | "NOT_STARTED" + | "DOCUMENT_REQUIRED" + | "PENDING" + | "PROCESSING" + | "DOCUMENT_PENDING" + | "DOCUMENT_APPROVED" + | "AWAITING_CONFIRMATION"; + export interface Base { email: string; document_number?: string; @@ -51,6 +65,9 @@ export namespace Customer { customer_wallet?: string | null; trading_wallet?: string | null; account_type?: string | null; + additional_info?: Record | null; + synced?: boolean | null; + synced_at?: string | null; } type Provider = @@ -89,6 +106,14 @@ export namespace Customer { document_type?: string; country_code?: string; } + export type FilesResponse = ApiResponse; + export type PlatformResponse = ApiResponse; + + export interface PlatformRequest { + provider: string; + [key: string]: unknown; + } + export interface BalanceFilter { provider: string; role: string; @@ -113,7 +138,7 @@ export namespace Customer { as_of: string; totals: { currency: string; - amount: number; + available: number; pending: number; reserved: number; instant_payouts: number; diff --git a/packages/payments/src/types/dispute.ts b/packages/payments/src/types/dispute.ts new file mode 100644 index 00000000..960f0ee2 --- /dev/null +++ b/packages/payments/src/types/dispute.ts @@ -0,0 +1,56 @@ +import { ApiResponse } from "./common"; + +export namespace Dispute { + // ---------------------- + // Evidence request + // ---------------------- + export interface FileEvidence { + id: string; + type: + | "receipt" + | "customer_signature" + | "shipping_documentation" + | "service_documentation" + | "refund_policy" + | "cancellation_policy" + | "uncategorized_file"; + } + + export interface TextEvidence { + key: + | "customer_name" + | "customer_email_address" + | "customer_purchase_ip" + | "product_description" + | "duplicate_charge_id" + | "enhanced_evidence" + | "customer_communication" + | "refund_policy" + | "refund_policy_disclosure" + | "refund_refusal_explanation" + | "service_date" + | "shipping_address" + | "shipping_carrier" + | "shipping_date" + | "shipping_tracking_number" + | "shipping_tracking_url" + | "duplicate_charge_explanation" + | "cancellation_policy" + | "cancellation_rebuttal" + | "uncategorized_text"; + value: string; + } + + export interface EvidenceRequest { + file_evidences?: FileEvidence[]; + text_evidences?: TextEvidence[]; + } + + // ---------------------- + // Responses + // ---------------------- + export type ListResponse = ApiResponse; + export type UpdateResponse = ApiResponse; + export type SubmitResponse = ApiResponse; + export type CloseResponse = ApiResponse; +} diff --git a/packages/payments/src/types/file.ts b/packages/payments/src/types/file.ts new file mode 100644 index 00000000..35a5a153 --- /dev/null +++ b/packages/payments/src/types/file.ts @@ -0,0 +1,8 @@ +import { ApiResponse } from "./common"; + +export namespace File { + export type UploadResponse = ApiResponse; + export type ListResponse = ApiResponse; + export type GetResponse = ApiResponse; + export type DeleteResponse = ApiResponse; +} diff --git a/packages/payments/src/types/index.ts b/packages/payments/src/types/index.ts index 7cd3553e..e8aa76f2 100644 --- a/packages/payments/src/types/index.ts +++ b/packages/payments/src/types/index.ts @@ -14,3 +14,8 @@ export * from "./plan"; export * from "./buy"; export * from "./result"; export * from "./refund"; +export * from "./subscription"; +export * from "./dispute"; +export * from "./payout"; +export * from "./file"; +export * from "./tax"; diff --git a/packages/payments/src/types/payment.ts b/packages/payments/src/types/payment.ts index 0dacfec4..27eef691 100644 --- a/packages/payments/src/types/payment.ts +++ b/packages/payments/src/types/payment.ts @@ -22,7 +22,7 @@ export namespace Payment { export interface FraudCheckConfig { threshold?: "low" | "medium" | "high"; sequence?: "fraud_before_auth" | "fraud_after_auth"; - action_on_fail?: "reject" | "review"; + action_on_fail?: "RETAIN_AUTH" | "CANCEL_AUTH"; } export interface FraudCheck { @@ -48,7 +48,7 @@ export namespace Payment { payment_method: PaymentMethod; installments?: number; float_rate?: number; - capture_method: "automatic"; + capture_method: "automatic" | "manual"; fraud_check?: FraudCheck; } @@ -151,7 +151,28 @@ export namespace Payment { metadata?: Record; } - export type Request = MercadoPagoRequest | PagarMeRequest | StripeRequest; + export interface PagarMePixRequest { + provider: "pagar_me"; + source: { + amount: number; + currency: "brl"; + customer: { + id: string; + }; + payment_method: { + type: "pix"; + expiry_date: string; // ISO date, must be in the future + }; + }; + confirm?: boolean; + metadata?: Record; + } + + export type Request = + | MercadoPagoRequest + | PagarMeRequest + | PagarMePixRequest + | StripeRequest; // ---------------------------------------- // Payment responses (create / confirm / cancel) @@ -163,7 +184,7 @@ export namespace Payment { metadata?: Record; id: string; status: string; - type: "payment"; + type: string; created_at: string; updated_at: string; provider_response?: ProviderResponse; diff --git a/packages/payments/src/types/paymentMethod.ts b/packages/payments/src/types/paymentMethod.ts index 82041073..8c859ebb 100644 --- a/packages/payments/src/types/paymentMethod.ts +++ b/packages/payments/src/types/paymentMethod.ts @@ -1,8 +1,33 @@ import { ApiResponse } from "./common"; export namespace PaymentMethod { + export type MethodType = + | "bank" + | "card" + | "plaid" + | "virtual_account" + | "liquidation_address" + | "trading_wallet" + | "customer_wallet" + | "pix" + | "evm_address"; + + export type PaymentMethodKind = + | "pix" + | "boleto" + | "card" + | "bank_transfer" + | "bank" + | "cash_payment"; + + export type BankAccountType = + | "payment" + | "checking" + | "savings" + | "virtual_account"; + export interface BridgeBankAccount { - type: string; // from SUBJECT_PAYMENT_METHOD_TYPE keys + type: MethodType; provider?: string; // from PLATFORMS keys currency?: string; // from CURRENCY keys (lowercase) bank_name: string; @@ -22,7 +47,7 @@ export namespace PaymentMethod { } export interface OakBankAccount { - type: string; // from SUBJECT_PAYMENT_METHOD_TYPE keys + type: MethodType; provider?: string; // from PLATFORMS keys bank_branch_code: string; bank_account_number: string; // pattern: digits only @@ -34,7 +59,7 @@ export namespace PaymentMethod { } export interface StripeBankAccount { - type: string; // from SUBJECT_PAYMENT_METHOD_TYPE keys + type: MethodType; provider?: string; // from PLATFORMS keys currency?: string; // from CURRENCY keys bank_name: string; @@ -47,7 +72,7 @@ export namespace PaymentMethod { } export interface MercadoPagoCard { - type: string; // from SUBJECT_PAYMENT_METHOD_TYPE keys + type: MethodType; provider: string; // from PLATFORMS keys card_details: { card_token: string; @@ -56,7 +81,7 @@ export namespace PaymentMethod { } export interface PagarMeCard { - type: string; // from SUBJECT_PAYMENT_METHOD_TYPE keys + type: MethodType; provider: string; // from PLATFORMS keys card_token: string; billing_address: { @@ -72,13 +97,13 @@ export namespace PaymentMethod { } export interface StripeCard { - type: string; // from SUBJECT_PAYMENT_METHOD_TYPE keys + type: MethodType; provider: string; // from PLATFORMS keys metadata?: Record; } export interface OakCustomerWallet { - type: string; // from SUBJECT_PAYMENT_METHOD_TYPE keys + type: MethodType; provider?: string; // from PLATFORMS keys evm_address: string; // validated as checksummed Ethereum address chain: string; // from WALLET_CHAIN keys @@ -87,7 +112,7 @@ export namespace PaymentMethod { } export interface BridgeLiquidationAddress { - type: string; // from SUBJECT_PAYMENT_METHOD_TYPE keys + type: MethodType; provider: string; // from PLATFORMS keys source_currency: string; // from ASSET_TYPE keys destination_currency: string; // from CURRENCY keys @@ -101,20 +126,45 @@ export namespace PaymentMethod { } export interface OakPix { - type: string; // from SUBJECT_PAYMENT_METHOD_TYPE keys + type: MethodType; provider?: string; // from PLATFORMS keys pix_string: string; metadata?: Record; } export interface BridgePlaid { - type: string; // from SUBJECT_PAYMENT_METHOD_TYPE keys + type: MethodType; provider: string; // from PLATFORMS keys metadata?: Record; } + export interface PlaidResponseData { + id: string; + type: MethodType; + link_token: string; + callback_url: string; + link_token_expires_at: string; + metadata?: Record; + } + + export interface TradingWallet { + type: MethodType; + provider: string; // from PLATFORMS keys + metadata?: Record; + } + + export interface TradingWalletResponseData { + id: string; + type: MethodType; + status: string; + wallet_details: { + address: string; + }; + metadata?: Record; + } + export interface BridgeVirtualAccount { - type: string; // from SUBJECT_PAYMENT_METHOD_TYPE keys + type: MethodType; provider: string; // from PLATFORMS keys source_currency: string; // from CURRENCY keys destination_currency: string; // from ASSET_TYPE keys @@ -146,7 +196,8 @@ export namespace PaymentMethod { | BridgeLiquidationAddress | OakPix | BridgePlaid - | BridgeVirtualAccount; + | BridgeVirtualAccount + | TradingWallet; export type ResponseData = Request & { id: string; diff --git a/packages/payments/src/types/payout.ts b/packages/payments/src/types/payout.ts new file mode 100644 index 00000000..0c751be1 --- /dev/null +++ b/packages/payments/src/types/payout.ts @@ -0,0 +1,35 @@ +import { ApiResponse } from "./common"; + +export namespace Payout { + export interface Request { + payment_method_id: string; + amount: number; + currency: "BRL" | "USD" | "COP" | "BRLA" | "JPY"; + customer_id: string; + metadata?: Record; + } + + export interface PayoutData { + id: string; + status: string; + type: string; + provider: string; + source: { + amount: number; + currency: string; + customer?: { id: string }; + }; + destination?: { + customer?: { id: string }; + payment_method?: { + id: string; + type: string; + }; + }; + metadata?: Record; + created_at: string; + updated_at: string; + } + + export type Response = ApiResponse; +} diff --git a/packages/payments/src/types/plan.ts b/packages/payments/src/types/plan.ts index a3aea98b..9d340d8c 100644 --- a/packages/payments/src/types/plan.ts +++ b/packages/payments/src/types/plan.ts @@ -9,11 +9,14 @@ export namespace Plan { description: string; frequency: number; // in days price: number; + overridden_price?: number; start_date: string; // ISO date format (YYYY-MM-DD) end_date?: string; // Optional ISO date format is_auto_renewable: boolean; currency: string; // e.g. "BRL" allow_amount_override: boolean; + campaign_id?: string; + campaign_name?: string; created_by: string; } @@ -28,6 +31,7 @@ export namespace Plan { description: string; frequency: number; // in days price: number; + overridden_price?: number; is_active: boolean; start_time: string; // ISO datetime end_time: string; // ISO datetime @@ -36,6 +40,8 @@ export namespace Plan { updated_by: string; currency: string; // lowercase like "brl" allow_amount_override: boolean; + campaign_id?: string; + campaign_name?: string; created_at: string; // ISO datetime updated_at: string; // ISO datetime deleted_at: string | null; @@ -66,5 +72,7 @@ export namespace Plan { export interface ListQuery { page_no?: number; per_page?: number; + active?: boolean; + expired?: boolean; } } diff --git a/packages/payments/src/types/provider.ts b/packages/payments/src/types/provider.ts index 757c11c7..b9bcfe91 100644 --- a/packages/payments/src/types/provider.ts +++ b/packages/payments/src/types/provider.ts @@ -9,10 +9,31 @@ export namespace Provider { | "mercado_pago" | "bridge" | "stripe" - | "pagar_me"; + | "pagar_me" + | "brla" + | "facilita_pay" + | "inter_bank" + | "wallet_service" + | "crowd_split" + | "konduto" + | "cel_coin" + | "cel_baas"; export type TargetRole = "subaccount" | "customer" | "connected_account"; + export type PlatformStatus = + | "NOT_SUBMITTED" + | "SUBMITTED" + | "AWAITING_CONFIRMATION" + | "PROCESSING" + | "APPROVED" + | "REJECTED" + | "RESTRICTED" + | "CANCELLED" + | "ERROR"; + + export type KycLevel = "1" | "2"; + // ---------------------- // Schema // ---------------------- diff --git a/packages/payments/src/types/sell.ts b/packages/payments/src/types/sell.ts index 7083e6e7..1bdd049f 100644 --- a/packages/payments/src/types/sell.ts +++ b/packages/payments/src/types/sell.ts @@ -6,7 +6,8 @@ export namespace Sell { // ---------------------- export type PaymentMethod = | { type: "pix"; id: string } // saved payment method - | { type: "pix"; pix_string: string }; // direct PIX string + | { type: "pix"; pix_string: string } // direct PIX string + | { type: "bank"; id: string }; // saved bank payment method // ---------------------- // Request @@ -42,7 +43,7 @@ export namespace Sell { currency: string; customer: { id: string }; payment_method: { - type: "pix"; + type: "pix" | "bank"; id?: string; pix_string?: string; }; diff --git a/packages/payments/src/types/subscription.ts b/packages/payments/src/types/subscription.ts new file mode 100644 index 00000000..30c2973e --- /dev/null +++ b/packages/payments/src/types/subscription.ts @@ -0,0 +1,83 @@ +import { ApiResponse } from "./common"; + +export namespace Subscription { + // ---------------------- + // Subscribe request + // ---------------------- + export interface SubscribeRequest { + plan_id: string; + source_customer_id: string; + destination_customer_id: string; + payment_method_id: string; + payment_method_type: "CARD" | "PIX" | "BOLETO"; + payment_method_provider: "PAGAR_ME" | "FACILITA_PAY" | "BRLA"; + fee_bearer: "connected_account" | "platform"; + } + + // ---------------------- + // Payment request + // ---------------------- + export interface PaymentRequest { + customer_id: string; + payment_method_id: string; + payment_method_type: "CARD" | "PIX" | "BOLETO"; + payment_method_provider: "PAGAR_ME" | "FACILITA_PAY" | "BRLA"; + } + + // ---------------------- + // List query + // ---------------------- + export interface ListQuery { + customer_id: string; + status?: string; + per_page?: number; + page_no?: number; + } + + // ---------------------- + // Data + // ---------------------- + export type Status = + | "active" + | "pending_activation" + | "canceled" + | "expired" + | "queued"; + + export interface Details { + hash_id: string; + start_time: string; + end_time: string; + plan_hash_id: string; + auto_renew: boolean; + status: Status; + created_at: string; + updated_at: string; + } + + export interface SubscribeData { + id: string; + status: string; + sub_status: string; + } + + export interface Pagination { + per_page: number; + page_no: number; + total: number; + } + + export interface ListData { + data: Details[]; + pagination: Pagination; + } + + // ---------------------- + // Responses + // ---------------------- + export type SubscribeResponse = ApiResponse; + export type DetailsResponse = ApiResponse
; + export type ListResponse = ApiResponse; + export type CancelResponse = ApiResponse>; + export type PaymentResponse = ApiResponse; +} diff --git a/packages/payments/src/types/tax.ts b/packages/payments/src/types/tax.ts new file mode 100644 index 00000000..5b26d884 --- /dev/null +++ b/packages/payments/src/types/tax.ts @@ -0,0 +1,10 @@ +import { ApiResponse } from "./common"; + +export namespace Tax { + export interface CalculateRequest { + provider: string; + [key: string]: unknown; + } + + export type CalculateResponse = ApiResponse; +} diff --git a/packages/payments/src/types/transactions.ts b/packages/payments/src/types/transactions.ts index 65547d59..ba95014c 100644 --- a/packages/payments/src/types/transactions.ts +++ b/packages/payments/src/types/transactions.ts @@ -12,8 +12,10 @@ export namespace Transaction { type_list?: string; // e.g. "installment_payment" status?: string; // comma-separated, e.g. "created,processing" payment_method?: string; // e.g. "pix" - dateFrom?: string; // e.g. "2025-07-02" - dateTo?: string; // e.g. "2025-07-02" + date_from?: string; // e.g. "2025-07-02" + date_to?: string; // e.g. "2025-07-02" + provider?: string; // comma-separated, e.g. "stripe,pagar_me" + strict?: boolean; source_currency?: string; // e.g. "brla" destination_currency?: string; // e.g. "brl" } @@ -22,24 +24,91 @@ export namespace Transaction { // Status // ---------------------- export type Status = - | "INITIATED" - | "PENDING" - | "COMPLETED" - | "SETTLED" - | "FAILED" - | "CANCELED_AFTER_COMPLETION"; + | "created" + | "awaiting_confirmation" + | "processing" + | "captured" + | "succeeded" + | "failed" + | "canceled_after_completion" + | "canceled"; + + // ---------------------- + // Type + // ---------------------- + export type TransactionType = + | "pledge" + | "payment" + | "pay_out" + | "batch_transfer" + | "transfer" + | "buy" + | "sell" + | "cancel" + | "refund" + | "payment_method_verification" + | "pledge_with_installment" + | "recurring_payment" + | "installment_payment" + | "external"; + + // ---------------------- + // Sub-status + // ---------------------- + export type SubStatus = + | "payment_intent_created" + | "awaiting_user_initiation" + | "instant_payment_completed" + | "on_ramp_successful" + | "awaiting_capture" + | "captured" + | "authorized" + | "collateral_account" + | "transfer_initiated" + | "transfer_completed" + | "collateral_added" + | "payment_completed" + | "fraud_check_approved" + | "fraud_check_pending" + | "fraud_check_cancelled" + | "capture_failed" + | "on_ramp_failed" + | "transfer_failed" + | "instant_payment_failed" + | "payment_failed" + | "collateral_add_failed" + | "authorization_failed" + | "fraud_check_failed" + | "capture_cancelled" + | "capture_cancellation_failed" + | "funds_scheduled" + | "funds_received" + | "payment_submitted" + | "payment_processed" + | "requires_action" + | "off_ramp_failed"; // ---------------------- // Model // ---------------------- + export interface Installment { + uid: string; + amount: number; + settlement_date: string; + status: string; + sequence: number; + } + export interface Item { id: string; status: Status | string; - type: string; + sub_status?: SubStatus | string | null; + type: TransactionType | string; source: Payment.Source; confirm: boolean; metadata?: Payment.Metadata; provider: string; + installments?: Installment[]; created_at: string; updated_at: string; } diff --git a/packages/payments/src/types/transfer.ts b/packages/payments/src/types/transfer.ts index a49e8de8..978e92da 100644 --- a/packages/payments/src/types/transfer.ts +++ b/packages/payments/src/types/transfer.ts @@ -16,8 +16,8 @@ export namespace Transfer { }; payment_method?: { id?: string; // if present, chain and evm_address are forbidden - type: string; // from TRANSFER_PAYMENT_METHOD_TYPE keys - chain?: string; // from WALLET_CHAIN values, required when id is absent + type: "customer_wallet" | "bank"; + chain?: "polygon" | "celo"; // required when id is absent evm_address?: string; // required when id is absent, validated as checksummed address }; }; diff --git a/packages/payments/src/types/webhook.ts b/packages/payments/src/types/webhook.ts index 4ad44c49..d5504373 100644 --- a/packages/payments/src/types/webhook.ts +++ b/packages/payments/src/types/webhook.ts @@ -1,6 +1,23 @@ import { ApiResponse } from "./common"; export namespace Webhook { + // ---------------------- + // Categories + // ---------------------- + export type Category = + | "payment_lifecycle" + | "provider_registration_lifecycle" + | "payment_method_lifecycle" + | "transfer_lifecycle" + | "buy_lifecycle" + | "sell_lifecycle" + | "payout_lifecycle" + | "dispute_lifecycle" + | "refund_lifecycle" + | "OTHER" + | "externally_lifecycle" + | "customer_sync_lifecycle"; + // ---------------------- // Data // ---------------------- @@ -30,17 +47,98 @@ export namespace Webhook { // ---------------------- // Notifications // ---------------------- + export type EventType = + // Customer lifecycle + | "customer.verified" + | "customer.processing" + | "customer.action_required" + | "customer.rejected" + | "customer.created" + | "customer.updated" + // Customer sync lifecycle + | "customer.sync.started" + | "customer.sync.succeeded" + | "customer.sync.failed" + // Provider registration lifecycle + | "provider_registration.submitted" + | "provider_registration.approved" + | "provider_registration.restricted" + | "provider_registration.rejected" + | "provider_registration.awaiting_confirmation" + | "provider_registration.processing" + | "provider_registration.action_required" + | "provider_registration.documents_uploaded" + | "provider_registration.verification_expired" + // Payment lifecycle + | "payment.processing" + | "payment.succeeded" + | "payment.captured" + | "payment.failed" + | "payment.awaiting_confirmation" + | "payment.cancelled" + | "payment.refunded" + | "payment.updated" + // Payment refund sub-events + | "payment.refund.created" + | "payment.refund.updated" + | "payment.refund.failed" + // Refund lifecycle + | "refund.failed" + | "refund.succeeded" + // Payment method lifecycle + | "payment_method.verified" + | "payment_method.approved" + | "payment_method.rejected" + | "payment_method.created" + | "payment_method.updated" + // Transaction lifecycle + | "transaction.updated" + | "transaction.sc_updated" + // Dispute lifecycle + | "payment.disputed" + | "payment.dispute.updated" + | "payment.dispute.closed" + | "payment.dispute.funds_reinstated" + | "payment.dispute.funds_withdrawn" + // Payout lifecycle + | "payout.succeeded" + // Installment payment lifecycle + | "payment.installment.succeeded" + | "payment.installment.failed" + // KYC lifecycle + | "kyc.approved" + | "kyc.rejected" + | "kyc.action_required" + // External deposit lifecycle + | "external.deposit.received" + | "external.deposit.awaiting_confirmation" + | "external.deposit.completed" + | "external.deposit.succeeded" + | "external.virtual_account.funded" + // Transfer lifecycle + | "transfer.succeeded" + | "transfer.failed" + // Sell lifecycle + | "sell.succeeded" + | "sell.failed" + // Buy lifecycle + | "buy.succeeded" + | "buy.completed" + | "buy.awaiting_confirmation"; + export interface Notification { id: string; is_acknowledged: boolean; - event: string | null; - category: string | null; + event: EventType | string | null; + category: Category | string | null; data: any; } export interface ListNotificationsQuery { limit?: number; offset?: number; + reference_id?: string; + reference_module?: string; } export interface ListNotificationsData { diff --git a/packages/payments/src/utils/httpClient.ts b/packages/payments/src/utils/httpClient.ts index 6a2483f1..2d9c6e19 100644 --- a/packages/payments/src/utils/httpClient.ts +++ b/packages/payments/src/utils/httpClient.ts @@ -7,6 +7,7 @@ export interface HttpClientConfig { headers?: Record; retryOptions: RetryOptions; signal?: AbortSignal; + isMultipart?: boolean; } /** @@ -28,11 +29,16 @@ const oakVersion = process.env.OAK_VERSION ?? getPackageVersion() ?? "unknown"; * @param headers - Optional custom headers to merge * @returns Merged headers with defaults */ -const mergeHeaders = (headers?: Record) => ({ - "Content-Type": "application/json", - "Oak-Version": oakVersion, - ...(headers ?? {}), -}); +const mergeHeaders = (headers?: Record, isMultipart?: boolean) => { + const base: Record = { + "Oak-Version": oakVersion, + ...(headers ?? {}), + }; + if (!isMultipart) { + base["Content-Type"] = "application/json"; + } + return base; +}; type ParseResult = | { success: true; data: unknown; error?: undefined } @@ -110,7 +116,7 @@ const request = async ( try { response = await fetch(url, { ...init, - headers: mergeHeaders(config.headers), + headers: mergeHeaders(config.headers, config.isMultipart), signal: config.signal, }); } catch (error) { @@ -162,6 +168,23 @@ export const httpClient = { body: JSON.stringify(data), }); }, + /** + * @typeParam T - Expected response body type + * @param url - Request URL + * @param data - FormData or other multipart body + * @param config - HTTP client configuration (isMultipart should be true) + * @returns Result containing parsed response or error + */ + async postMultipart( + url: string, + data: unknown, + config: HttpClientConfig + ): Promise> { + return request(url, { ...config, isMultipart: true }, { + method: "POST", + body: data as BodyInit, + }); + }, /** * @typeParam T - Expected response body type * @param url - Request URL diff --git a/packages/payments/src/utils/webhookVerification.ts b/packages/payments/src/utils/webhookVerification.ts index 521c7887..d721f782 100644 --- a/packages/payments/src/utils/webhookVerification.ts +++ b/packages/payments/src/utils/webhookVerification.ts @@ -29,13 +29,28 @@ export function verifyWebhookSignature( secret: string, ): boolean { try { - // Generate expected signature + // Parse the CrowdSplit-Signature header: "t=,v1=" + let timestamp: string; + let v1Signature: string; + + if (signature.includes("t=") && signature.includes("v1=")) { + const parts = signature.split(","); + timestamp = (parts.find(p => p.trimStart().startsWith("t="))?.replace("t=", "") ?? "").trim(); + v1Signature = (parts.find(p => p.trimStart().startsWith("v1="))?.replace("v1=", "") ?? "").trim(); + } else { + // Fallback: treat as raw signature (legacy) + timestamp = ""; + v1Signature = signature; + } + + // Crowdsplit signs: timestamp + "." + payload + const base = timestamp ? `${timestamp}.${payload}` : payload; const hmac = createHmac("sha256", secret); - hmac.update(payload); + hmac.update(base, "utf8"); const expectedSignature = hmac.digest("hex"); // Convert both signatures to buffers for timing-safe comparison - const signatureBuffer = Buffer.from(signature, "utf-8"); + const signatureBuffer = Buffer.from(v1Signature, "utf-8"); const expectedBuffer = Buffer.from(expectedSignature, "utf-8"); // Ensure buffers are same length to prevent timing attacks