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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@
},
"dependencies": {
"@types/node": "^22.15.18",
"axios": "^1.10.0"
"axios": "^1.10.0",
"form-data": "^4.0.0"
},
"devDependencies": {
"@ava/typescript": "^6.0.0",
Expand Down
2 changes: 2 additions & 0 deletions src/interfaces/client/requestOptions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Method } from 'axios';
import FormData from 'form-data';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

necesitamos esta librería? creo que usar el soporte nativo de Javascript sería mejor opción

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pucha si, estamos con node-10 como min requirement del SDK


export interface IRequestOptions {
path: string,
Expand All @@ -7,4 +8,5 @@ export interface IRequestOptions {
params?: Record<string, any>,
json?: Record<string, string>,
idempotencyKey?: string,
form?: FormData,
}
25 changes: 21 additions & 4 deletions src/lib/client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import crypto from 'crypto';

import axios, { AxiosInstance } from 'axios';
import FormData from 'form-data';

import { IConstructorOptions } from '../interfaces/client';
import { IExtendOptions } from '../interfaces/client/extensionOptions';
Expand Down Expand Up @@ -56,7 +57,7 @@ export class Client {
options: IRequestOptions,
): Promise<Record<string, string> | AsyncGenerator<Record<string, string>, void, unknown>> {
const {
path, json, paginated = false, method = 'get', params = {}, idempotencyKey,
path, json, paginated = false, method = 'get', params = {}, idempotencyKey, form,
} = options;
const url = `${this.baseUrl}${path}`;
const allParams = { ...this.params, ...params };
Expand All @@ -68,6 +69,13 @@ export class Client {
params: allParams,
});
}
if (form) {
const headers = this.buildHeaders(method, undefined, idempotencyKey, form);
const response = await this.client.request({
method, url, params: allParams, data: form, headers,
});
return response.data;
}
const rawBody = JSON.stringify(json);
const headers = this.buildHeaders(method, rawBody, idempotencyKey);
const response = await this.client.request({
Expand All @@ -90,22 +98,31 @@ export class Client {

private buildHeaders(
method: string,
rawBody: string,
rawBody?: string,
idempotencyKey?: string,
form?: FormData,
): Record<string, string> {
const headers: Record<string, string> = {
...this.staticHeaders,
...this.buildJWSHeaders(method, rawBody),
};

if (form) {
// form-data is sent as a stream, so axios won't set the multipart
// Content-Type itself; replace the default JSON one with the boundary.
delete headers['Content-Type'];
Object.assign(headers, form.getHeaders());
}

if (method === 'post') {
headers['Idempotency-Key'] = idempotencyKey || crypto.randomUUID();
}
return headers;
}

private buildJWSHeaders(method: string, rawBody: string): Record<string, string> {
if (this.jws && (method === 'post' || method === 'put' || method === 'patch')) {
private buildJWSHeaders(method: string, rawBody?: string): Record<string, string> {
// Multipart requests are not signed; skip JWS when there is no raw JSON body.
if (rawBody !== undefined && this.jws && (method === 'post' || method === 'put' || method === 'patch')) {
const signature = this.jws.generateHeader(rawBody);
return { 'Fintoc-JWS-Signature': signature };
}
Expand Down
12 changes: 11 additions & 1 deletion src/lib/managers/v2/entitiesManager.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import { Client } from '../../client';
import { ManagerMixin } from '../../mixins';
import { Entity } from '../../resources/v2/entity';

import { OnboardingsManager } from './onboardingsManager';

export class EntitiesManager extends ManagerMixin<Entity> {
static resource = 'entity';
static methods = ['list', 'get'];
static methods = ['list', 'get', 'create'];

onboardings: OnboardingsManager;

constructor(path: string, client: Client) {
super(path, client);
this.onboardings = new OnboardingsManager('/v2/entities/{entity_id}/onboardings', client);
}
}
1 change: 1 addition & 0 deletions src/lib/managers/v2/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ export * from './paymentMethodsManager';
export * from './productsManager';
export * from './subscriptionsManager';
export * from './accountStatementsManager';
export * from './onboardingsManager';
42 changes: 42 additions & 0 deletions src/lib/managers/v2/onboardingsManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { ResourceArguments, UploadFile } from '../../../types';
import { ManagerMixin } from '../../mixins';
import { Onboarding } from '../../resources/v2/onboarding';

export class OnboardingsManager extends ManagerMixin<Onboarding> {
static resource = 'onboarding';
static methods = ['list', 'get'];

create(args?: ResourceArguments): Promise<Onboarding> {
const innerArgs = args || {};
const path = this.buildPath(innerArgs);
return this._create({ path_: path, ...innerArgs });
}

submit(id: string, args?: ResourceArguments): Promise<Onboarding> {
const innerArgs = args || {};
const path = `${this.buildPath(innerArgs)}/${id}/submit`;
return this._create({ path_: path, ...innerArgs });
}

uploadDocument(
id: string,
slotKey: string,
file: UploadFile,
args?: ResourceArguments,
): Promise<Onboarding> {
const innerArgs = args || {};
const path = `${this.buildPath(innerArgs)}/${id}/documents/${slotKey}`;
return this._upload(path, file);
}

uploadShareholderDocument(
id: string,
shareholderId: string,
file: UploadFile,
args?: ResourceArguments,
): Promise<Onboarding> {
const innerArgs = args || {};
const path = `${this.buildPath(innerArgs)}/${id}/shareholders/${shareholderId}/document`;
return this._upload(path, file);
}
}
16 changes: 15 additions & 1 deletion src/lib/mixins/managerMixin.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { IManagerMixinConstructor, IResourceMixin } from '../../interfaces/mixins';
import { GenericFunction, ResourceArguments } from '../../types';
import { GenericFunction, ResourceArguments, UploadFile } from '../../types';
import { Client } from '../client';
import {
resourceAll,
resourceCreate,
resourceDelete,
resourceGet,
resourceUpdate,
resourceUpload,
} from '../resourceHandlers';
import { canRaiseHTTPError, getResourceClass } from '../utils';

Check warning on line 12 in src/lib/mixins/managerMixin.ts

View workflow job for this annotation

GitHub Actions / eslint

'canRaiseHTTPError' is defined but never used

export abstract class ManagerMixin<ResourceType extends IResourceMixin> {
static resource: string;
Expand Down Expand Up @@ -59,7 +60,7 @@
all(args?: ResourceArguments & { lazy: false }): Promise<ResourceType[]>;
all(args?: ResourceArguments): Promise<AsyncGenerator<ResourceType>>;
all(args?: ResourceArguments): Promise<ResourceType[] | AsyncGenerator<ResourceType>> {
console.warn("Warning: The 'all()' method is deprecated. Please use 'list()' instead.");

Check warning on line 63 in src/lib/mixins/managerMixin.ts

View workflow job for this annotation

GitHub Actions / eslint

Unexpected console statement
if (!this.#originatingClass.methods.includes('list')) {
throw new TypeError(`${this.#originatingClass.name}.all is not a valid function of of this manager`);
}
Expand Down Expand Up @@ -171,6 +172,19 @@
return this.postCreateHandler(object, innerArgs);
}

@canRaiseHTTPError
protected async _upload(path: string, file: UploadFile): Promise<ResourceType> {
const klass = await getResourceClass(this.#originatingClass.resource);
return resourceUpload<ResourceType>(
this._client,
path,
klass,
file,
this.#handlers,
this.#originatingClass.methods,
);
}

@canRaiseHTTPError
protected async _update(identifier: string, args?: ResourceArguments): Promise<ResourceType> {
const innerArgs = args || {};
Expand Down
39 changes: 38 additions & 1 deletion src/lib/resourceHandlers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { GenericFunction } from '../types';
import FormData from 'form-data';

import { GenericFunction, UploadFile } from '../types';

import { Client } from './client';
import { objetize, objetizeGenerator } from './utils';
Expand Down Expand Up @@ -113,6 +115,41 @@ export async function resourceCreate<ResourceType>(
);
}

/**
* Upload a file to a resource endpoint through a multipart/form-data request.
*
* @param client - The Client object passed to the retrieved object
* @param path - The path within the API server for the upload
* @param klass - Class that will wrap the instance of the resource
* @param file - The file descriptor to upload under the `file` form field
* @param handlers - The post-request handlers
* @param methods - An array of the methods that the object can execute
* @returns - An object of class `klass`
*/
export async function resourceUpload<ResourceType>(
client: Client,
path: string,
klass: any,
file: UploadFile,
handlers: Record<string, GenericFunction> = {},
methods: string[] = [],
): Promise<ResourceType> {
const form = new FormData();
form.append('file', file.data, {
filename: file.filename,
contentType: file.contentType,
});
const data = await client.request({ path, method: 'put', form });
return objetize(
klass,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

que sea con k es a propósito? jajaja

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seguí el patrón que estaba en las otras clases nomás jeje, según yo igual es común para evitar el clash con class que es un keyword

client,
data,
handlers,
methods,
path,
);
}

/**
* Update a specific instance of a resource.
*
Expand Down
3 changes: 3 additions & 0 deletions src/lib/resources/v2/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,6 @@ export * from './paymentMethod';
export * from './product';
export * from './subscription';
export * from './accountStatement';
export * from './onboarding';
export * from './onboardingShareholder';
export * from './onboardingDocument';
4 changes: 4 additions & 0 deletions src/lib/resources/v2/onboarding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { ResourceMixin } from '../../mixins/resourceMixin';

export class Onboarding extends ResourceMixin<Onboarding> {
}
4 changes: 4 additions & 0 deletions src/lib/resources/v2/onboardingDocument.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { ResourceMixin } from '../../mixins/resourceMixin';

export class OnboardingDocument extends ResourceMixin<OnboardingDocument> {
}
4 changes: 4 additions & 0 deletions src/lib/resources/v2/onboardingShareholder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { ResourceMixin } from '../../mixins/resourceMixin';

export class OnboardingShareholder extends ResourceMixin<OnboardingShareholder> {
}
120 changes: 120 additions & 0 deletions src/spec/integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1424,6 +1424,126 @@ test('fintoc.v2.products.create()', async (t) => {
t.is(product.json.currency, productData.currency);
});

test('fintoc.v2.entities.onboardings is wired', (t) => {
const ctx: any = t.context;
t.truthy(ctx.fintoc.v2.entities.onboardings);
t.is(typeof ctx.fintoc.v2.entities.onboardings.list, 'function');
t.is(typeof ctx.fintoc.v2.entities.onboardings.get, 'function');
t.is(typeof ctx.fintoc.v2.entities.onboardings.create, 'function');
t.is(typeof ctx.fintoc.v2.entities.onboardings.submit, 'function');
t.is(typeof ctx.fintoc.v2.entities.onboardings.uploadDocument, 'function');
t.is(typeof ctx.fintoc.v2.entities.onboardings.uploadShareholderDocument, 'function');
});

test('fintoc.v2.entities.onboardings.list()', async (t) => {
const ctx: any = t.context;
const entityId = 'ent_12345';
const onboardings = await ctx.fintoc.v2.entities.onboardings.list({
entity_id: entityId,
});

let count = 0;
for await (const onboarding of onboardings) {
count += 1;
t.is(onboarding.method, 'get');
t.is(onboarding.url, `v2/entities/${entityId}/onboardings`);
t.is(onboarding.params.entity_id, entityId);
}

t.true(count > 0);
});

test('fintoc.v2.entities.onboardings.get()', async (t) => {
const ctx: any = t.context;
const entityId = 'ent_12345';
const onboardingId = 'onb_12345';
const onboarding = await ctx.fintoc.v2.entities.onboardings.get(onboardingId, {
entity_id: entityId,
});

t.is(onboarding.method, 'get');
t.is(onboarding.url, `v2/entities/${entityId}/onboardings/${onboardingId}`);
t.is(onboarding.params.entity_id, entityId);
});

test('fintoc.v2.entities.onboardings.create()', async (t) => {
const ctx: any = t.context;
const entityId = 'ent_12345';
const onboardingData = {
entity_id: entityId,
company_information: { legal_name: 'ACME SpA' },
legal_representative: { first_name: 'Jane', last_name: 'Doe' },
transactional_profile: {
resource_origins: ['sales'],
monthly_amount_range: '0-1000',
monthly_operations_range: '0-10',
},
shareholders: [
{
type: 'natural_person', name: 'Jane', last_name: 'Doe', holder_id: 'h1', nationality: 'CL', percentage: 100,
},
],
};
const onboarding = await ctx.fintoc.v2.entities.onboardings.create(onboardingData);

t.is(onboarding.method, 'post');
t.is(onboarding.url, `v2/entities/${entityId}/onboardings`);
t.is(onboarding.json.company_information.legal_name, 'ACME SpA');
t.is(onboarding.json.legal_representative.first_name, 'Jane');
t.deepEqual(onboarding.json.transactional_profile.resource_origins, ['sales']);
});

test('fintoc.v2.entities.onboardings.submit()', async (t) => {
const ctx: any = t.context;
const entityId = 'ent_12345';
const onboardingId = 'onb_12345';
const onboarding = await ctx.fintoc.v2.entities.onboardings.submit(onboardingId, {
entity_id: entityId,
});

t.is(onboarding.method, 'post');
t.is(onboarding.url, `v2/entities/${entityId}/onboardings/${onboardingId}/submit`);
});

test('fintoc.v2.entities.onboardings.uploadDocument()', async (t) => {
const ctx: any = t.context;
const entityId = 'ent_12345';
const onboardingId = 'onb_12345';
const slotKey = 'tax_registration_certificate';
const file = { data: Buffer.from('fake pdf'), filename: 'cert.pdf', contentType: 'application/pdf' };
const onboarding = await ctx.fintoc.v2.entities.onboardings.uploadDocument(
onboardingId,
slotKey,
file,
{ entity_id: entityId },
);

t.is(onboarding.method, 'put');
t.is(onboarding.url, `v2/entities/${entityId}/onboardings/${onboardingId}/documents/${slotKey}`);
t.true(onboarding.multipart);
});

test('fintoc.v2.entities.onboardings.uploadShareholderDocument()', async (t) => {
const ctx: any = t.context;
const entityId = 'ent_12345';
const onboardingId = 'onb_12345';
const shareholderId = 'onbsh_abcd';
const file = { data: Buffer.from('fake pdf'), filename: 'id.pdf', contentType: 'application/pdf' };
const onboarding = await ctx.fintoc.v2.entities.onboardings.uploadShareholderDocument(
onboardingId,
shareholderId,
file,
{ entity_id: entityId },
);

t.is(onboarding.method, 'put');
t.is(
onboarding.url,
`v2/entities/${entityId}/onboardings/${onboardingId}/shareholders/${shareholderId}/document`,
);
t.true(onboarding.multipart);
});

test('fintoc.apiKeys.list()', async (t) => {
const ctx: any = t.context;
const apiKeys = await ctx.fintoc.apiKeys.list();
Expand Down
Loading
Loading