Handle paymongo webhooks #761

This commit is contained in:
Ramon Gutierrez 2023-09-20 06:08:00 +08:00
parent c5a8bda95a
commit ee021da453
14 changed files with 515 additions and 112 deletions

View file

@ -53,6 +53,10 @@ security:
pattern: ^\/test_capi\/ pattern: ^\/test_capi\/
security: false security: false
paymongo:
pattern: ^\/paymongo\/
security: false
cust_api_v2: cust_api_v2:
pattern: ^\/apiv2\/(?!register|register\/|number_confirm|number_confirm\/|code_validate|code_validate\/|resend_code|resend_code\/|version_check|version_check\/|account|account\/|account_code_validate|account_code_validate\/|account_resend_code|account_resend_code\/) pattern: ^\/apiv2\/(?!register|register\/|number_confirm|number_confirm\/|code_validate|code_validate\/|resend_code|resend_code\/|version_check|version_check\/|account|account\/|account_code_validate|account_code_validate\/|account_resend_code|account_resend_code\/)
provider: api_v2_provider provider: api_v2_provider

View file

@ -4,13 +4,3 @@ insurance_listener:
path: /insurance/listen path: /insurance/listen
controller: App\Controller\InsuranceController::listen controller: App\Controller\InsuranceController::listen
methods: [POST] methods: [POST]
insurance_payment_success:
path: /insurance/payment/success
controller: App\Controller\InsuranceController::paymentSuccess
methods: [GET]
insurance_payment_cancel:
path: /insurance/payment/cancel
controller: App\Controller\InsuranceController::paymentCancel
methods: [GET]

View file

@ -4,3 +4,13 @@ paymongo_listener:
path: /paymongo/listen path: /paymongo/listen
controller: App\Controller\PayMongoController::listen controller: App\Controller\PayMongoController::listen
methods: [POST] methods: [POST]
paymongo_payment_success:
path: /paymongo/success
controller: App\Controller\PayMongoController::paymentSuccess
methods: [GET]
paymongo_payment_cancelled:
path: /paymongo/cancelled
controller: App\Controller\PayMongoController::paymentCancelled
methods: [GET]

View file

@ -216,6 +216,16 @@ services:
$username: "%env(INSURANCE_USERNAME)%" $username: "%env(INSURANCE_USERNAME)%"
$password: "%env(INSURANCE_PASSWORD)%" $password: "%env(INSURANCE_PASSWORD)%"
# entity listener for gateway transactions
App\EntityListener\GatewayTransactionListener:
arguments:
$em: "@doctrine.orm.entity_manager"
$ic: "@App\\Service\\InsuranceConnector"
tags:
- name: doctrine.orm.entity_listener
event: 'postUpdate'
entity: 'App\Entity\GatewayTransaction'
# paymongo connector # paymongo connector
App\Service\PayMongoConnector: App\Service\PayMongoConnector:
arguments: arguments:

View file

@ -11,12 +11,13 @@ use App\Service\InsuranceConnector;
use App\Service\PayMongoConnector; use App\Service\PayMongoConnector;
use App\Entity\InsuranceApplication; use App\Entity\InsuranceApplication;
use App\Entity\GatewayTransaction;
use App\Entity\CustomerVehicle; use App\Entity\CustomerVehicle;
use App\Ramcar\InsuranceApplicationStatus; use App\Ramcar\InsuranceApplicationStatus;
use App\Ramcar\InsuranceMVType; use App\Ramcar\InsuranceMVType;
use App\Ramcar\InsuranceClientType; use App\Ramcar\InsuranceClientType;
use App\Ramcar\TransactionStatus;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use DateTime; use DateTime;
@ -131,51 +132,65 @@ class InsuranceController extends ApiController
$req->files->get('orcr_file') $req->files->get('orcr_file')
); );
if (!$result['success']) { if (!$result['success']) {
return new APIResponse(false, $result['error']['message']); return new ApiResponse(false, $result['error']['message']);
} }
$premium_amount_int = (int)bcmul($result['response']['premium'], 100);
// build checkout item and metadata // build checkout item and metadata
$items = [ $items = [
[ [
'name' => "Insurance Premium", 'name' => "Insurance Premium",
'description' => "Premium fee for vehicle insurance", 'description' => "Premium fee for vehicle insurance",
'quantity' => 1, 'quantity' => 1,
'amount' => (int)bcmul($result['response']['premium'], 100), 'amount' => $premium_amount_int,
'currency' => 'PHP', 'currency' => 'PHP',
], ],
]; ];
$metadata = [ $now = new DateTime();
'customer_id' => $cust->getID(),
'customer_vehicle_id' => $cv->getID(), // create gateway transaction
]; $gt = new GatewayTransaction();
$gt->setCustomer($cust);
$gt->setDateCreate($now);
$gt->setAmount($premium_amount_int);
$gt->setStatus(TransactionStatus::PENDING);
$gt->setGateway('paymongo'); // TODO: define values elsewhere
$gt->setType('insurance_premium'); // TODO: define values elsewhere
$gt->setExtTransactionId($result['response']['id']);
$this->em->persist($gt);
$this->em->flush();
// create paymongo checkout resource // create paymongo checkout resource
$checkout = $paymongo->createCheckout( $checkout = $paymongo->createCheckout(
$cust, $cust,
$items, $items,
implode("-", [$cust->getID(), $result['response']['id']]), $gt->getID(),
"Motolite RES-Q Vehicle Insurance", "Motolite RES-Q Vehicle Insurance",
$router->generate('insurance_payment_success', [], UrlGeneratorInterface::ABSOLUTE_URL), $router->generate('paymongo_payment_success', [], UrlGeneratorInterface::ABSOLUTE_URL),
$router->generate('insurance_payment_cancel', [], UrlGeneratorInterface::ABSOLUTE_URL), $router->generate('paymongo_payment_cancelled', [], UrlGeneratorInterface::ABSOLUTE_URL),
$metadata, ['transaction_id' => $gt->getID()], // NOTE: passing this here too for payment resource metadata
); );
if (!$checkout['success']) { if (!$checkout['success']) {
return new APIResponse(false, $checkout['error']['message']); return new ApiResponse(false, $checkout['error']['message']);
} }
$checkout_url = $checkout['response']['data']['attributes']['checkout_url']; $checkout_url = $checkout['response']['data']['attributes']['checkout_url'];
// add checkout url and id to transaction metadata
$gt->setMetadata([
'checkout_url' => $checkout_url,
'checkout_id' => $checkout['response']['data']['id'],
]);
// store application in db // store application in db
$app = new InsuranceApplication(); $app = new InsuranceApplication();
$app->setDateSubmitted(new DateTime()); $app->setDateSubmit($now);
$app->setCustomer($cust); $app->setCustomer($cust);
$app->setCustomerVehicle($cv); $app->setCustomerVehicle($cv);
$app->setTransactionID($result['response']['id']); $app->setGatewayTransaction($gt);
$app->setPremiumAmount($result['response']['premium']);
$app->setStatus(InsuranceApplicationStatus::CREATED); $app->setStatus(InsuranceApplicationStatus::CREATED);
$app->setCheckoutURL($checkout_url);
$app->setCheckoutID($checkout['response']['data']['id']);
$app->setMetadata($input); $app->setMetadata($input);
$this->em->persist($app); $this->em->persist($app);
$this->em->flush(); $this->em->flush();
@ -200,7 +215,7 @@ class InsuranceController extends ApiController
// get maker list // get maker list
$result = $this->client->getVehicleMakers(); $result = $this->client->getVehicleMakers();
if (!$result['success']) { if (!$result['success']) {
return new APIResponse(false, $result['error']['message']); return new ApiResponse(false, $result['error']['message']);
} }
return new ApiResponse(true, '', [ return new ApiResponse(true, '', [
@ -220,7 +235,7 @@ class InsuranceController extends ApiController
// get maker list // get maker list
$result = $this->client->getVehicleModels($maker_id); $result = $this->client->getVehicleModels($maker_id);
if (!$result['success']) { if (!$result['success']) {
return new APIResponse(false, $result['error']['message']); return new ApiResponse(false, $result['error']['message']);
} }
return new ApiResponse(true, '', [ return new ApiResponse(true, '', [
@ -240,7 +255,7 @@ class InsuranceController extends ApiController
// get maker list // get maker list
$result = $this->client->getVehicleTrims($model_id); $result = $this->client->getVehicleTrims($model_id);
if (!$result['success']) { if (!$result['success']) {
return new APIResponse(false, $result['error']['message']); return new ApiResponse(false, $result['error']['message']);
} }
return new ApiResponse(true, '', [ return new ApiResponse(true, '', [

View file

@ -2,6 +2,8 @@
namespace App\Controller; namespace App\Controller;
use App\Entity\GatewayTransaction;
use App\Ramcar\TransactionStatus;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@ -10,14 +12,89 @@ use Symfony\Bundle\FrameworkBundle\Controller\Controller;
class PayMongoController extends Controller class PayMongoController extends Controller
{ {
public function listen(Request $req, EntityManagerInterface $em) protected $em;
public function __construct(EntityManagerInterface $em)
{ {
$payload = $req->request->all(); $this->em = $em;
error_log(print_r($payload, true)); }
public function listen(Request $req)
{
$payload = json_decode($req->getContent(), true);
// DEBUG
@file_put_contents(__DIR__ . '/../../var/log/paymongo.log', print_r($payload, true) . "\r\n----------------------------------------\r\n\r\n", FILE_APPEND);
/*
return $this->json([
'success' => true,
]);
*/
// END DEBUG
// get event type and process accordingly
$attr = $payload['data']['attributes'];
$event = $attr['data'];
$event_name = $attr['type'];
switch ($event_name) {
case "payment.paid":
return $this->handlePaymentPaid($event);
break;
case "payment.failed":
return $this->handlePaymentPaid($event);
break;
case "payment.refunded": // TODO: handle refunds
case "payment.refund.updated":
case "checkout_session.payment.paid":
default:
break;
}
return $this->json([ return $this->json([
'success' => true, 'success' => true,
'payload' => $payload,
]); ]);
} }
protected function handlePaymentPaid($event)
{
$metadata = $event['attributes']['metadata'];
$obj = $this->getTransaction($metadata['transaction_id']);
// mark as paid
$obj->setStatus(TransactionStatus::PAID);
$this->em->flush();
return $this->json([
'success' => true,
]);
}
protected function handlePaymentFailed(Request $req)
{
// TODO: do something about failed payments?
return $this->json([
'success' => true,
]);
}
protected function getTransaction($id)
{
//$class_name = 'App\\Entity\\' . $type;
//$instance = new $class_name;
return $this->em->getRepository(GatewayTransaction::class)->find($id);
}
public function paymentSuccess(Request $req)
{
return $this->render('paymongo/success.html.twig');
}
public function paymentCancelled(Request $req)
{
return $this->render('paymongo/cancelled.html.twig');
}
} }

View file

@ -0,0 +1,204 @@
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use App\Ramcar\TransactionStatus;
use Symfony\Component\Validator\Constraints as Assert;
use DateTime;
/**
* @ORM\Entity
* @ORM\Table(name="gateway_transaction")
*/
class GatewayTransaction
{
// unique id
/**
* @ORM\Id
* @ORM\Column(type="integer")
* @ORM\GeneratedValue(strategy="AUTO")
*/
protected $id;
/**
* @ORM\ManyToOne(targetEntity="Customer", inversedBy="transactions")
* @ORM\JoinColumn(name="customer_id", referencedColumnName="id")
*/
protected $customer;
// date ticket was created
/**
* @ORM\Column(type="datetime")
*/
protected $date_create;
// date ticket was paid
/**
* @ORM\Column(type="datetime", nullable=true)
*/
protected $date_pay;
// amount
/**
* @ORM\Column(type="bigint")
* @Assert\NotBlank()
*/
protected $amount;
// status of the transaction
/**
* @ORM\Column(type="string", length=50)
* @Assert\NotBlank()
*/
protected $status;
// type of transaction
/**
* @ORM\Column(type="string", length=50)
* @Assert\NotBlank()
*/
protected $type;
// gateway used for transaction
/**
* @ORM\Column(type="string", length=50)
* @Assert\NotBlank()
*/
protected $gateway;
// external transaction id
/**
* @ORM\Column(type="string", length=255, nullable=true)
*/
protected $ext_transaction_id;
// other data related to the transaction
/**
* @ORM\Column(type="json")
*/
protected $metadata;
public function __construct()
{
$this->date_create = new DateTime();
$this->status = TransactionStatus::PENDING;
$this->metadata = [];
}
public function getID()
{
return $this->id;
}
public function setCustomer(Customer $customer)
{
$this->customer = $customer;
return $this;
}
public function getCustomer()
{
return $this->customer;
}
public function setDateCreate(DateTime $date)
{
$this->date_create = $date;
return $this;
}
public function getDateCreate()
{
return $this->date_create;
}
public function setDatePay(DateTime $date)
{
$this->date_pay = $date;
return $this;
}
public function getDatePay()
{
return $this->date_pay;
}
public function setAmount($amount)
{
$this->amount = $amount;
return $this;
}
public function getAmount()
{
return $this->amount;
}
public function setStatus($status)
{
$this->status = $status;
return $this;
}
public function getStatus()
{
return $this->status;
}
public function setType($type)
{
$this->type = $type;
return $this;
}
public function getType()
{
return $this->type;
}
public function setGateway($gateway)
{
$this->gateway = $gateway;
return $this;
}
public function getGateway()
{
return $this->gateway;
}
public function setExtTransactionId($transaction_id)
{
$this->ext_transaction_id = $transaction_id;
return $this;
}
public function getExtTransactionId()
{
return $this->ext_transaction_id;
}
public function setCallbackClass($callback_class)
{
$this->callback_class = $callback_class;
return $this;
}
public function getCallbackClass()
{
return $this->callback_class;
}
public function setMetadata($metadata)
{
$this->metadata = $metadata;
return $this;
}
public function getMetadata()
{
return $this->metadata;
}
}

View file

@ -36,19 +36,12 @@ class InsuranceApplication
*/ */
protected $customer_vehicle; protected $customer_vehicle;
// paramount transaction id // gateway transaction
/** /**
* @ORM\Column(type="string", length=32) * @ORM\OneToOne(targetEntity="GatewayTransaction")
* @Assert\NotBlank() * @ORM\JoinColumn(name="gateway_transaction_id", referencedColumnName="id")
*/ */
protected $transaction_id; protected $gateway_transaction;
// premium amount
/**
* @ORM\Column(type="decimal", precision=7, scale=2)
* @Assert\NotBlank()
*/
protected $premium_amount;
// status // status
/** /**
@ -66,19 +59,19 @@ class InsuranceApplication
/** /**
* @ORM\Column(type="datetime") * @ORM\Column(type="datetime")
*/ */
protected $date_submitted; protected $date_submit;
// date the application was paid // date the application was paid
/** /**
* @ORM\Column(type="datetime", nullable=true) * @ORM\Column(type="datetime", nullable=true)
*/ */
protected $date_paid; protected $date_pay;
// date the application was marked as completed by the insurance api // date the application was marked as completed by the insurance api
/** /**
* @ORM\Column(type="datetime", nullable=true) * @ORM\Column(type="datetime", nullable=true)
*/ */
protected $date_completed; protected $date_complete;
// form data when submitting the application // form data when submitting the application
/** /**
@ -86,23 +79,11 @@ class InsuranceApplication
*/ */
protected $metadata; protected $metadata;
// paymongo checkout url
/**
* @ORM\Column(type="string", length=255, nullable=true)
*/
protected $checkout_url;
// paymongo checkout id
/**
* @ORM\Column(type="string", length=32, nullable=true)
*/
protected $checkout_id;
public function __construct() public function __construct()
{ {
$this->date_submitted = new DateTime(); $this->date_submit = new DateTime();
$this->date_paid = null; $this->date_pay = null;
$this->date_completed = null; $this->date_complete = null;
$this->metadata = []; $this->metadata = [];
} }
@ -133,35 +114,26 @@ class InsuranceApplication
return $this->customer_vehicle; return $this->customer_vehicle;
} }
public function setDateSubmitted(DateTime $date) public function setDateSubmit(DateTime $date)
{ {
$this->date_submitted = $date; $this->date_submit = $date;
return $this; return $this;
} }
public function getDateSubmitted() public function getDateSubmit()
{ {
return $this->date_submitted; return $this->date_submit;
} }
public function setTransactionID($id) public function setGatewayTransaction(GatewayTransaction $transaction)
{ {
return $this->transaction_id = $id; $this->gateway_transaction = $transaction;
return $this;
} }
public function getTransactionID() public function getGatewayTransaction()
{ {
return $this->transaction_id; return $this->gateway_transaction;
}
public function setPremiumAmount($amount)
{
return $this->premium_amount = $amount;
}
public function getPremiumAmount()
{
return $this->premium_amount;
} }
public function setStatus($status) public function setStatus($status)
@ -184,26 +156,26 @@ class InsuranceApplication
return $this->coc_url; return $this->coc_url;
} }
public function setDatePaid(DateTime $date) public function setDatePay(DateTime $date)
{ {
$this->date_paid = $date; $this->date_pay = $date;
return $this; return $this;
} }
public function getDatePaid() public function getDatePay()
{ {
return $this->date_paid; return $this->date_pay;
} }
public function setDateCompleted(DateTime $date) public function setDateComplete(DateTime $date)
{ {
$this->date_completed = $date; $this->date_complete = $date;
return $this; return $this;
} }
public function getDateCompleted() public function getDateComplete()
{ {
return $this->date_completed; return $this->date_complete;
} }
public function setMetadata($metadata) public function setMetadata($metadata)
@ -215,24 +187,4 @@ class InsuranceApplication
{ {
return $this->metadata; return $this->metadata;
} }
public function setCheckoutURL($url)
{
return $this->checkout_url = $url;
}
public function getCheckoutURL()
{
return $this->checkout_url;
}
public function setCheckoutID($id)
{
return $this->checkout_id = $id;
}
public function getCheckoutID()
{
return $this->checkout_id;
}
} }

View file

@ -0,0 +1,75 @@
<?php
namespace App\EntityListener;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Doctrine\ORM\EntityManagerInterface;
use App\Entity\GatewayTransaction;
use App\Entity\InsuranceApplication;
use App\Service\InsuranceConnector;
use App\Ramcar\InsuranceApplicationStatus;
use App\Ramcar\TransactionStatus;
use DateTime;
class GatewayTransactionListener
{
protected $ic;
protected $em;
public function __construct(EntityManagerInterface $em, InsuranceConnector $ic)
{
$this->em = $em;
$this->ic = $ic;
}
public function postUpdate(GatewayTransaction $gt_obj, LifecycleEventArgs $args)
{
// get transaction changes
$em = $args->getEntityManager();
$uow = $em->getUnitOfWork();
$changeset = $uow->getEntityChangeSet($gt_obj);
if (array_key_exists('status', $changeset)) {
$field_changes = $changeset['status'];
$prev_value = $field_changes[0] ?? null;
$new_value = $field_changes[1] ?? null;
// only do something if the status has changed to paid
if ($prev_value !== $new_value && $new_value === TransactionStatus::PAID) {
// handle based on type
// TODO: add types here as we go. there's probably a better way to do this.
switch ($gt_obj->getType()) {
case 'insurance_premium':
return $this->handleInsurancePremium($gt_obj);
break;
default:
break;
}
}
}
}
protected function handleInsurancePremium($gt_obj)
{
// get insurance application object
$obj = $this->em->getRepository(InsuranceApplication::class)->findOneBy([
'gateway_transaction' => $gt_obj,
]);
if ($obj) {
// mark as paid
$obj->setDatePay(new DateTime());
$obj->setStatus(InsuranceApplicationStatus::PAID);
$this->em->flush();
}
// flag on api as paid
$result = $this->ic->tagApplicationPaid($obj->getID());
if (!$result['success']) {
error_log("INSURANCE MARK AS PAID FAILED FOR " . $obj->getID() . ": " . $result['error']['message']);
}
}
}

View file

@ -0,0 +1,18 @@
<?php
namespace App\Ramcar;
class TransactionStatus extends NameValue
{
const PENDING = 'pending';
const PAID = 'paid';
const CANCELLED = 'cancelled';
const REFUNDED = 'refunded';
const COLLECTION = [
'pending' => 'Pending',
'paid' => 'Paid',
'cancelled' => 'Cancelled',
'refunded' => 'Refunded',
];
}

View file

@ -110,6 +110,7 @@ class InsuranceConnector
error_log("Insurance API Error: " . $error['message']); error_log("Insurance API Error: " . $error['message']);
error_log(Psr7\Message::toString($e->getRequest())); error_log(Psr7\Message::toString($e->getRequest()));
error_log($e->getResponse()->getBody()->getContents());
if ($e->hasResponse()) { if ($e->hasResponse()) {
$error['response'] = Psr7\Message::toString($e->getResponse()); $error['response'] = Psr7\Message::toString($e->getResponse());
@ -121,7 +122,7 @@ class InsuranceConnector
]; ];
} }
//error_log(print_r(json_decode($response->getBody(), true), true)); error_log(print_r(json_decode($response->getBody(), true), true));
return [ return [
'success' => true, 'success' => true,

View file

@ -51,18 +51,21 @@ class PayMongoConnector
* ['name', 'description', 'quantity', 'amount', 'currency'] * ['name', 'description', 'quantity', 'amount', 'currency']
*/ */
'line_items' => $items, 'line_items' => $items,
'reference_number' => $ref_no, 'reference_number' => (string)$ref_no,
'cancel_url' => $cancel_url, 'cancel_url' => $cancel_url,
'success_url' => $success_url, 'success_url' => $success_url,
'statement_descriptor' => $description, 'statement_descriptor' => $description,
'send_email_receipt' => true, 'send_email_receipt' => true,
'show_description' => true, 'show_description' => true,
'show_line_items' => false, 'show_line_items' => false,
'metadata' => $metadata,
], ],
], ],
]; ];
if (!empty($metadata)) {
$body['data']['attributes']['metadata'] = $metadata;
}
return $this->doRequest('/v1/checkout_sessions', 'POST', $body); return $this->doRequest('/v1/checkout_sessions', 'POST', $body);
} }

View file

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Payment Cancelled</title>
<style>
body {
background-color: #333;
}
</style>
</head>
<body>
<script>
window.addEventListener('load', (e) => {
if (typeof toApp !== 'undefined') {
toApp.postMessage("paymentCancelled");
}
});
</script>
</body>
</html>

View file

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Payment Successful</title>
<style>
body {
background-color: #333;
}
</style>
</head>
<body>
<script>
window.addEventListener('load', (e) => {
if (typeof toApp !== 'undefined') {
toApp.postMessage("paymentSuccess");
}
});
</script>
</body>
</html>