Merge branch '799-subscription-support' into 809-loyalty-system-support

This commit is contained in:
Ramon Gutierrez 2024-10-14 07:22:24 +08:00
commit 857c573ae5
20 changed files with 1073 additions and 61 deletions

View file

@ -317,4 +317,41 @@ apiv2_insurance_body_types:
apiv2_loyalty_register:
path: /apiv2/loyalty/register
controller: App\Controller\CustomerAppAPI\LoyaltyController::register
methods: [POST]
methods: [POST]
# static content
apiv2_static_content:
path: /apiv2/static_content/{id}
controller: App\Controller\CustomerAppAPI\StaticContentController::getContent
methods: [GET]
# subscription
apiv2_subscription_plan_details:
path: /apiv2/subscription/vehicle/{vid}/plan
controller: App\Controller\CustomerAppAPI\SubscriptionController::getPlanDetails
methods: [GET]
#apiv2_subscription_paymongo_public_key:
# path: /apiv2/subscription/ppk
# controller: App\Controller\CustomerAppAPI\SubscriptionController::getPaymongoPublicKey
# methods: [GET]
apiv2_subscription_create:
path: /apiv2/subscription
controller: App\Controller\CustomerAppAPI\SubscriptionController::createSubscription
methods: [POST]
apiv2_subscription_finalize:
path: /apiv2/subscription/{id}/finalize
controller: App\Controller\CustomerAppAPI\SubscriptionController::finalizeSubscription
methods: [GET]
#apiv2_subscription_payment_intent:
# path: /apiv2/subscription/payment_intent/{pi_id}
# controller: App\Controller\CustomerAppAPI\SubscriptionController::getPaymentIntent
# methods: [GET]
#apiv2_subscription_activate:
# path: /apiv2/subscription/{id}/activate
# controller: App\Controller\CustomerAppAPI\SubscriptionController::activateSubscription
# methods: [POST]

View file

@ -17,6 +17,13 @@ parameters:
ios_app_version: "%env(IOS_APP_VERSION)%"
insurance_premiums_banner_url: "%env(INSURANCE_PREMIUMS_BANNER_URL)%"
enabled_hub_filters: "%env(ENABLED_HUB_FILTERS)%"
insurance_paymongo_public_key: "%env(INSURANCE_PAYMONGO_PUBLIC_KEY)%"
insurance_paymongo_secret_key: "%env(INSURANCE_PAYMONGO_SECRET_KEY)%"
insurance_paymongo_webhook_id: "%env(INSURANCE_PAYMONGO_WEBHOOK_ID)%"
subscription_paymongo_public_key: "%env(SUBSCRIPTION_PAYMONGO_PUBLIC_KEY)%"
subscription_paymongo_secret_key: "%env(SUBSCRIPTION_PAYMONGO_SECRET_KEY)%"
subscription_paymongo_webhook_id: "%env(SUBSCRIPTION_PAYMONGO_WEBHOOK_ID)%"
subscription_months: "%env(SUBSCRIPTION_MONTHS)%"
services:
# default configuration for services in *this* file
@ -114,7 +121,6 @@ services:
arguments:
$em: "@doctrine.orm.entity_manager"
$paymongo: "@App\\Service\\PayMongoConnector"
$webhook_id: "%env(PAYMONGO_WEBHOOK_ID)%"
# rider tracker service
App\Service\RiderTracker:
@ -238,8 +244,6 @@ services:
App\Service\PayMongoConnector:
arguments:
$base_url: "%env(PAYMONGO_BASE_URL)%"
$public_key: "%env(PAYMONGO_PUBLIC_KEY)%"
$secret_key: "%env(PAYMONGO_SECRET_KEY)%"
# loyalty system connector
App\Service\LoyaltyConnector:

View file

@ -6,6 +6,7 @@ use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Doctrine\ORM\EntityManagerInterface;
@ -19,14 +20,18 @@ class ProcessLatePaymongoTransactionsCommand extends Command
{
protected $em;
protected $paymongo;
protected $webhook_id;
public function __construct(EntityManagerInterface $em, PayMongoConnector $paymongo, $webhook_id)
public function __construct(EntityManagerInterface $em, PayMongoConnector $paymongo, ParameterBagInterface $params)
{
$this->em = $em;
$this->webhook_id = $params->get('insurance_paymongo_webhook_id');
$this->paymongo = $paymongo;
$this->webhook_id = $webhook_id;
$this->paymongo->initialize(
$params->get('insurance_paymongo_public_key'),
$params->get('insurance_paymongo_secret_key')
);
parent::__construct();
}

View file

@ -9,11 +9,24 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use App\Service\PayMongoConnector;
use Catalyst\MenuBundle\Annotation\Menu;
class BatterySizeController extends Controller
{
protected $pm;
public function __construct(PayMongoConnector $pm, ParameterBagInterface $params)
{
$this->pm = $pm;
$this->pm->initialize(
$params->get('subscription_paymongo_public_key'),
$params->get('subscription_paymongo_secret_key'),
);
}
/**
* @Menu(selected="bsize_list")
*/
@ -130,7 +143,8 @@ class BatterySizeController extends Controller
->setTIPriceMotolite($req->request->get('tip_motolite'))
->setTIPricePremium($req->request->get('tip_premium'))
->setTIPriceOther($req->request->get('tip_other'))
->setTIPriceLazada($req->request->get('tip_lazada'));
->setTIPriceLazada($req->request->get('tip_lazada'))
->setSubRecurringFee($req->request->get('sub_recurring_fee'));
}
public function addSubmit(Request $req, ValidatorInterface $validator)
@ -167,6 +181,9 @@ class BatterySizeController extends Controller
$em->persist($row);
$em->flush();
// create new paymongo subscription plan
$this->pm->createOrUpdateSubPlan($row);
// return successful response
return $this->json([
'success' => 'Changes have been saved!'
@ -234,6 +251,9 @@ class BatterySizeController extends Controller
// validated! save the entity
$em->flush();
// find if paymongo subscription plan exists, then update accordingly
$this->pm->createOrUpdateSubPlan($row);
// return successful response
return $this->json([
'success' => 'Changes have been saved!'

View file

@ -13,6 +13,7 @@ use App\Ramcar\JOStatus;
use App\Entity\Warranty;
use App\Entity\JobOrder;
use App\Entity\CustomerSession;
use App\Service\PayMongoConnector;
class ApiController extends BaseApiController
{
@ -164,4 +165,12 @@ class ApiController extends BaseApiController
{
return 'Our services are currently limited to some areas in Metro Manila, Baguio, Batangas, Laguna, Cavite, Pampanga, and Palawan. We will update you as soon as we are available in your area. Thank you for understanding. Keep safe!';
}
protected function initializeSubscriptionPayMongoConnector(PayMongoConnector &$pm)
{
$pm->initialize(
$this->getParameter('subscription_paymongo_public_key'),
$this->getParameter('subscription_paymongo_secret_key'),
);
}
}

View file

@ -9,6 +9,7 @@ use App\Ramcar\CustomerSource;
use App\Entity\Customer;
use App\Entity\PrivacyPolicy;
use App\Service\HashGenerator;
use App\Service\PayMongoConnector;
class CustomerController extends ApiController
{
@ -43,13 +44,10 @@ class CustomerController extends ApiController
]);
}
public function updateInfo(Request $req)
public function updateInfo(Request $req, PayMongoConnector $pm)
{
// validate params
$validity = $this->validateRequest($req, [
'first_name',
'last_name',
]);
$validity = $this->validateRequest($req);
if (!$validity['is_valid']) {
return new ApiResponse(false, $validity['error']);
@ -67,6 +65,12 @@ class CustomerController extends ApiController
$this->em->flush();
// initialize paymongo connector
$this->initializeSubscriptionPayMongoConnector($pm);
// update customer paymongo record if exists
$pm->updateCustomerIfExists($cust);
// response
return new ApiResponse();
}
@ -129,10 +133,21 @@ class CustomerController extends ApiController
$this->session->setCustomer($cust);
}
$cust->setFirstName($req->request->get('first_name'))
->setLastName($req->request->get('last_name'))
->setEmail($req->request->get('email', ''))
->setConfirmed($this->session->isConfirmed());
if (!is_null($req->request->get('first_name'))) {
$cust->setFirstName($req->request->get('first_name'));
}
if (!is_null($req->request->get('last_name'))) {
$cust->setLastName($req->request->get('last_name'));
}
if (!is_null($req->request->get('email'))) {
$cust->setEmail($req->request->get('email'));
}
if (!is_null($this->session->isConfirmed())) {
$cust->setConfirmed($this->session->isConfirmed());
}
// if customer user isn't set, set it now
if ($cust->getCustomerUser() == null) {

View file

@ -3,6 +3,7 @@
namespace App\Controller\CustomerAppAPI;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpKernel\KernelInterface;
use Catalyst\ApiBundle\Component\Response as ApiResponse;
@ -33,7 +34,7 @@ class InsuranceController extends ApiController
$this->client = $client;
}
public function createApplication(Request $req, PayMongoConnector $paymongo, UrlGeneratorInterface $router)
public function createApplication(Request $req, PayMongoConnector $paymongo, UrlGeneratorInterface $router, ParameterBagInterface $params)
{
// validate params
$validity = $this->validateRequest($req, [
@ -162,6 +163,12 @@ class InsuranceController extends ApiController
$this->em->persist($gt);
$this->em->flush();
// initialize paymongo connector
$paymongo->initialize(
$params->get('insurance_paymongo_public_key'),
$params->get('insurance_paymongo_secret_key')
);
// create paymongo checkout resource
$checkout = $paymongo->createCheckout(
$cust,

View file

@ -0,0 +1,34 @@
<?php
namespace App\Controller\CustomerAppAPI;
use Symfony\Component\HttpFoundation\Request;
use Catalyst\ApiBundle\Component\Response as ApiResponse;
use App\Entity\StaticContent;
class StaticContentController extends ApiController
{
public function getContent(Request $req, $id)
{
// check requirements
$validity = $this->validateRequest($req);
if (!$validity['is_valid']) {
return new ApiResponse(false, $validity['error']);
}
// get content
$content = $this->em->getRepository(Staticcontent::class)->find($id);
// check if it exists
if ($content == null) {
return new ApiResponse(false, 'Requested content does not exist.');
}
// response
return new ApiResponse(true, '', [
'content' => $content->getContent(),
]);
}
}

View file

@ -0,0 +1,315 @@
<?php
namespace App\Controller\CustomerAppAPI;
use Symfony\Component\HttpFoundation\Request;
use Catalyst\ApiBundle\Component\Response as ApiResponse;
use App\Service\PayMongoConnector;
use App\Entity\Customer;
use App\Entity\Vehicle;
use App\Entity\Subscription;
use App\Entity\CustomerVehicle;
use App\Ramcar\SubscriptionStatus;
use DateTime;
class SubscriptionController extends ApiController
{
public function getPlanDetails(Request $req, $vid, PayMongoConnector $pm)
{
// check requirements
$validity = $this->validateRequest($req);
if (!$validity['is_valid']) {
return new ApiResponse(false, $validity['error']);
}
// get vehicle
$vehicle = $this->em->getRepository(Vehicle::class)->find($vid);
if ($vehicle == null) {
return new ApiResponse(false, 'Invalid vehicle.');
}
$plan = null;
// get compatible batteries
$batts = $vehicle->getActiveBatteries();
if (!empty($batts)) {
// initialize paymongo connector
$this->initializeSubscriptionPayMongoConnector($pm);
$plan = $pm->getPlanByBatterySize($batts[0]->getSize());
}
error_log("FOUND PLAN FOR $vid: " . print_r($plan, true));
// response
return new ApiResponse(true, '', [
'plan' => $plan,
]);
}
// NOTE: disabling this since we can just include the public key with the subscription creation endpoint
/*
public function getPayMongoPublicKey(Request $req)
{
// check requirements
$validity = $this->validateRequest($req);
if (!$validity['is_valid']) {
return new ApiResponse(false, $validity['error']);
}
// response
return new ApiResponse(true, '', [
'key' => $this->getParameter('subscription_paymongo_public_key'),
]);
}
*/
public function createSubscription(Request $req, PayMongoConnector $pm)
{
// check requirements
$validity = $this->validateRequest($req, [
'plan_id',
'cv_id',
'email',
]);
if (!$validity['is_valid']) {
return new ApiResponse(false, $validity['error']);
}
// get customer
$cust = $this->session->getCustomer();
if ($cust == null) {
return new ApiResponse(false, 'No customer information found.');
}
// verify email does not belong to someone else
$email = $req->request->get('email');
$qb = $this->em->getRepository(Customer::class)
->createQueryBuilder('c')
->select('c')
->where('c.email = :email')
->andWhere('c.id != :cust_id')
->setParameter('email', $email)
->setParameter('cust_id', $cust->getID());
$email_exists = $qb->getQuery()->getOneOrNullResult();
if (!empty($email_exists)) {
return new ApiResponse(false, 'Email is already in use. Please use a different email address.');
}
// get customer vehicle
$cv = $this->em->getRepository(CustomerVehicle::class)->find($req->request->get('cv_id'));
// check if it exists
if ($cv == null) {
return new ApiResponse(false, 'Vehicle does not exist.');
}
// check if it's owned by customer
if ($cv->getCustomer()->getID() != $cust->getID()) {
return new ApiResponse(false, 'Invalid vehicle.');
}
// initialize paymongo connector
$this->initializeSubscriptionPayMongoConnector($pm);
// get paymongo customer
$pm_cust = $pm->findOrCreateCustomer($email, $cust);
if (empty($pm_cust)) {
return new ApiResponse(false, 'Error retrieving customer record. Please try again later.');
}
// create subscription
// NOTE: for now we save ourselves the extra API call and assume the plan_id is valid since this won't change often anyway
$pm_sub = $pm->createSubscription($pm_cust['id'], $req->request->get('plan_id'));
$sub_pi = $pm_sub['response']['data']['attributes']['latest_invoice']['payment_intent'] ?? null;
// not the response we expected
if (empty($sub_pi)) {
return new ApiResponse(false, 'Error creating subscription. Please try again later.');
}
// the payment intent must still be in a pending state
// TODO: log this somewhere
if ($sub_pi['status'] !== 'awaiting_payment_method') {
return new ApiResponse(false, 'Error creating subscription invoice. Please try again later.');
}
// fetch payment intent details for client key
$pi = $pm->getPaymentIntent($sub_pi['id']);
if (empty($pi['response']['data']['id'])) {
return new ApiResponse(false, 'Error retrieving payment intent. Please try again later.');
}
// create subscription entity
$obj = new Subscription();
$obj->setCustomer($cust)
->setCustomerVehicle($cv)
->setEmail($email)
->setStatus(SubscriptionStatus::PENDING)
->setExtApiId($pm_sub['response']['data']['id'])
->setMetadata($pm_sub['response']['data']);
// if requested to save email, save it
if (!empty($req->request->get('remember_email'))) {
$cust->setEmail($email);
}
// save stuff to db
$this->em->persist($obj);
$this->em->flush();
// response
return new ApiResponse(true, '', [
'subscription_id' => $obj->getID(),
'payment_intent_id' => $pi['response']['data']['id'],
'payment_intent_client_key' => $pi['response']['data']['attributes']['client_key'],
'paymongo_public_key' => $this->getParameter('subscription_paymongo_public_key'),
]);
}
public function finalizeSubscription(Request $req, $id, PayMongoConnector $pm)
{
// check requirements
$validity = $this->validateRequest($req);
if (!$validity['is_valid']) {
return new ApiResponse(false, $validity['error']);
}
// initialize paymongo connector
$this->initializeSubscriptionPayMongoConnector($pm);
// get subscription
$sub_obj = $this->em->getRepository(Subscription::class)->findOneBy([
'id' => $id,
'status' => SubscriptionStatus::PENDING,
'customer' => $this->session->getCustomer(),
]);
if (empty($sub_obj)) {
return new ApiResponse(false, 'Invalid subscription provided.');
}
// get paymongo subscription so we can verify if the latest invoice is paid or not
$pm_sub = $pm->getSubscription($sub_obj->getExtApiId());
if (empty($pm_sub['response']['data']['id'])) {
return new ApiResponse(false, 'Error retrieving subscription. Please try again later.');
}
// make sure the latest invoice has been paid
// NOTE: ignore this, this is unreliable due to race condition
/*
if ($pm_sub['response']['data']['attributes']['latest_invoice']['status'] !== 'paid') {
return new ApiResponse(false, 'Latest invoice for subscription is not yet paid.');
}
*/
// get payment intent
$pi = $pm->getPaymentIntent($pm_sub['response']['data']['attributes']['latest_invoice']['payment_intent']['id']);
if (empty($pi['response']['data']['id'])) {
return new ApiResponse(false, 'Error retrieving payment intent. Please try again later.');
}
// if the paymongo sub is active, and the payment was successful, update the internal subscription status
if (
$sub_obj->getStatus() === SubscriptionStatus::PENDING &&
$pi['response']['data']['attributes']['status'] === 'succeeded'
) {
$sub_start_date = new DateTime();
$sub_end_date = clone $sub_start_date;
$sub_end_date->modify('+' . $this->getParameter('subscription_months') . ' month');
$sub_obj->setStatus(SubscriptionStatus::ACTIVE)
->setDateStart($sub_start_date)
->setDateEnd($sub_end_date);
$this->em->flush();
}
error_log("PI STATUS: " . $pi['response']['data']['attributes']['status']);
// response
return new ApiResponse(true, '', [
'payment_intent' => $pi['response']['data'],
]);
}
/*
public function getPaymentIntent(Request $req, $pid, PayMongoConnector $pm)
{
// check requirements
$validity = $this->validateRequest($req);
if (!$validity['is_valid']) {
return new ApiResponse(false, $validity['error']);
}
// initialize paymongo connector
$this->initializeSubscriptionPayMongoConnector($pm);
// get payment intent
$pi = $pm->getPaymentIntent($pid);
if (empty($pi['response']['data']['id'])) {
return new ApiResponse(false, 'Error retrieving payment intent. Please try again later.');
}
// response
return new ApiResponse(true, '', [
'payment_intent' => $pi['response']['data'],
]);
}
public function activateSubscription(Request $req, $id, PayMongoConnector $pm)
{
// check requirements
$validity = $this->validateRequest($req);
if (!$validity['is_valid']) {
return new ApiResponse(false, $validity['error']);
}
// initialize paymongo connector
$this->initializeSubscriptionPayMongoConnector($pm);
// get subscription
$obj = $this->em->getRepository(Subscription::class)->findOneBy([
'id' => $id,
'status' => SubscriptionStatus::PENDING,
'customer' => $this->session->getCustomer(),
]);
if (empty($obj)) {
return new ApiResponse(false, 'Invalid subscription provided.');
}
// get paymongo subscription so we can verify if the latest invoice is paid or not
$pm_sub = $pm->getSubscription($obj->getExtApiId());
if (empty($pm_sub['response']['data']['id'])) {
return new ApiResponse(false, 'Error retrieving subscription. Please try again later.');
}
// make sure the latest invoice has been paid
if ($pm_sub['response']['data']['attributes']['latest_invoice']['status'] !== 'succeeded') {
return new ApiResponse(false, 'Latest invoice for subscription is not yet paid.');
}
// mark subscription as paid
$obj->setStatus(SubscriptionStatus::ACTIVE)
->setDateStart(new DateTime());
$this->em->flush();
// response
return new ApiResponse(true, '', [
'success' => true,
]);
}
*/
}

View file

@ -3,6 +3,7 @@
namespace App\Controller\CustomerAppAPI;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Catalyst\ApiBundle\Component\Response as ApiResponse;
use CrEOF\Spatial\PHP\Types\Geometry\Point;
@ -15,6 +16,7 @@ use App\Ramcar\JOStatus;
use App\Ramcar\ServiceType;
use App\Ramcar\TradeInType;
use App\Ramcar\InsuranceApplicationStatus;
use App\Ramcar\SubscriptionStatus;
use App\Service\PayMongoConnector;
use App\Service\PriceTierManager;
use DateTime;
@ -115,7 +117,7 @@ class VehicleController extends ApiController
}
public function getVehicle(Request $req, $id, PayMongoConnector $paymongo)
public function getVehicle(Request $req, $id, PayMongoConnector $paymongo, ParameterBagInterface $params)
{
// check requirements
$validity = $this->validateRequest($req);
@ -139,7 +141,7 @@ class VehicleController extends ApiController
// response
return new ApiResponse(true, '', [
'vehicle' => $this->generateVehicleInfo($cv, true, $paymongo),
'vehicle' => $this->generateVehicleInfo($cv, true, true, $paymongo, $params),
]);
}
@ -210,7 +212,7 @@ class VehicleController extends ApiController
]);
}
public function listVehicles(Request $req, PayMongoConnector $paymongo)
public function listVehicles(Request $req, PayMongoConnector $paymongo, ParameterBagInterface $params)
{
// validate params
$validity = $this->validateRequest($req);
@ -231,7 +233,7 @@ class VehicleController extends ApiController
// only get the customer's vehicles whose flag_active is true
$cvs = $this->em->getRepository(CustomerVehicle::class)->findBy(['flag_active' => true, 'customer' => $cust]);
foreach ($cvs as $cv) {
$cv_list[] = $this->generateVehicleInfo($cv, true, $paymongo);
$cv_list[] = $this->generateVehicleInfo($cv, true, true, $paymongo, $params);
}
// response
@ -393,7 +395,7 @@ class VehicleController extends ApiController
// TODO: possibly refactor this bit
// if no valid previous JO is found, base the trade-in value on recommended batteries
if (!$previous_jo_found) {
$comp_batteries = $cv->getVehicle()->getBatteries();
$comp_batteries = $cv->getVehicle()->getActiveBatteries();
// get the lowest trade-in value from the list of batteries
if (!empty($comp_batteries)) {
@ -417,7 +419,7 @@ class VehicleController extends ApiController
];
}
protected function generateVehicleInfo(CustomerVehicle $cv, $include_insurance = false, PayMongoConnector $paymongo)
protected function generateVehicleInfo(CustomerVehicle $cv, $include_insurance = false, $include_active_sub = false, PayMongoConnector $paymongo, ParameterBagInterface $params)
{
$battery_id = null;
if ($cv->getCurrentBattery() != null)
@ -437,6 +439,8 @@ class VehicleController extends ApiController
'cv_id' => $cv->getID(),
'mfg_id' => $cv->getVehicle()->getManufacturer()->getID(),
'make_id' => $cv->getVehicle()->getID(),
'mfg_name' => $cv->getVehicle()->getManufacturer()->getName(),
'make_name' => $cv->getVehicle()->getMake(),
'name' => $cv_name,
'plate_num' => $cv->getPlateNumber(),
'model_year' => $cv->getModelYear(),
@ -467,6 +471,12 @@ class VehicleController extends ApiController
// TODO: maybe handle this more elegantly. issue is not sure it is a good idea to update the db for this very transient status as the webhook listener also updates this status right away
switch ($status) {
case InsuranceApplicationStatus::CREATED:
// initialize paymongo connector
$paymongo->initialize(
$params->get('insurance_paymongo_public_key'),
$params->get('insurance_paymongo_secret_key')
);
// get latest status on this checkout from paymongo
$checkout = $paymongo->getCheckout($gt->getExtTransactionId());
@ -506,6 +516,17 @@ class VehicleController extends ApiController
$row['latest_insurance'] = $insurance;
}
// get active subscription row
if ($include_active_sub) {
$active_sub = null;
$sobj = $cv->getLatestSubscription();
if (!empty($sobj) && $sobj->getStatus() == SubscriptionStatus::ACTIVE) {
$active_sub = $sobj;
}
$row['active_subscription'] = $active_sub;
}
return $row;
}

View file

@ -212,8 +212,7 @@ class StaticContentController extends Controller
throw $this->createNotFoundException('The item does not exist');
// set and save values
$row->setID($req->request->get('id'))
->setContent($req->request->get('content'));
$row->setContent($req->request->get('content'));
// validate
$errors = $validator->validate($row);
@ -221,13 +220,6 @@ class StaticContentController extends Controller
// initialize error list
$error_array = [];
// check for duplicate ID
$result = $em->getRepository(StaticContent::class)->find($id);
if ($result != null)
{
$error_array['id'] = 'Duplicate ID exists.';
}
// add errors to list
foreach ($errors as $error) {
$error_array[$error->getPropertyPath()] = $error->getMessage();

View file

@ -57,6 +57,12 @@ class BatterySize
*/
protected $tip_lazada;
// subscription msrp
/**
* @ORM\Column(type="decimal", precision=7, scale=2, nullable=true)
*/
protected $sub_recurring_fee;
public function __construct()
{
$this->batteries = new ArrayCollection();
@ -64,6 +70,7 @@ class BatterySize
$this->tip_premium = 0;
$this->tip_other = 0;
$this->tip_lazada = 0;
$this->sub_recurring_fee = 0;
}
public function getID()
@ -149,4 +156,14 @@ class BatterySize
return $this->tip_lazada;
}
public function setSubRecurringFee($sub_recurring_fee)
{
$this->sub_recurring_fee = $sub_recurring_fee;
return $this;
}
public function getSubRecurringFee()
{
return $this->sub_recurring_fee;
}
}

View file

@ -3,6 +3,7 @@
namespace App\Entity;
use App\Ramcar\InsuranceApplicationStatus;
use App\Ramcar\SubscriptionStatus;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection;
@ -122,6 +123,12 @@ class CustomerVehicle
*/
protected $insurance_applications;
// link to subscription
/**
* @ORM\OneToMany(targetEntity="Subscription", mappedBy="customer_vehicle")
*/
protected $subscriptions;
public function __construct()
{
$this->flag_active = true;
@ -306,4 +313,26 @@ class CustomerVehicle
return !empty($result) ? $result[0] : null;
}
public function getSubscriptions()
{
return $this->subscriptions;
}
public function getLatestSubscription()
{
// we get the latest subscription that actually started
$criteria = Criteria::create()
->where(Criteria::expr()->notIn('status', [
SubscriptionStatus::CANCELLED,
SubscriptionStatus::PENDING,
]))
->where(Criteria::expr()->neq('date_start', null))
->orderBy(['date_create' => Criteria::DESC])
->setMaxResults(1);
$result = $this->subscriptions->matching($criteria);
return !empty($result) ? $result[0] : null;
}
}

214
src/Entity/Subscription.php Normal file
View file

@ -0,0 +1,214 @@
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection;
use Symfony\Component\Validator\Constraints as Assert;
use DateTime;
/**
* @ORM\Entity
* @ORM\Table(name="subscription")
*/
class Subscription
{
/**
* @ORM\Id
* @ORM\Column(type="integer")
* @ORM\GeneratedValue(strategy="AUTO")
*/
protected $id;
// link to customer
/**
* @ORM\ManyToOne(targetEntity="Customer")
* @ORM\JoinColumn(name="customer_id", referencedColumnName="id")
*/
protected $customer;
/**
* @ORM\ManyToOne(targetEntity="CustomerVehicle", inversedBy="subscriptions")
* @ORM\JoinColumn(name="customer_vehicle_id", referencedColumnName="id")
*/
protected $customer_vehicle;
// email address
/**
* @ORM\Column(type="string", length=255, nullable=true)
*/
protected $email;
// date subscription was created
/**
* @ORM\Column(type="datetime")
*/
protected $date_create;
// date subscription starts
/**
* @ORM\Column(type="datetime", nullable=true)
*/
protected $date_start;
// date subscription ends
/**
* @ORM\Column(type="datetime", nullable=true)
*/
protected $date_end;
// date subscription was cancelled
/**
* @ORM\Column(type="datetime", nullable=true)
*/
protected $date_cancel;
// external api id (paymongo)
/**
* @ORM\Column(type="string", length=255, nullable=true)
*/
protected $ext_api_id;
// status of the subscription
/**
* @ORM\Column(type="string", length=50)
* @Assert\NotBlank()
*/
protected $status;
// other data related to the transaction
/**
* @ORM\Column(type="json")
*/
protected $metadata;
public function __construct()
{
$this->date_create = new DateTime();
$this->date_start = null;
$this->date_end = null;
$this->date_cancel = null;
$this->metadata = [];
}
public function getID()
{
return $this->id;
}
public function setCustomer(Customer $cust = null)
{
$this->customer = $cust;
return $this;
}
public function getCustomer()
{
return $this->customer;
}
public function setCustomerVehicle(CustomerVehicle $customer_vehicle)
{
$this->customer_vehicle = $customer_vehicle;
return $this;
}
public function getCustomerVehicle()
{
return $this->customer_vehicle;
}
public function setDateCreate(DateTime $date)
{
$this->date_create = $date;
return $this;
}
public function getDateCreate()
{
return $this->date_create;
}
public function setDateStart(DateTime $date)
{
$this->date_start = $date;
return $this;
}
public function getDateStart()
{
return $this->date_start;
}
public function setDateEnd(DateTime $date)
{
$this->date_end = $date;
return $this;
}
public function getDateEnd()
{
return $this->date_end;
}
public function setDateCancel(DateTime $date)
{
$this->date_cancel = $date;
return $this;
}
public function getDateCancel()
{
return $this->date_cancel;
}
public function setStatus($status)
{
$this->status = $status;
return $this;
}
public function getStatus()
{
return $this->status;
}
public function setEmail($email)
{
$this->email = $email;
return $this;
}
public function getEmail()
{
return $this->email;
}
public function setExtApiId($ext_api_id)
{
$this->ext_api_id = $ext_api_id;
return $this;
}
public function getExtApiId()
{
return $this->ext_api_id;
}
public function setMetadata($metadata)
{
$this->metadata = $metadata;
return $this;
}
public function getMetadata()
{
return $this->metadata;
}
public function getGatewayTransactions()
{
// TODO: get gateway transactions here via type and metadata
}
}

View file

@ -0,0 +1,20 @@
<?php
namespace App\Ramcar;
class SubscriptionStatus extends NameValue
{
const PENDING = 'pending';
const ACTIVE = 'active';
const ENDED = 'ended';
const CANCELLED = 'cancelled';
const REPOSSESSED = 'reposessed';
const COLLECTION = [
'pending' => 'Pending',
'active' => 'Active',
'ended' => 'Ended',
'cancelled' => 'Cancelled',
'repossessed' => 'Reposessed',
];
}

View file

@ -4,6 +4,7 @@ namespace App\Service\CustomerHandler;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@ -24,7 +25,7 @@ use App\Entity\Battery;
use App\Entity\VehicleManufacturer;
use App\Entity\BatteryManufacturer;
use App\Entity\CustomerTag;
use App\Service\PayMongoConnector;
use DateTime;
class ResqCustomerHandler implements CustomerHandlerInterface
@ -34,14 +35,22 @@ class ResqCustomerHandler implements CustomerHandlerInterface
protected $country_code;
protected $security;
protected $template_hash;
protected $pm;
public function __construct(EntityManagerInterface $em, ValidatorInterface $validator,
string $country_code, Security $security)
string $country_code, Security $security, PayMongoConnector $pm, ParameterBagInterface $params)
{
$this->em = $em;
$this->validator = $validator;
$this->country_code = $country_code;
$this->security = $security;
$this->pm = $pm;
// initialize paymongo connector
$this->pm->initialize(
$params->get('subscription_paymongo_public_key'),
$params->get('subscription_paymongo_secret_key'),
);
$this->loadTemplates();
}
@ -391,6 +400,9 @@ class ResqCustomerHandler implements CustomerHandlerInterface
$em->persist($cust);
$em->flush();
// update customer paymongo record if exists
$this->pm->updateCustomerIfExists($cust);
$result = [
'id' => $cust->getID(),
];

View file

@ -2,25 +2,39 @@
namespace App\Service;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use GuzzleHttp\Client;
use GuzzleHttp\Psr7;
use GuzzleHttp\Exception\RequestException;
use App\Entity\Customer;
use App\Entity\BatterySize;
use Doctrine\ORM\Query\Parameter;
class PayMongoConnector
{
protected $base_url;
protected $public_key;
protected $secret_key;
protected $hash;
public function __construct($base_url, $public_key, $secret_key)
protected $public_hash;
protected $secret_hash;
protected $sub_months;
public function __construct($base_url, ParameterBagInterface $params)
{
$this->base_url = $base_url;
$this->sub_months = $params->get('subscription_months');
}
public function initialize($public_key, $secret_key)
{
$this->public_key = $public_key;
$this->secret_key = $secret_key;
$this->hash = $this->generateHash();
$this->public_hash = $this->generateHash($this->public_key);
$this->secret_hash = $this->generateHash($this->secret_key);
}
public function createCheckout(Customer $cust, $items, $ref_no = null, $description = null, $success_url = null, $cancel_url = null, $metadata = [])
@ -84,25 +98,251 @@ class PayMongoConnector
return $this->doRequest('/v1/webhooks/' . $id . '/enable', 'POST');
}
protected function generateHash()
public function getPlans()
{
return base64_encode($this->secret_key);
return $this->doRequest('/v1/subscriptions/plans?limit=100', 'GET');
}
protected function doRequest($url, $method, $request_body = [])
public function getPlanByBatterySize(BatterySize $bsize)
{
$client = new Client();
$headers = [
'Content-Type' => 'application/json',
'accept' => 'application/json',
'authorization' => 'Basic '. $this->hash,
// get all plans
$plans = $this->getPlans();
// find the plan with the matching metadata for plan ID
$found_plan = null;
if ($plans['success'] && !empty($plans['response']['data'])) {
foreach ($plans['response']['data'] as $plan) {
$plan_bsize_id = $plan['attributes']['metadata']['battery_size_id'] ?? null;
if ($plan_bsize_id == $bsize->getID()) {
$found_plan = $plan;
break;
}
}
}
return $found_plan;
}
public function createPlan($plan_data)
{
$body = [
'data' => [
'attributes' => [
'amount' => $plan_data['amount'],
'currency' => 'PHP',
'description' => $plan_data['description'],
'interval' => $plan_data['interval'],
'interval_count' => $plan_data['interval_count'],
//'cycle_count' => $plan_data['cycle_count'],
'name' => $plan_data['name'],
'metadata' => $plan_data['metadata'],
],
],
];
try {
$response = $client->request($method, $this->base_url . '/' . $url, [
'json' => $request_body,
'headers' => $headers,
return $this->doRequest('/v1/subscriptions/plans', 'POST', $body);
}
public function updatePlan($plan_id, $plan_data)
{
$body = [
'data' => [
'attributes' => [
'amount' => $plan_data['amount'],
],
],
];
return $this->doRequest('/v1/subscriptions/plans/' . $plan_id, 'PUT', $body);
}
public function createOrUpdateSubPlan(BatterySize $bsize)
{
$found_plan = $this->getPlanByBatterySize($bsize);
if (!empty($found_plan)) {
// update existing plan
$result = $this->updatePlan($found_plan['id'], [
'amount' => (int)bcmul($bsize->getSubRecurringFee(), 100),
]);
} else {
// create new plan
$result = $this->createPlan([
'name' => "RESQ Subscription",
'amount' => (int)bcmul($bsize->getSubRecurringFee() ?? 0, 100),
'description' => "Motolite Battery Subscription Plan",
'interval' => 'monthly',
'interval_count' => 1,
//'cycle_count' => $this->sub_months,
'metadata' => [
'battery_size_id' => (string)$bsize->getID(),
],
]);
}
return $result;
}
public function findCustomerByEmail($email)
{
return $this->doRequest('/v1/customers?email=' . $email, 'GET');
}
public function createCustomer(Customer $cust, $email_override = "")
{
$body = [
'data' => [
'attributes' => [
'first_name' => $cust->getFirstName(),
'last_name' => $cust->getLastName(),
'phone' => "+63" . $cust->getPhoneMobile(),
'email' => $email_override ?? $cust->getEmail(),
'default_device' => 'email',
],
],
];
return $this->doRequest('/v1/customers', 'POST', $body);
}
public function updateCustomer(Customer $cust, $ext_cust_id)
{
$body = [
'data' => [
'attributes' => [
'first_name' => $cust->getFirstName(),
'last_name' => $cust->getLastName(),
'phone' => $cust->getPhoneMobile(),
'email' => $cust->getEmail(),
],
],
];
return $this->doRequest('/v1/customers/' . $ext_cust_id, 'PUT', $body);
}
public function updateCustomerIfExists(Customer $cust)
{
$email = $cust->getEmail();
// if no email, then we don't need to update
if (empty($email)) {
return false;
}
// check if we have an existing paymongo customer with this email
$found_cust = $this->findCustomerByEmail($email);
if (isset($found_cust['data']['id'])) {
// update existing customer record
return $this->updateCustomer($cust, $found_cust['data']);
}
return false;
}
public function findOrCreateCustomer($email, Customer $cust)
{
error_log("FINDING RECORD FOR $email");
// check if we have an existing paymongo customer with this email
$found_cust = $this->findCustomerByEmail($email);
$pm_cust = null;
error_log("FOUND CUSTOMER?");
error_log(print_r($found_cust, true));
if (isset($found_cust['response']['data'][0]['id'])) {
// we found a customer record
$pm_cust = $found_cust['response']['data'][0];
} else {
error_log("CREATING CUSTOMER");
// we create a new customer record
$new_cust = $this->createCustomer($cust, $email);
error_log("NEW CUST RESPONSE");
error_log(print_r($new_cust, true));
if (isset($new_cust['response']['data']['id'])) {
// customer record was created successfully
$pm_cust = $new_cust['response']['data'];
}
}
// NOTE: if $pm_cust is null at this point, an error occurred during customer creation and we check the request logs for more details
return $pm_cust;
}
public function createSubscription($ext_cust_id, $plan_id)
{
$body = [
'data' => [
'attributes' => [
'customer_id' => $ext_cust_id,
'plan_id' => $plan_id,
],
],
];
return $this->doRequest('/v1/subscriptions', 'POST', $body);
}
public function getSubscription($sub_id)
{
return $this->doRequest('/v1/subscriptions/'. $sub_id, 'GET');
}
public function getPaymentIntent($pi_id)
{
return $this->doRequest('/v1/payment_intents/' . $pi_id, 'GET');
}
public function attachPaymentIntent($pm_id, $pi_id)
{
$body = [
'data' => [
'attributes' => [
'payment_method' => $pm_id,
],
],
];
return $this->doRequest('/v1/payment_intents/' . $pi_id . '/attach', 'POST', $body);
}
protected function generateHash($key)
{
return base64_encode($key);
}
protected function buildHeaders($use_public_key = false)
{
$hash = $use_public_key ? $this->public_hash : $this->secret_hash;
return [
'Content-Type' => 'application/json',
'accept' => 'application/json',
'authorization' => 'Basic '. $hash,
];
}
protected function doRequest($url, $method, $request_body = [], $use_public_key = false)
{
$client = new Client();
$headers = $this->buildHeaders($use_public_key);
$request_params = ['headers' => $headers];
if (!empty($request_body)) {
$request_params['json'] = $request_body;
}
try {
$response = $client->request($method, $this->base_url . $url, $request_params);
} catch (RequestException $e) {
$error = ['message' => $e->getMessage()];

View file

@ -71,15 +71,24 @@
<div class="form-control-feedback hide" data-field="tip_other"></div>
</div>
</div>
<div class="form-group m-form__group row no-border">
<label class="col-lg-3 col-form-label" data-field="tip_lazada">
{% trans %}battery_size_tradein_lazada{% endtrans %}
</label>
<div class="col-lg-9">
<input type="text" name="tip_lazada" class="form-control m-input" value="{{ obj.getTIPriceLazada }}">
<div class="form-control-feedback hide" data-field="tip_lazada"></div>
</div>
</div>
<div class="form-group m-form__group row no-border">
<label class="col-lg-3 col-form-label" data-field="tip_lazada">
{% trans %}battery_size_tradein_lazada{% endtrans %}
</label>
<div class="col-lg-9">
<input type="text" name="tip_lazada" class="form-control m-input" value="{{ obj.getTIPriceLazada }}">
<div class="form-control-feedback hide" data-field="tip_lazada"></div>
</div>
</div>
<div class="form-group m-form__group row no-border">
<label class="col-lg-3 col-form-label" data-field="sub_recurring_fee">
{% trans %}battery_Size_sub_recurring_fee{% endtrans %}
</label>
<div class="col-lg-9">
<input type="text" name="sub_recurring_fee" class="form-control m-input" value="{{ obj.getSubRecurringFee }}">
<div class="form-control-feedback hide" data-field="sub_recurring_fee"></div>
</div>
</div>
</div>
<div class="m-portlet__foot m-portlet__foot--fit">
<div class="m-form__actions m-form__actions--solid m-form__actions--right">

View file

@ -39,7 +39,7 @@
<label data-field="id">
ID:
</label>
<input type="text" name="id" class="form-control m-input" value="{{ obj.getID() }}">
<input type="text" name="id" class="form-control m-input" value="{{ obj.getID() }}" {{ mode == 'update' ? 'disabled' }}>
<div class="form-control-feedback hide" data-field="id"></div>
</div>
</div>
@ -48,7 +48,7 @@
<label data-field="content">
Content
</label>
<textarea name="content" class="form-control m-input" data-name="content" rows="50">{{ obj.getContent() }}</textarea>
<textarea id="content" name="content" class="form-control m-input" data-name="content" rows="50">{{ obj.getContent() }}</textarea>
<div class="form-control-feedback hide" data-field="content"></div>
</div>
</div>
@ -70,8 +70,19 @@
</div>
{% endblock %}
{% block stylesheets %}
<link rel="stylesheet" href="https://unpkg.com/easymde/dist/easymde.min.css">
{% endblock %}
{% block scripts %}
<script src="https://unpkg.com/easymde/dist/easymde.min.js"></script>
<script>
// load markdown editor
const mde = new EasyMDE({
element: document.getElementById('content'),
spellChecker: false,
});
$(function() {
$("#row-form").submit(function(e) {
var form = $(this);

View file

@ -9,6 +9,7 @@ battery_size_tradein_brand: Trade-in Motolite
battery_size_tradein_premium: Trade-in Premium
battery_size_tradein_other: Trade-in Other
battery_size_tradein_lazada: Trade-in Lazada
battery_Size_sub_recurring_fee: Subscription Recurring Fee
add_cust_vehicle_battery_info: This vehicle is using a Motolite battery
jo_title_pdf: Motolite Res-Q Job Order
country_code_prefix: '+63'