Skip to content

Commit 05a3b86

Browse files
committed
feat(vindi): Setup Vindi payment integration app
1 parent 3f773cf commit 05a3b86

18 files changed

Lines changed: 760 additions & 12 deletions

packages/apps/vindi/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Please refer to GitHub [repository releases](https://github.com/ecomplus/cloud-commerce/releases) or monorepo unified [CHANGELOG.md](https://github.com/ecomplus/cloud-commerce/blob/main/CHANGELOG.md).

packages/apps/vindi/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# `@cloudcommerce/app-vindi`

packages/apps/vindi/events.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './lib/vindi-events.js';
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import axios from 'axios';
2+
3+
export const addInstallments = (
4+
amount,
5+
installments,
6+
gateway = {},
7+
response = null,
8+
) => {
9+
let maxInterestFree = !(installments.interest_free_min_amount > amount.total)
10+
? installments.max_interest_free : 0;
11+
const maxInstallments = installments.max_number && maxInterestFree
12+
? Math.max(installments.max_number, maxInterestFree)
13+
: installments.max_number || maxInterestFree;
14+
if (maxInstallments > 1) {
15+
// default installments option
16+
if (!installments.monthly_interest) {
17+
maxInterestFree = maxInstallments;
18+
}
19+
const minInstallment = installments.min_installment || 5;
20+
if (response) {
21+
response.installments_option = {
22+
min_installment: minInstallment,
23+
max_number: maxInterestFree || installments.max_number,
24+
monthly_interest: maxInterestFree ? 0 : installments.monthly_interest,
25+
};
26+
}
27+
// list installment options
28+
gateway.installment_options = [];
29+
for (let number = 2; number <= maxInstallments; number++) {
30+
const tax = !(maxInterestFree >= number);
31+
let interest;
32+
if (tax) {
33+
interest = installments.monthly_interest / 100;
34+
}
35+
const value = !tax ? amount.total / number
36+
// https://pt.wikipedia.org/wiki/Tabela_Price
37+
: amount.total * (interest / (1 - (1 + interest) ** -number));
38+
if (value >= minInstallment) {
39+
gateway.installment_options.push({
40+
number,
41+
value,
42+
tax,
43+
});
44+
}
45+
}
46+
}
47+
return { response, gateway };
48+
};
49+
50+
export const parseVindiStatus = (vindiChargeStatus) => {
51+
switch (vindiChargeStatus) {
52+
case 'pending':
53+
case 'paid':
54+
return vindiChargeStatus;
55+
case 'canceled':
56+
return 'voided';
57+
case 'processing':
58+
return 'under_analysis';
59+
case 'fraud_review':
60+
return 'authorized';
61+
default:
62+
}
63+
return 'unknown';
64+
};
65+
66+
export const createVindiAxios = (vindiApiKey, isSandbox) => {
67+
// https://vindi.github.io/api-docs/dist/
68+
return axios.create({
69+
baseURL: `https://${(isSandbox ? 'sandbox-' : '')}app.vindi.com.br/api/v1/`,
70+
headers: {
71+
Authorization: 'Basic ' + Buffer.from(`${vindiApiKey}:`).toString('base64'),
72+
},
73+
});
74+
};
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
import $config, { logger } from '@cloudcommerce/firebase/lib/config';
2+
import updateAppData from '@cloudcommerce/firebase/lib/helpers/update-app-data';
3+
import {
4+
addInstallments,
5+
createVindiAxios,
6+
parseVindiStatus,
7+
} from './util/vindi-utils.mjs';
8+
9+
export default async (modBody) => {
10+
const { application, params } = modBody;
11+
const appData = {
12+
...application.data,
13+
...application.hidden_data,
14+
};
15+
if (appData.vindi_api_key) {
16+
process.env.VINDI_API_KEY = appData.vindi_api_key;
17+
}
18+
const { VINDI_API_KEY } = process.env;
19+
if (!VINDI_API_KEY) {
20+
return {
21+
error: 'NO_VINDI_KEYS',
22+
message: 'Chave de API e/ou criptografia não configurada (lojista deve configurar o aplicativo)',
23+
};
24+
}
25+
const vindiAxios = createVindiAxios(VINDI_API_KEY, appData.vindi_sandbox);
26+
const { storeId } = $config.get();
27+
28+
// https://apx-mods.e-com.plus/api/v1/create_transaction/schema.json?store_id=100
29+
const orderId = params.order_id;
30+
const {
31+
amount, buyer, to, items,
32+
} = params;
33+
// https://apx-mods.e-com.plus/api/v1/create_transaction/response_schema.json?store_id=100
34+
const transaction = {
35+
amount: amount.total,
36+
};
37+
38+
// must always create Vindi customer before
39+
const vindiMetadata = {
40+
order_number: params.order_number,
41+
store_id: storeId,
42+
order_id: orderId,
43+
order_type: params.type,
44+
};
45+
const vindiCustomer = {
46+
name: buyer.fullname,
47+
email: buyer.email,
48+
registry_code: buyer.doc_number,
49+
code: `${buyer.customer_id}:${Date.now()}`,
50+
phones: [{
51+
phone_type: !buyer.phone.type || buyer.phone.type === 'personal' ? 'mobile' : 'landline',
52+
number: `${(buyer.phone.country_code || '55')}${buyer.phone.number}`,
53+
}],
54+
notes: `E-Com Plus => Pedido #${params.order_number} em ${params.domain}`,
55+
metadata: vindiMetadata,
56+
};
57+
const parseAddress = (_to) => ({
58+
street: _to.street,
59+
number: String(_to.number) || 'S/N',
60+
additional_details: _to.complement,
61+
zipcode: _to.zip,
62+
neighborhood: _to.borough,
63+
city: _to.city,
64+
state: _to.province_code || _to.province,
65+
country: _to.country_code || 'BR',
66+
});
67+
if (to && to.street) {
68+
vindiCustomer.address = parseAddress(to);
69+
} else if (params.billing_address) {
70+
vindiCustomer.address = parseAddress(params.billing_address);
71+
}
72+
73+
// strart mounting Vindi bill object
74+
const vindiBill = {
75+
code: String(params.order_number),
76+
metadata: vindiMetadata,
77+
};
78+
79+
let finalAmount = Math.floor(amount.total * 100) / 100;
80+
if (params.payment_method.code === 'credit_card') {
81+
let installmentsNumber = params.installments_number;
82+
if (installmentsNumber > 1) {
83+
if (appData.installments) {
84+
// list all installment options
85+
const { gateway } = addInstallments(amount, appData.installments);
86+
const installmentOption = gateway.installment_options
87+
&& gateway.installment_options.find(({ number }) => number === installmentsNumber);
88+
if (installmentOption) {
89+
transaction.installments = installmentOption;
90+
transaction.installments.total = Math.round(
91+
installmentOption.number * installmentOption.value * 100,
92+
) / 100;
93+
finalAmount = transaction.installments.total;
94+
} else {
95+
installmentsNumber = 1;
96+
}
97+
}
98+
}
99+
vindiBill.payment_method_code = 'credit_card';
100+
vindiBill.installments = installmentsNumber;
101+
} else {
102+
// banking billet
103+
vindiBill.payment_method_code = appData.banking_billet?.is_yapay
104+
? 'bank_slip_yapay' : 'bank_slip';
105+
}
106+
107+
try {
108+
const { data: vindiCustomerRes } = await vindiAxios({
109+
url: '/customers',
110+
method: 'post',
111+
timeout: 12000,
112+
data: vindiCustomer,
113+
});
114+
vindiBill.customer_id = vindiCustomerRes.customer?.id || vindiCustomerRes.id;
115+
116+
let vindiProductId = appData.vindi_product_id;
117+
if (!vindiProductId) {
118+
// must explicitly create a product before bill
119+
const { data: vindiProductRes } = await vindiAxios({
120+
url: '/products',
121+
method: 'post',
122+
timeout: 12000,
123+
data: {
124+
name: `Pedido na loja ${params.domain}`,
125+
code: `ecomplus-${Date.now()}`,
126+
status: 'active',
127+
description: 'Produto pré-definido para pedidos através da plataforma E-Com Plus',
128+
pricing_schema: {
129+
price: 100,
130+
schema_type: 'flat',
131+
},
132+
},
133+
});
134+
vindiProductId = vindiProductRes.product?.id || vindiProductRes.id;
135+
await updateAppData(application, {
136+
vindi_product_id: vindiProductId,
137+
}, {
138+
isHiddenData: true,
139+
canSendPubSub: false,
140+
});
141+
}
142+
143+
if (params.credit_card && params.credit_card.hash) {
144+
vindiBill.payment_profile = {
145+
payment_method_code: vindiBill.payment_method_code,
146+
allow_as_fallback: true,
147+
gateway_token: params.credit_card.hash,
148+
};
149+
if (params.payer && params.payer.doc_number) {
150+
vindiBill.payment_profile.registry_code = params.payer.doc_number;
151+
}
152+
}
153+
154+
if (params.type === 'recurrence') {
155+
// async handle plan subscription
156+
// must register all products and a plan on Vindi
157+
// check products metadata to prevent duplication
158+
return {
159+
data: {
160+
id: 0,
161+
charges: [{
162+
id: 0,
163+
status: 'pending',
164+
}],
165+
},
166+
};
167+
}
168+
169+
/*
170+
"Apesar do bill_item suportar um esquema de precificação (pricing_schema)
171+
com quantidade (quantity), recomendamos utilizar apenas o parâmetro
172+
amount para evitar complexidade desnecessária no desenvolvimento.
173+
Se pricing_schema, quantity e amount forem informados ao mesmo tempo,
174+
garanta que todos sejam mutuamente válidos"
175+
*/
176+
// create a product for current order
177+
let description = '';
178+
if (items.length === 1) {
179+
description = `${items[0].quantity}x ${items[0].sku} - ${items[0].name}`;
180+
} else {
181+
items.forEach(({ quantity, sku }) => {
182+
description += `${quantity}x ${sku}; `;
183+
});
184+
}
185+
if (description.length > 255) {
186+
description = description.substring(0, 250) + ' ...';
187+
}
188+
vindiBill.bill_items = [{
189+
product_id: vindiProductId,
190+
amount: finalAmount,
191+
description,
192+
}];
193+
// create Vindi single bill
194+
const { data: vindiBillRes } = await vindiAxios({
195+
url: '/bills',
196+
method: 'post',
197+
data: vindiBill,
198+
});
199+
200+
const createdBill = vindiBillRes.bill || vindiBillRes;
201+
const vindiCharge = createdBill.charges[0];
202+
if (vindiCharge.amount) {
203+
transaction.amount = Number(vindiCharge.amount);
204+
}
205+
transaction.intermediator = {
206+
buyer_id: String(vindiBill.customer_id),
207+
transaction_code: String(vindiCharge.id),
208+
transaction_reference: String(createdBill.id),
209+
};
210+
211+
if (vindiCharge.payment_method) {
212+
transaction.intermediator.payment_method = {
213+
code: vindiCharge.payment_method.code || params.payment_method.code,
214+
};
215+
if (vindiCharge.payment_method.name) {
216+
transaction.intermediator.payment_method.name = vindiCharge.payment_method.name;
217+
}
218+
}
219+
if (vindiCharge.print_url) {
220+
transaction.payment_link = vindiCharge.print_url;
221+
if (params.payment_method.code === 'banking_billet') {
222+
transaction.banking_billet = {
223+
link: vindiCharge.print_url,
224+
};
225+
}
226+
}
227+
228+
const vindiTransaction = vindiCharge.last_transaction;
229+
if (vindiTransaction) {
230+
transaction.intermediator.transaction_id = String(vindiTransaction.id);
231+
if (vindiTransaction.payment_profile && vindiTransaction.payment_profile.token) {
232+
transaction.credit_card = {
233+
token: vindiTransaction.payment_profile.token,
234+
};
235+
if (vindiTransaction.payment_profile.card_number_last_four) {
236+
transaction.credit_card.last_digits = vindiTransaction
237+
.payment_profile.card_number_last_four;
238+
}
239+
if (vindiTransaction.payment_profile.payment_company) {
240+
transaction.credit_card.company = vindiTransaction
241+
.payment_profile.payment_company.name;
242+
}
243+
}
244+
}
245+
246+
transaction.status = {
247+
updated_at: vindiBillRes.updated_at || vindiBillRes.created_at || new Date().toISOString(),
248+
current: parseVindiStatus(vindiCharge.status),
249+
};
250+
return { transaction };
251+
} catch (error) {
252+
// try to debug request error
253+
const errCode = 'VINDI_BILL_ERR';
254+
let { message } = error;
255+
const err = new Error(`${errCode} ${orderId} => ${message}`);
256+
if (error.response) {
257+
const { status, data } = error.response;
258+
if (status !== 401 && status !== 403) {
259+
if (error.config) {
260+
err.url = error.config.url;
261+
err.data = error.config.data;
262+
} else {
263+
err.bill = JSON.stringify(vindiBill);
264+
}
265+
err.customer = JSON.stringify(vindiCustomer);
266+
err.status = status;
267+
if (typeof data === 'object' && data) {
268+
err.response = JSON.stringify(data);
269+
} else {
270+
err.response = data;
271+
}
272+
} else if (data?.errors?.[0]?.message) {
273+
message = data.errors[0].message;
274+
}
275+
}
276+
logger.error(err);
277+
return {
278+
error: errCode,
279+
message,
280+
};
281+
}
282+
};

0 commit comments

Comments
 (0)