diff --git a/README.md b/README.md index ec6ffe2..7db3135 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,16 @@ ## 🌟 Features - A Pagar.me `PaymentMethodHandler` to createPayments +- A `PagarmePostbackController` controller to receive postback request from Pagar.me +- Refund credit card payments + +## Roadmap +- Handle refund postback +- Add unit and E2E test +- Cronjob for cancel order after some time +- A query route in Shop GQL to get transaction information in storefront +- CRUD for save bank information for refund +- CRUD for save credit card's ID ## ⚙️ Install ### 1. Install and configure Vendure @@ -17,13 +27,13 @@ npm install vendure-pagarme-plugin --save ``` -### 3. Add the handler in Vendure configuration +### 3. Add the plugin in Vendure configuration ```typescript -import { pagarmePaymentMethodHandler } from 'vendure-pagarme-plugin'; +import { PagarmePlugin } from 'vendure-pagarme-plugin'; const config: VendureConfig = { ... - paymentOptions: [ - pagarmePaymentMethodHandler + plugins: [ + PagarmePlugin ] } ``` @@ -35,7 +45,10 @@ To create a payment with this plugin you will need to fill with metadata in `cre import { PagarmePaymentMethodMetadata } from 'vendure-pagarme-plugin'; ``` -### 5. Enjoy! +### 5. Configure Pagar.me +You will need to enable and configure the options to make work. You can edit this in _Payment Method_ section in Vendure Admin UI + +### 6. Enjoy! It's done! ## 😍 Do you like? diff --git a/package.json b/package.json index f15b83c..49fd86d 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "lint:fix": "eslint --ext .ts,.tsx --fix" }, "peerDependencies": { - "@vendure/core": ">=0.13.0" + "@vendure/core": ">=0.13.1" }, "dependencies": { "pagarme": "4.12.0" @@ -30,7 +30,7 @@ "@commitlint/config-conventional": "9.1.1", "@typescript-eslint/eslint-plugin": "3.6.1", "@typescript-eslint/parser": "3.6.1", - "@vendure/core": "0.13.0", + "@vendure/core": "0.13.1", "eslint": "7.4.0", "eslint-config-prettier": "6.11.0", "eslint-plugin-prettier": "3.1.4", diff --git a/src/index.ts b/src/index.ts index e655805..bfe829b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,345 +1,2 @@ -import { PaymentMethodHandler, LanguageCode, Logger } from '@vendure/core'; -import pagarme, { - CreateTransacaoCreditCartInput, - CreateTransacaoBoletoInput, - CreateTransacaoInputBase -} from 'pagarme'; -import type { Unarray, Optional } from './types/utils'; -import { mapTransactionStatusToPaymentStatus } from './utils'; - -export type PagarmePaymentMethodMetadata = Omit< - Optional, - | 'postback_url' - | 'async' - | 'capture' - | 'boleto_expiration_date' - | 'soft_descriptor' - | 'boleto_instructions' - | 'boleto_fine' - | 'boleto_interest' - | 'split_rules' - | 'items' - | 'metadata' - | 'reference_key' - | 'local_time' -> & - (CreateTransacaoCreditCartInput | CreateTransacaoBoletoInput) & { - extraMetadata?: any; - itemsExtraInfo?: Partial< - Pick< - Unarray, - 'venue' | 'tangible' | 'category' - > - > & - { id: string }[]; - }; - -export const pagarmePaymentMethodHandler = new PaymentMethodHandler({ - code: 'pagarme', - args: { - apiKey: { - type: 'string', - label: [ - { languageCode: LanguageCode.en, value: 'API Key' }, - { languageCode: LanguageCode.pt_BR, value: 'Chave da API' } - ] - }, - soft_descriptor: { - type: 'string', - label: [ - { - languageCode: LanguageCode.en, - value: 'Soft Descriptor' - }, - { - languageCode: LanguageCode.pt_BR, - value: 'Descrição da fatura' - } - ], - description: [ - { - languageCode: LanguageCode.en, - value: '' - }, - { - languageCode: LanguageCode.pt_BR, - value: - 'Descrição que aparecerá na fatura depois do nome de sua empresa. Máximo de 13 caracteres, sendo alfanuméricos e espaços.' - } - ] - }, - boleto_instructions: { - type: 'string', - label: [ - { - languageCode: LanguageCode.en, - value: 'Boleto - Instructions' - }, - { - languageCode: LanguageCode.pt_BR, - value: 'Boleto - Instruções' - } - ], - description: [ - { - languageCode: LanguageCode.en, - value: '' - }, - { - languageCode: LanguageCode.pt_BR, - value: - 'Descrição que aparecerá na fatura depois do nome de sua empresa. Máximo de 13 caracteres, sendo alfanuméricos e espaços.' - } - ] - }, - boleto_expiration_date: { - type: 'string', - label: [ - { - languageCode: LanguageCode.en, - value: 'Boleto - Expiration Date' - }, - { - languageCode: LanguageCode.pt_BR, - value: 'Boleto - Prazo de Expiração' - } - ], - description: [ - { - languageCode: LanguageCode.pt_BR, - value: - 'Prazo limite para pagamento do boleto. Deve ser passado no formato yyyy-MM-dd' - } - ] - }, - boletoFineDays: { - type: 'string', - label: [ - { - languageCode: LanguageCode.en, - value: 'Boleto - Fine days' - }, - { - languageCode: LanguageCode.pt_BR, - value: 'Boleto - Dias até aplicação da multa' - } - ], - description: [ - { - languageCode: LanguageCode.en, - value: '' - }, - { - languageCode: LanguageCode.pt_BR, - value: - 'Dias após a expiração do boleto quando a multa deve ser cobrada.' - } - ] - }, - boletoFineAmount: { - type: 'string', - label: [ - { - languageCode: LanguageCode.en, - value: 'Boleto - Fine Amount' - }, - { - languageCode: LanguageCode.pt_BR, - value: 'Boleto - Valor da multa' - } - ], - description: [ - { - languageCode: LanguageCode.en, - value: '' - }, - { - languageCode: LanguageCode.pt_BR, - value: - 'Valor em centavos da multa. Valor máximo de 2% do valor do documento.' - } - ] - }, - boletoInterestDays: { - type: 'string', - label: [ - { - languageCode: LanguageCode.en, - value: 'Boleto - Interest Days' - }, - { - languageCode: LanguageCode.pt_BR, - value: 'Boleto - Dias até aplicação do juros' - } - ], - description: [ - { - languageCode: LanguageCode.en, - value: '' - }, - { - languageCode: LanguageCode.pt_BR, - value: - 'Dias após a expiração do boleto quando o juros deve ser cobrado.' - } - ] - }, - boletoInterestAmount: { - type: 'string', - label: [ - { - languageCode: LanguageCode.en, - value: 'Boleto - Interest Amount' - }, - { - languageCode: LanguageCode.pt_BR, - value: 'Boleto - Valor do Juros' - } - ], - description: [ - { - languageCode: LanguageCode.en, - value: '' - }, - { - languageCode: LanguageCode.pt_BR, - value: - 'Valor em porcentagem da taxa de juros que será cobrado por dia. Valor máximo de 1% ao mês.' - } - ] - }, - async: { - type: 'boolean', - label: [ - { - languageCode: LanguageCode.en, - value: 'Async' - }, - { - languageCode: LanguageCode.pt_BR, - value: 'Assíncrono' - } - ], - description: [ - { - languageCode: LanguageCode.en, - value: '' - }, - { - languageCode: LanguageCode.pt_BR, - value: - 'Utilize false caso queira manter o processamento síncrono de uma transação. Ou seja, a resposta da transação é recebida na hora.' - } - ] - }, - capture: { - type: 'boolean', - label: [ - { - languageCode: LanguageCode.en, - value: 'Capture' - }, - { - languageCode: LanguageCode.pt_BR, - value: 'Capturar' - } - ], - description: [ - { - languageCode: LanguageCode.en, - value: '' - }, - { - languageCode: LanguageCode.pt_BR, - value: - 'Após a autorização de uma transação, você pode escolher se irá capturar ou adiar a captura do valor. Caso opte por postergar a captura, atribua o valor false.' - } - ] - } - }, - description: [{ languageCode: LanguageCode.en, value: 'Pagar.me' }], - async createPayment( - order, - { - apiKey, - boletoFineDays, - boletoFineAmount, - boletoInterestDays, - boletoInterestAmount, - ...args - }, - { itemsExtraInfo, extraMetadata, ...metadata }: PagarmePaymentMethodMetadata - ) { - const amount = metadata.amount ? metadata.amount : order.total; - const pgClient = await pagarme.client.connect({ api_key: apiKey }); - - try { - const transaction = await pgClient.transactions.create({ - amount: amount, - ...metadata, - items: order.lines.map((line) => { - let extraInfo: any = {}; - - if (itemsExtraInfo) { - const itemExtraInfo = itemsExtraInfo.find((i) => i.id === line.id); - extraInfo = itemExtraInfo ? itemExtraInfo : {}; - } - - return { - tangible: true, - ...extraInfo, - id: String(line.id), - title: line.productVariant.name, - unit_price: line.unitPrice, - quantity: line.quantity - }; - }), - boleto_fine: { - days: boletoFineDays, - amount: boletoFineAmount - }, - boleto_interest: { - days: boletoInterestDays, - amount: boletoInterestAmount - }, - metadata: JSON.stringify({ - id: order.id, - ...extraMetadata - }), - ...args - }); - return { - amount: amount, - state: mapTransactionStatusToPaymentStatus(transaction.status), - transactionId: String(transaction.id), - errorMessage: transaction.acquirer_response_code - }; - } catch (e) { - return { - amount: amount, - state: 'Error' as const, - errorMessage: e.message - }; - } - }, - async settlePayment(_, payment, { apiKey }) { - const pgClient = await pagarme.client.connect({ api_key: apiKey }); - - try { - const transaction = await pgClient.transactions.capture({ - id: payment.transactionId, - amount: payment.amount - }); - const status = mapTransactionStatusToPaymentStatus(transaction.status); - return { - success: status === 'Settled', - errorMessage: transaction.status_reason - }; - } catch (e) { - return { - success: false, - errorMessage: e.message - }; - } - } -}); +export * from './payment-method-handler'; +export * from './index'; diff --git a/src/payment-method-handler.ts b/src/payment-method-handler.ts new file mode 100644 index 0000000..4251b28 --- /dev/null +++ b/src/payment-method-handler.ts @@ -0,0 +1,394 @@ +import { PaymentMethodHandler, LanguageCode, Logger } from '@vendure/core'; +import pagarme, { + CreateTransacaoCreditCartInput, + CreateTransacaoBoletoInput, + CreateTransacaoInputBase + // TransactionRefundDefaultArgs, + // TransactionRefundDynamicArgs +} from 'pagarme'; +import type { Unarray, Optional } from './types/utils'; +import { + mapTransactionStatusToPaymentStatus, + mapTransactionStatusToRefundStatus +} from './utils'; + +export type PagarmePaymentMethodMetadata = Omit< + Optional, + | 'postback_url' + | 'async' + | 'capture' + | 'boleto_expiration_date' + | 'soft_descriptor' + | 'boleto_instructions' + | 'boleto_fine' + | 'boleto_interest' + | 'split_rules' + | 'items' + | 'metadata' + | 'reference_key' + | 'local_time' +> & + (CreateTransacaoCreditCartInput | CreateTransacaoBoletoInput) & { + extraMetadata?: any; + itemsExtraInfo?: Partial< + Pick< + Unarray, + 'venue' | 'tangible' | 'category' + > + > & + { id: string }[]; + }; + +export const pagarmePaymentMethodHandler = new PaymentMethodHandler({ + code: 'pagarme', + args: { + apiKey: { + type: 'string', + label: [ + { languageCode: LanguageCode.en, value: 'API Key' }, + { languageCode: LanguageCode.pt_BR, value: 'Chave da API' } + ] + }, + soft_descriptor: { + type: 'string', + label: [ + { + languageCode: LanguageCode.en, + value: 'Soft Descriptor' + }, + { + languageCode: LanguageCode.pt_BR, + value: 'Descrição da fatura' + } + ], + description: [ + { + languageCode: LanguageCode.en, + value: '' + }, + { + languageCode: LanguageCode.pt_BR, + value: + 'Descrição que aparecerá na fatura depois do nome de sua empresa. Máximo de 13 caracteres, sendo alfanuméricos e espaços.' + } + ] + }, + boleto_instructions: { + type: 'string', + label: [ + { + languageCode: LanguageCode.en, + value: 'Boleto - Instructions' + }, + { + languageCode: LanguageCode.pt_BR, + value: 'Boleto - Instruções' + } + ], + description: [ + { + languageCode: LanguageCode.en, + value: '' + }, + { + languageCode: LanguageCode.pt_BR, + value: + 'Descrição que aparecerá na fatura depois do nome de sua empresa. Máximo de 13 caracteres, sendo alfanuméricos e espaços.' + } + ] + }, + boleto_expiration_date: { + type: 'string', + label: [ + { + languageCode: LanguageCode.en, + value: 'Boleto - Expiration Date' + }, + { + languageCode: LanguageCode.pt_BR, + value: 'Boleto - Prazo de Expiração' + } + ], + description: [ + { + languageCode: LanguageCode.pt_BR, + value: + 'Prazo limite para pagamento do boleto. Deve ser passado no formato yyyy-MM-dd' + } + ] + }, + boletoFineDays: { + type: 'string', + label: [ + { + languageCode: LanguageCode.en, + value: 'Boleto - Fine days' + }, + { + languageCode: LanguageCode.pt_BR, + value: 'Boleto - Dias até aplicação da multa' + } + ], + description: [ + { + languageCode: LanguageCode.en, + value: '' + }, + { + languageCode: LanguageCode.pt_BR, + value: + 'Dias após a expiração do boleto quando a multa deve ser cobrada.' + } + ] + }, + boletoFineAmount: { + type: 'string', + label: [ + { + languageCode: LanguageCode.en, + value: 'Boleto - Fine Amount' + }, + { + languageCode: LanguageCode.pt_BR, + value: 'Boleto - Valor da multa' + } + ], + description: [ + { + languageCode: LanguageCode.en, + value: '' + }, + { + languageCode: LanguageCode.pt_BR, + value: + 'Valor em centavos da multa. Valor máximo de 2% do valor do documento.' + } + ] + }, + boletoInterestDays: { + type: 'string', + label: [ + { + languageCode: LanguageCode.en, + value: 'Boleto - Interest Days' + }, + { + languageCode: LanguageCode.pt_BR, + value: 'Boleto - Dias até aplicação do juros' + } + ], + description: [ + { + languageCode: LanguageCode.en, + value: '' + }, + { + languageCode: LanguageCode.pt_BR, + value: + 'Dias após a expiração do boleto quando o juros deve ser cobrado.' + } + ] + }, + boletoInterestAmount: { + type: 'string', + label: [ + { + languageCode: LanguageCode.en, + value: 'Boleto - Interest Amount' + }, + { + languageCode: LanguageCode.pt_BR, + value: 'Boleto - Valor do Juros' + } + ], + description: [ + { + languageCode: LanguageCode.en, + value: '' + }, + { + languageCode: LanguageCode.pt_BR, + value: + 'Valor em porcentagem da taxa de juros que será cobrado por dia. Valor máximo de 1% ao mês.' + } + ] + }, + async: { + type: 'boolean', + label: [ + { + languageCode: LanguageCode.en, + value: 'Async' + }, + { + languageCode: LanguageCode.pt_BR, + value: 'Assíncrono' + } + ], + description: [ + { + languageCode: LanguageCode.en, + value: '' + }, + { + languageCode: LanguageCode.pt_BR, + value: + 'Utilize false caso queira manter o processamento síncrono de uma transação. Ou seja, a resposta da transação é recebida na hora.' + } + ] + }, + capture: { + type: 'boolean', + label: [ + { + languageCode: LanguageCode.en, + value: 'Capture' + }, + { + languageCode: LanguageCode.pt_BR, + value: 'Capturar' + } + ], + description: [ + { + languageCode: LanguageCode.en, + value: '' + }, + { + languageCode: LanguageCode.pt_BR, + value: + 'Após a autorização de uma transação, você pode escolher se irá capturar ou adiar a captura do valor. Caso opte por postergar a captura, atribua o valor false.' + } + ] + } + }, + description: [{ languageCode: LanguageCode.en, value: 'Pagar.me' }], + async createPayment( + order, + { + apiKey, + boletoFineDays, + boletoFineAmount, + boletoInterestDays, + boletoInterestAmount, + ...args + }, + { itemsExtraInfo, extraMetadata, ...metadata }: PagarmePaymentMethodMetadata + ) { + const amount = metadata.amount ? metadata.amount : order.total; + const pgClient = await pagarme.client.connect({ api_key: apiKey }); + + try { + const transaction = await pgClient.transactions.create({ + amount: amount, + ...metadata, + items: order.lines.map((line) => { + let extraInfo: any = {}; + + if (itemsExtraInfo) { + const itemExtraInfo = itemsExtraInfo.find((i) => i.id === line.id); + extraInfo = itemExtraInfo ? itemExtraInfo : {}; + } + + return { + tangible: true, + ...extraInfo, + id: String(line.id), + title: line.productVariant.name, + unit_price: line.unitPrice, + quantity: line.quantity + }; + }), + boleto_fine: { + days: boletoFineDays, + amount: boletoFineAmount + }, + boleto_interest: { + days: boletoInterestDays, + amount: boletoInterestAmount + }, + metadata: JSON.stringify({ + id: order.id, + ...extraMetadata + }), + ...args + }); + return { + amount: amount, + state: mapTransactionStatusToPaymentStatus(transaction.status), + transactionId: String(transaction.id), + errorMessage: transaction.acquirer_response_code + }; + } catch (e) { + return { + amount: amount, + state: 'Error' as const, + errorMessage: e.message + }; + } + }, + async settlePayment(_, payment, { apiKey }) { + const pgClient = await pagarme.client.connect({ api_key: apiKey }); + + try { + const transaction = await pgClient.transactions.capture({ + id: payment.transactionId, + amount: payment.amount + }); + const status = mapTransactionStatusToPaymentStatus(transaction.status); + return { + success: status === 'Settled', + errorMessage: transaction.status_reason + }; + } catch (e) { + return { + success: false, + errorMessage: e.message + }; + } + }, + async createRefund(input, total, order, payment, { apiKey, async }) { + const pgClient = await pagarme.client.connect({ api_key: apiKey }); + + try { + let transaction = await pgClient.transactions.find( + {}, + { id: Number(payment.transactionId) } + ); + // TODO: Get bank information from the user + // const args: Omit & + // TransactionRefundDynamicArgs = {}; + // if (transaction.payment_method === 'boleto') { + // // Precisa de todos inputs para criar refund + // args = { bank_account: {} }; + // } + + if (transaction.payment_method === 'boleto') { + throw new Error( + 'There is not a way to create refund of boleto transactions yet' + ); + } + + transaction = await pgClient.transactions.refund( + {}, + { + // ...args, + id: Number(payment.transactionId), + amount: total, + async: async + } + ); + + return { + state: mapTransactionStatusToRefundStatus(transaction.status) + }; + } catch (e) { + return { + state: 'Failed' as const, + metadata: { + errorMessage: e.message + } + }; + } + } +}); diff --git a/src/plugin.ts b/src/plugin.ts new file mode 100644 index 0000000..a9b5425 --- /dev/null +++ b/src/plugin.ts @@ -0,0 +1,15 @@ +import { PluginCommonModule, VendurePlugin } from '@vendure/core'; +import { pagarmePaymentMethodHandler } from './payment-method-handler'; +import { PagarmePostbackController } from './postback-controller'; + +@VendurePlugin({ + imports: [PluginCommonModule], + controllers: [PagarmePostbackController], + configuration: (config) => { + config.paymentOptions.paymentMethodHandlers.push( + pagarmePaymentMethodHandler + ); + return config; + } +}) +export class PagarmePlugin {} diff --git a/src/postback-controller.ts b/src/postback-controller.ts new file mode 100644 index 0000000..483fb9c --- /dev/null +++ b/src/postback-controller.ts @@ -0,0 +1,190 @@ +import { Controller, Body, Headers, Post } from '@nestjs/common'; +import { InjectConnection } from '@nestjs/typeorm'; +import { Connection } from 'typeorm'; +import { + PaymentMethod, + Payment, + ID, + Order, + PaymentState, + OrderService, + RequestContext, + LanguageCode, + ChannelService +} from '@vendure/core'; +import pagarme, { Postback } from 'pagarme'; +import { mapTransactionStatusToPaymentStatus } from './utils'; +import { PaymentStateMachine } from '@vendure/core/dist/service/helpers/payment-state-machine/payment-state-machine'; + +//TODO: CHECK Application/x-www-form-urlencoded +//TODO: Tratar postback de um refund +@Controller('pagarme-postback') +export class PagarmePostbackController { + private apiKeyName = 'apiKey'; + private pagarmePaymentMethodHandlerCode = 'pagarme'; + constructor( + @InjectConnection() private connection: Connection, + private channelService: ChannelService, + private paymentStateMachine: PaymentStateMachine, + private orderService: OrderService + ) {} + + /** + * The default function from the POST Postback. + * + * Extracts the body of the request containing the postback response and the signature from Pagar.me + */ + @Post() + async create( + @Body() postback: Postback | undefined, + @Headers('X-Hub-Signature') signature: string | undefined + ) { + if (signature && postback) { + if (postback.old_status === postback.current_status) { + return; + } + await this.verifySignature(signature, postback); + const payment = await this.getPaymentByTransactionId(postback.id); + await this.handleNewTransactionStatus(postback, payment); + } else { + throw new Error("Request doesn't have a signature or a postback"); + } + } + private async handleNewTransactionStatus( + postback: Postback, + payment: Payment + ): Promise { + const ctx = await this.createRequestContext(); + const status = mapTransactionStatusToPaymentStatus(postback.current_status); + if (status !== payment.state) { + switch (status) { + case 'Created': + return await this.handleChangeToCreated(); + case 'Authorized': + return await this.handleChangeToAuthorized(ctx, payment); + case 'Settled': + return await this.handleChangeToSettled(ctx, payment); + case 'Declined': + return await this.handleChangeToDeclined(ctx, payment); + default: + break; + } + return; + } + } + private async handleChangeToCreated(): Promise { + return; + } + private async handleChangeToAuthorized( + ctx: RequestContext, + payment: Payment + ): Promise { + const order = payment.order; + await this.paymentStateMachine.transition( + ctx, + order, + payment, + 'Authorized' + ); + if (this.orderTotalIsCovered(order, 'Authorized')) { + await this.orderService.transitionToState( + ctx, + order.id, + 'PaymentAuthorized' + ); + return; + } + } + private async handleChangeToSettled( + ctx: RequestContext, + payment: Payment + ): Promise { + const order = payment.order; + await this.paymentStateMachine.transition(ctx, order, payment, 'Settled'); + if (this.orderTotalIsCovered(order, 'Settled')) { + await this.orderService.transitionToState( + ctx, + order.id, + 'PaymentSettled' + ); + return; + } + } + private async handleChangeToDeclined( + ctx: RequestContext, + payment: Payment + ): Promise { + const order = payment.order; + await this.paymentStateMachine.transition(ctx, order, payment, 'Declined'); + // TODO: se for boleto cancela pedido e busca por todas transações com cartão para cancelar + } + private async verifySignature(signature: string, postback: Postback) { + const apiKey = await this.getApiKey(); + if ( + !pagarme.client.postback.verifySignature( + apiKey, + JSON.stringify(postback), + signature + ) + ) { + throw new Error("The request don't have a valid signature"); + } + } + private async createRequestContext(): Promise { + const channel = await this.channelService.getDefaultChannel(); + return new RequestContext({ + apiType: 'admin', + isAuthorized: true, + authorizedAsOwnerOnly: false, + channel, + languageCode: LanguageCode.en + }); + } + /** + * Get a Api Key for Pagar.me from the PaymentMethod in Vendure + */ + private async getApiKey(): Promise { + const paymentMethod = await this.connection + .getRepository(PaymentMethod) + .findOne({ + where: { + code: this.pagarmePaymentMethodHandlerCode + } + }); + if (!paymentMethod) { + throw new Error('Pagarme is not configured as payment provider'); + } + const apiKey = paymentMethod.configArgs.find( + (c) => c.name === this.apiKeyName + ); + + if (!apiKey || !apiKey.value) { + throw new Error("There isn't a API key configured for Pagar.me"); + } + + return apiKey.value; + } + private async getPaymentByTransactionId(transactionId: ID): Promise { + const payment = await this.connection.getRepository(Payment).findOne({ + where: { transactionId }, + relations: ['order', 'order.payments'] + }); + if (!payment) { + throw new Error( + "There isn't a payment related with the transaction ID from the postback" + ); + } + + return payment; + } + /** + * Returns true if the Order total is covered by Payments in the specified state. + */ + private orderTotalIsCovered(order: Order, state: PaymentState): boolean { + return ( + order.payments + .filter((p) => p.state === state) + .reduce((sum, p) => sum + p.amount, 0) === order.total + ); + } +} diff --git a/src/types/pagarme.d.ts b/src/types/pagarme.d.ts index 190ebdc..205ad0a 100644 --- a/src/types/pagarme.d.ts +++ b/src/types/pagarme.d.ts @@ -186,6 +186,24 @@ declare module 'pagarme' { function update(opts: any, body: any): any; } + namespace postback { + function calculateSignature( + /** the keys used to sign the hash. */ + key: string, + /** The string to be hashed. */ + string: string + ): string; + + function verifySignature( + /** the keys used to sign the hash. */ + key: string, + /** The string to be hashed. */ + string: string, + /** The expected result. */ + expected: string + ): boolean; + } + namespace postbacks { function find(opts: any, body: any): any; @@ -247,7 +265,10 @@ declare module 'pagarme' { } namespace transactions { - function all(opts: any, body: any): any; + function all( + opts: any, + body: TransactionFindAll + ): Promise; function calculateInstallmentsAmount(opts: any, body: any): any; @@ -259,9 +280,17 @@ declare module 'pagarme' { function create(opts: CreateTransacaoInput): Promise; - function find(opts: any, body: any): any; + function find( + opts: any, + body: T + ): Promise< + T extends TransactionFindAll ? TransacaoObject[] : TransacaoObject + >; - function refund(args: EstornoArgs): Promise; + function refund( + opts: any, + body: TransactionRefundArgs + ): Promise; function reprocess(opts: any, body: any): any; @@ -488,21 +517,23 @@ declare module 'pagarme' { | 'no_acquirer' | 'acquirer_timeout'; + type TransacaoStatus = + | 'processing' + | 'authorized' + | 'paid' + | 'refunded' + | 'waiting_payment' + | 'pending_refund' + | 'refused' + | 'chargedback' + | 'analyzing' + | 'pending_review'; + interface TransacaoObject { /** Nome do tipo do objeto criado/modificado. */ object: 'transaction'; /** Representa o estado da transação. A cada atualização no processamento da transação, esta propriedade é alterada e, caso você esteja usando uma postback_url, os seus servidores são notificados desses updates. */ - status: - | 'processing' - | 'authorized' - | 'paid' - | 'refunded' - | 'waiting_payment' - | 'pending_refund' - | 'refused' - | 'chargedback' - | 'analyzing' - | 'pending_review'; + status: TransacaoStatus; /** Motivo pelo qual a transação foi recusada. */ refuse_reason?: RefuseStatus; /** Agente responsável pela validação ou anulação da transação. */ @@ -586,6 +617,107 @@ declare module 'pagarme' { /** Valor único que identifica a transação para permitir uma nova tentativa de requisição com a segurança de que a mesma operação não será executada duas vezes acidentalmente. */ reference_key: string; } + // TODO: Atualizar tipagem transaction | subscription + export interface Postback { + /** ID da transação. */ + id: number; + /** A qual evento o postback se refere. */ + event: 'transaction_status_changed' | 'subscription_status_changed'; + /** Status anterior da transação. */ + old_status: TransacaoStatus; + /** Status ideal para objetos deste tipo, em um fluxo normal, onde autorização e captura são feitos com sucesso, por exemplo. */ + desired_status: TransacaoStatus; + /** Status para o qual efetivamente mudou. */ + current_status: TransacaoStatus; + /** Qual o tipo do objeto referido. */ + object: 'transaction' | 'subscription'; + /** Possui todas as informações do objeto. */ + transaction: TransacaoObject; + } + + interface TransactionRefundBoletoDataArgs { + /** Objeto bank_account que contém os dados da conta bancária para onde o estorno será feito. */ + bank_account: { + /** Dígitos que identificam cada banco. Confira a lista dos bancos aqui: http://www.febraban.org.br/associados/utilitarios/Bancos.asp */ + bank_code: string; + /** Número da agência bancária */ + agencia: string; + /** Dígito verificador da agência. Obrigatório caso o banco o utilize. Apenas números, deve conter somente 1 dígito */ + agencia_dv: string; + /** Número da conta */ + conta: string; + /** Dígito verificador da conta. */ + conta_dv: string; + /** Tipo da conta bancária. */ + type: + | 'conta_corrente' + | 'conta_poupanca' + | 'conta_corrente_conjunta' + | 'conta_poupanca_conjunta'; + /** CPF ou CNPJ do favorecido */ + document_number: string; + /** Nome/razão social do favorecido, Até 30 caracteres */ + legal_name: string; + }; + } + interface TransactionRefundBoletoWithIdArgs { + /** ID da conta bancária. */ + bank_account_id: string; + } + // eslint-disable-next-line @typescript-eslint/no-empty-interface + interface TransactionRefundCreditCardArgs {} + + export interface TransactionRefundDefaultArgs { + /** The transaction ID. */ + id: number; + /** Valor desejado para o estorno da transação. Deve ser passado em centavos. Ex: R$ 10.00 = 1000. */ + amount?: number; + /** Define se a operação deve ser feita de maneira assíncrona ou não. Caso true(default), a reposta de sua request será enviada via post para sua postback_url cadastrada na respectiva transação. Caso false, no response será enviado o status final de refunded. */ + async?: boolean; + /** Você pode passar dados adicionais no estorno da transação para facilitar uma futura análise de dados por seus sistemas. */ + metadata?: string; + } + export type TransactionRefundDynamicArgs = + | TransactionRefundCreditCardArgs + | (TransactionRefundBoletoWithIdArgs | TransactionRefundBoletoDataArgs); + export type TransactionRefundArgs = TransactionRefundDefaultArgs & + TransactionRefundDynamicArgs; + + interface TransactionFindAll { + /** Retorna n objetos de transação, com um máximo de 1000 */ + count?: number; + /** Útil para implementação de uma paginação de resultados */ + page?: number; + status?: TransacaoStatus; + /** utiliza unixTimeStamp */ + date_created?: string; + /** utiliza unixTimeStamp */ + date_updated?: string; + amount?: string; + installments?: string; + tid?: string; + nsu?: string; + card_holder_name?: string; + card_last_digits?: string; + card_brand?: string; + postback_url?: string; + payment_method?: string; + capture_method?: string; + boleto_url?: string; + antifraud_score?: string; + subscription_id?: string; + customer?: Partial; + address?: Partial
; + phone?: PhoneNumber; + reference_key?: string; + order_id?: string; + metadata?: JSON; + } + interface TransactionFindById { + /** The transaction ID. If not sent a transaction list will be returned instead. */ + id: number; + } + type TransactionFindArgs = TransactionFindById | TransactionFindAll; /** * ------------------------------------------- @@ -821,10 +953,6 @@ declare module 'pagarme' { start_date?: number; end_date?: number; } - - interface EstornoArgs { - id: number; - } enum Country { Af = 'AF', Al = 'AL', diff --git a/src/utils.ts b/src/utils.ts index 3ac2779..fb48967 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,5 @@ import type { PaymentState } from '@vendure/core'; +import { RefundState } from '@vendure/core/dist/service/helpers/refund-state-machine/refund-state'; import type { TransacaoObject } from 'pagarme'; export function mapTransactionStatusToPaymentStatus( @@ -29,3 +30,20 @@ export function mapTransactionStatusToPaymentStatus( return 'Created'; } } + +export function mapTransactionStatusToRefundStatus( + status: TransacaoObject['status'] +): RefundState { + switch (status) { + case 'paid': + return 'Settled'; + case 'refunded': + return 'Settled'; + case 'pending_refund': + return 'Pending'; + case 'refused': + return 'Failed'; + default: + return 'Pending'; + } +} diff --git a/yarn.lock b/yarn.lock index 2ed2a0e..be2fb6f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -994,15 +994,15 @@ dependencies: eslint-visitor-keys "^1.1.0" -"@vendure/common@^0.13.0": +"@vendure/common@^0.13.1": version "0.13.1" resolved "https://registry.yarnpkg.com/@vendure/common/-/common-0.13.1.tgz#a27cbcc59e3235842174842a36ec308be600c5e3" integrity sha512-q6mvdb+mh9TE0aGnDZoBybRmdcQgD1uRz94LUfowoiViI0IvF2ZJ/nTlYKjfDW4ykflZeQbl1Jg01LNGLx/4Xw== -"@vendure/core@0.13.0": - version "0.13.0" - resolved "https://registry.yarnpkg.com/@vendure/core/-/core-0.13.0.tgz#5ef5c80dbc79805828d3b3bc235ac5430bbd1bc2" - integrity sha512-/Dqs0Y9zDQFTKHA7f1u4gEuvpLhy/feldLvpt78YHQe0lQdeB7xANoVGffcSb1QU6eZznhm9nMOvfsLhEFN0qg== +"@vendure/core@0.13.1": + version "0.13.1" + resolved "https://registry.yarnpkg.com/@vendure/core/-/core-0.13.1.tgz#659ebc191c928c461714a9f05d460352539e3e39" + integrity sha512-ajPLFaLDC4F3uhPu0n1AGLyieOWXljrTxC6ArV8DtqNA6los4V5MmC3pQdIbEI8WdG6bjl/Jko/CXLIQXKYjNg== dependencies: "@nestjs/common" "7.0.5" "@nestjs/core" "7.0.5" @@ -1013,7 +1013,7 @@ "@nestjs/testing" "7.0.5" "@nestjs/typeorm" "7.0.0" "@types/fs-extra" "^8.0.1" - "@vendure/common" "^0.13.0" + "@vendure/common" "^0.13.1" apollo-server-express "2.11.0" bcrypt "^4.0.1" body-parser "^1.19.0"