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\/
security: false
paymongo:
pattern: ^\/paymongo\/
security: false
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\/)
provider: api_v2_provider

View file

@ -4,13 +4,3 @@ insurance_listener:
path: /insurance/listen
controller: App\Controller\InsuranceController::listen
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
controller: App\Controller\PayMongoController::listen
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)%"
$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
App\Service\PayMongoConnector:
arguments:

View file

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

View file

@ -2,6 +2,8 @@
namespace App\Controller;
use App\Entity\GatewayTransaction;
use App\Ramcar\TransactionStatus;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\Request;
@ -10,14 +12,89 @@ use Symfony\Bundle\FrameworkBundle\Controller\Controller;
class PayMongoController extends Controller
{
public function listen(Request $req, EntityManagerInterface $em)
protected $em;
public function __construct(EntityManagerInterface $em)
{
$payload = $req->request->all();
error_log(print_r($payload, true));
$this->em = $em;
}
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([
'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;
// paramount transaction id
// gateway transaction
/**
* @ORM\Column(type="string", length=32)
* @Assert\NotBlank()
* @ORM\OneToOne(targetEntity="GatewayTransaction")
* @ORM\JoinColumn(name="gateway_transaction_id", referencedColumnName="id")
*/
protected $transaction_id;
// premium amount
/**
* @ORM\Column(type="decimal", precision=7, scale=2)
* @Assert\NotBlank()
*/
protected $premium_amount;
protected $gateway_transaction;
// status
/**
@ -66,19 +59,19 @@ class InsuranceApplication
/**
* @ORM\Column(type="datetime")
*/
protected $date_submitted;
protected $date_submit;
// date the application was paid
/**
* @ORM\Column(type="datetime", nullable=true)
*/
protected $date_paid;
protected $date_pay;
// date the application was marked as completed by the insurance api
/**
* @ORM\Column(type="datetime", nullable=true)
*/
protected $date_completed;
protected $date_complete;
// form data when submitting the application
/**
@ -86,23 +79,11 @@ class InsuranceApplication
*/
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()
{
$this->date_submitted = new DateTime();
$this->date_paid = null;
$this->date_completed = null;
$this->date_submit = new DateTime();
$this->date_pay = null;
$this->date_complete = null;
$this->metadata = [];
}
@ -133,35 +114,26 @@ class InsuranceApplication
return $this->customer_vehicle;
}
public function setDateSubmitted(DateTime $date)
public function setDateSubmit(DateTime $date)
{
$this->date_submitted = $date;
$this->date_submit = $date;
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;
}
public function setPremiumAmount($amount)
{
return $this->premium_amount = $amount;
}
public function getPremiumAmount()
{
return $this->premium_amount;
return $this->gateway_transaction;
}
public function setStatus($status)
@ -184,26 +156,26 @@ class InsuranceApplication
return $this->coc_url;
}
public function setDatePaid(DateTime $date)
public function setDatePay(DateTime $date)
{
$this->date_paid = $date;
$this->date_pay = $date;
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;
}
public function getDateCompleted()
public function getDateComplete()
{
return $this->date_completed;
return $this->date_complete;
}
public function setMetadata($metadata)
@ -215,24 +187,4 @@ class InsuranceApplication
{
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(Psr7\Message::toString($e->getRequest()));
error_log($e->getResponse()->getBody()->getContents());
if ($e->hasResponse()) {
$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 [
'success' => true,

View file

@ -51,18 +51,21 @@ class PayMongoConnector
* ['name', 'description', 'quantity', 'amount', 'currency']
*/
'line_items' => $items,
'reference_number' => $ref_no,
'reference_number' => (string)$ref_no,
'cancel_url' => $cancel_url,
'success_url' => $success_url,
'statement_descriptor' => $description,
'send_email_receipt' => true,
'show_description' => true,
'show_line_items' => false,
'metadata' => $metadata,
],
],
];
if (!empty($metadata)) {
$body['data']['attributes']['metadata'] = $metadata;
}
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>