Reapply "Add Blockonomics payment capabilities"

This reverts commit 0775299548.
This commit is contained in:
Shivansh Yadav 2025-03-31 13:21:42 +05:30
parent 3b1d033a64
commit f4d0d49e97
9 changed files with 724 additions and 2 deletions

View File

@ -0,0 +1,58 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Http\Controllers\Gateways;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request; // Import the Request class
use Illuminate\Support\Facades\Http; // Import the Http facade
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
use BaconQrCode\Renderer\ImageRenderer;
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
use BaconQrCode\Writer;
class BlockonomicsController extends Controller
{
public function getBTCPrice(Request $request)
{
$currency = $request->query('currency');
$response = Http::get("https://www.blockonomics.co/api/price?currency={$currency}");
if ($response->successful()) {
return response()->json(['price' => $response->json('price')]);
}
return response()->json(['error' => 'Unable to fetch BTC price'], 500);
}
public function getQRCode(Request $request)
{
$qr_string = $request->query('qr_string');
$svg = $this->getPaymentQrCodeRaw($qr_string);
return response($svg)->header('Content-Type', 'image/svg+xml');
}
private function getPaymentQrCodeRaw($qr_string)
{
$renderer = new ImageRenderer(
new RendererStyle(150, margin: 0),
new SvgImageBackEnd()
);
$writer = new Writer($renderer);
$qr = $writer->writeString($qr_string, 'utf-8');
return $qr;
}
}

View File

@ -0,0 +1,173 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\PaymentDrivers\Blockonomics;
use App\Models\Payment;
use App\Models\SystemLog;
use App\Models\GatewayType;
use App\Models\PaymentType;
use App\Jobs\Util\SystemLogger;
use App\Utils\Traits\MakesHash;
use App\Exceptions\PaymentFailed;
use Illuminate\Support\Facades\Http;
use App\Jobs\Mail\PaymentFailureMailer;
use App\PaymentDrivers\Common\MethodInterface;
use App\PaymentDrivers\BlockonomicsPaymentDriver;
use App\PaymentDrivers\Common\LivewireMethodInterface;
use App\Http\Requests\ClientPortal\Payments\PaymentResponseRequest;
class Blockonomics implements LivewireMethodInterface
{
use MakesHash;
public function __construct(public BlockonomicsPaymentDriver $blockonomics)
{
}
public function authorizeView($data)
{
}
public function authorizeRequest($request)
{
}
public function authorizeResponse($request)
{
}
public function getBTCAddress(): array
{
$api_key = $this->blockonomics->company_gateway->getConfigField('apiKey');
if (!$api_key) {
return ['success' => false, 'message' => 'Please enter a valid API key'];
}
// $params = config('ninja.environment') == 'development' ? '?reset=1' : '';
$url = 'https://www.blockonomics.co/api/new_address';
$response = Http::withToken($api_key)
->post($url, []);
nlog($response->body());
if ($response->status() == 401) {
return ['success' => false, 'message' => 'API Key is incorrect'];
};
if ($response->successful()) {
if (isset($response->object()->address)) {
return ['success' => true, 'address' => $response->object()->address];
} else {
return ['success' => false, 'message' => 'Address not returned'];
}
} else {
return ['success' => false, 'message' => "Could not generate new address (This may be a temporary error. Please try again). \n\n<br><br> If this continues, please ask website administrator to check blockonomics registered email address for error messages"];
}
}
public function getBTCPrice()
{
$r = Http::get('https://www.blockonomics.co/api/price', ['currency' => $this->blockonomics->client->getCurrencyCode()]);
return $r->successful() ? $r->object()->price : 'Something went wrong';
}
public function paymentData(array $data): array
{
$btc_price = $this->getBTCPrice();
$btc_address = $this->getBTCAddress();
$data['error'] = null;
if (!$btc_address['success']) {
$data['error'] = $btc_address['message'];
}
$fiat_amount = $data['total']['amount_with_fee'];
$btc_amount = $fiat_amount / $btc_price;
$_invoice = collect($this->blockonomics->payment_hash->data->invoices)->first();
$data['gateway'] = $this->blockonomics;
$data['company_gateway_id'] = $this->blockonomics->getCompanyGatewayId();
$data['amount'] = $fiat_amount;
$data['currency'] = $this->blockonomics->client->getCurrencyCode();
$data['btc_amount'] = number_format($btc_amount, 10, '.', '');
$data['btc_address'] = $btc_address['address'] ?? '';
$data['btc_price'] = $btc_price;
$data['invoice_number'] = $_invoice->invoice_number;
return $data;
}
public function livewirePaymentView(array $data): string
{
return 'gateways.blockonomics.pay_livewire';
}
public function paymentView($data)
{
$data = $this->paymentData($data);
return render('gateways.blockonomics.pay', $data);
}
public function paymentResponse(PaymentResponseRequest $request)
{
$request->validate([
'payment_hash' => ['required'],
'amount' => ['required'],
'currency' => ['required'],
'txid' => ['required'],
'payment_method_id' => ['required'],
]);
try {
$data = [];
$fiat_amount = round(($request->btc_price * $request->btc_amount), 2);
$data['amount'] = $fiat_amount;
$data['currency'] = $request->currency;
$data['payment_method_id'] = $request->payment_method_id;
$data['payment_type'] = PaymentType::CRYPTO;
$data['gateway_type_id'] = GatewayType::CRYPTO;
$data['transaction_reference'] = $request->txid;
$statusId = Payment::STATUS_PENDING;
$payment = $this->blockonomics->createPayment($data, $statusId);
SystemLogger::dispatch(
['response' => $payment, 'data' => $data],
SystemLog::CATEGORY_GATEWAY_RESPONSE,
SystemLog::EVENT_GATEWAY_SUCCESS,
SystemLog::TYPE_BLOCKONOMICS,
$this->blockonomics->client,
$this->blockonomics->client->company,
);
return redirect()->route('client.payments.show', ['payment' => $payment->hashed_id]);
} catch (\Throwable $e) {
$blockonomics = $this->blockonomics;
PaymentFailureMailer::dispatch($blockonomics->client, $blockonomics->payment_hash->data, $blockonomics->client->company, $request->amount);
throw new PaymentFailed('Error during Blockonomics payment : ' . $e->getMessage());
}
}
// Not supported yet
public function refund(Payment $payment, $amount)
{
return;
}
}

View File

@ -0,0 +1,238 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://opensource.org/licenses/AAL
*/
namespace App\PaymentDrivers;
use App\Models\Client;
use App\Models\Gateway;
use App\Models\Invoice;
use App\Models\Payment;
use App\Models\SystemLog;
use App\Models\GatewayType;
use App\Models\PaymentHash;
use App\Models\PaymentType;
use App\Utils\Traits\MakesHash;
use App\Exceptions\PaymentFailed;
use Illuminate\Support\Facades\Http;
use App\PaymentDrivers\Blockonomics\Blockonomics;
use App\Http\Requests\Payments\PaymentWebhookRequest;
class BlockonomicsPaymentDriver extends BaseDriver
{
use MakesHash;
public $refundable = false; //does this gateway support refunds?
public $token_billing = false; //does this gateway support token billing?
public $can_authorise_credit_card = false; //does this gateway support authorizations?
public $gateway; //initialized gateway
public $payment_method; //initialized payment method
public static $methods = [
GatewayType::CRYPTO => Blockonomics::class, //maps GatewayType => Implementation class
];
public const SYSTEM_LOG_TYPE = SystemLog::TYPE_BLOCKONOMICS; //define a constant for your gateway ie TYPE_YOUR_CUSTOM_GATEWAY - set the const in the SystemLog model
public $BASE_URL = 'https://www.blockonomics.co';
public $NEW_ADDRESS_URL = 'https://www.blockonomics.co/api/new_address';
public $PRICE_URL = 'https://www.blockonomics.co/api/price';
public $STORES_URL = 'https://www.blockonomics.co/api/v2/stores';
public function init()
{
return $this; /* This is where you boot the gateway with your auth credentials*/
}
/* Returns an array of gateway types for the payment gateway */
public function gatewayTypes(): array
{
$types = [];
$types[] = GatewayType::CRYPTO;
return $types;
}
public function setPaymentMethod($payment_method_id)
{
$class = self::$methods[$payment_method_id];
$this->payment_method = new $class($this);
return $this;
}
public function processPaymentView(array $data)
{
$this->init();
return $this->payment_method->paymentView($data); //this is your custom implementation from here
}
public function processPaymentResponse($request)
{
$this->init();
return $this->payment_method->paymentResponse($request);
}
public function processWebhookRequest(PaymentWebhookRequest $request)
{
$company = $request->getCompany();
// Re-introduce secret in a later stage if needed.
// $url_callback_secret = $request->secret;
// $db_callback_secret = $this->company_gateway->getConfigField('callbackSecret');
// if ($url_callback_secret != $db_callback_secret) {
// throw new PaymentFailed('Secret does not match');
// }
$txid = $request->txid;
$value = $request->value;
$status = $request->status;
$addr = $request->addr;
$payment = Payment::query()
->where('company_id', $company->id)
->where('transaction_reference', $txid)
->firstOrFail();
if (!$payment) {
return response()->json([], 200);
// TODO: Implement logic to create new payment in case user sends payment to the address after closing the payment page
}
$statusId = Payment::STATUS_PENDING;
switch ($status) {
case 0:
$statusId = Payment::STATUS_PENDING;
break;
case 1:
$statusId = Payment::STATUS_PENDING;
break;
case 2:
$statusId = Payment::STATUS_COMPLETED;
break;
}
if ($payment->status_id == $statusId) {
return response()->json([], 200);
} else {
$payment->status_id = $statusId;
$payment->save();
return response()->json([], 200);
}
}
public function refund(Payment $payment, $amount, $return_client_response = false)
{
$this->setPaymentMethod(GatewayType::CRYPTO);
return $this->payment_method->refund($payment, $amount); //this is your custom implementation from here
}
public function testNewAddressGen($crypto = 'btc', $response): string
{
$api_key = $this->company_gateway->getConfigField('apiKey');
$new_address_reset_url = $this->NEW_ADDRESS_URL . '?reset=1';
$new_address_response = Http::withToken($api_key)
->post($new_address_reset_url, []);
if ($new_address_response->response_code != 200) {
return isset($new_address_response->response_message) && $new_address_response->response_message
? $new_address_response->response_message
: 'Could not generate new address';
}
if (empty($new_address_response->address)) {
return 'No address returned from Blockonomics API';
}
return 'ok';
}
public function checkStores($stores): string
{
if (empty($stores['data'])) {
return "Please add a store to your Blockonomics' account";
}
$invoice_ninja_callback_url = $this->company_gateway->webhookUrl();
$matching_store = null;
$store_without_callback = null;
$partial_match_store = null;
foreach ($stores['data'] as $store) {
if ($store['http_callback'] === $invoice_ninja_callback_url) {
$matching_store = $store;
break;
}
if (empty($store['http_callback'])) {
$store_without_callback = $store;
continue;
}
// Check for partial match - only secret or protocol differs
// TODO: Implement logic for updating partial matches
$store_base_url = preg_replace('/https?:\/\//', '', $store['http_callback']);
if (strpos($store_base_url, $invoice_ninja_callback_url) === 0) {
$partial_match_store = $store;
}
}
if ($matching_store) {
$matching_store_wallet = $matching_store['wallets'];
if (empty($matching_store_wallet)) {
return 'Please add a wallet to your Blockonomics store';
}
return 'ok';
}
return "No callback URL from your Blockonomics stores matches your Invoice Ninja webhook";
}
public function auth(): string
{
try {
$api_key = $this->company_gateway->getConfigField('apiKey');
if(!$api_key) {
return 'No API Key';
}
$get_stores_response = Http::withToken($api_key)
->get($this->STORES_URL, ['wallets' => 'true']);
$get_stores_response_status = $get_stores_response->status();
if($get_stores_response_status == 401) {
return 'API Key is incorrect';
}
if (!$get_stores_response || $get_stores_response_status !== 200) {
return 'Could not connect to Blockonomics API';
}
$stores = $get_stores_response->json();
$stores_check_result = $this->checkStores($stores);
return $stores_check_result;
} catch (\Exception $e) {
return $e->getMessage();
}
}
}

View File

@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use App\Models\Gateway;
use App\Models\GatewayType;
use Illuminate\Support\Str;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if(!Gateway::find(65))
{
$fields = new \stdClass;
$fields->apiKey = "";
$fields->callbackSecret = "";
$gateway = new Gateway;
$gateway->id = 65;
$gateway->name = 'Blockonomics';
$gateway->key = 'wbhf02us6owgo7p4nfjd0ymssdshks4d';
$gateway->provider = 'Blockonomics';
$gateway->is_offsite = false;
$gateway->fields = \json_encode($fields);
$gateway->visible = true;
$gateway->site_url = 'https://blockonomics.co';
$gateway->default_gateway_type_id = GatewayType::CRYPTO;
$gateway->save();
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
//
}
};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,189 @@
@extends('portal.ninja2020.layout.payments', ['gateway_title' => ctrans('texts.payment_type_Crypto'), 'card_title' => ctrans('texts.payment_type_Crypto')])
@section('gateway_head')
<meta name="instant-payment" content="yes" />
<meta name="amount" content="{{ $amount }}" />
<meta name="btc_amount" content="{{ $btc_amount }}" />
<meta name="btc_address" content="{{ $btc_address }}" />
<meta name="currency" content="{{ $currency }}" />
@endsection
@section('gateway_content')
<div class="alert alert-failure mb-4" hidden id="errors"></div>
<div class="blockonomics-payment-wrapper">
<div class="initial-state">
<div class="invoice-info-wrapper">
<div class="invoice-number">Invoice #{{$invoice_number}}</div>
<div class="invoice-amount">{{$amount}} {{$currency}}</div>
</div>
<div class="sections-wrapper">
<div class="scan-section">
<div class="title">Scan</div>
<span class="input-wrapper">
<a href="bitcoin:{{$btc_address}}?amount={{$btc_amount}}" id="qr-code-link" target="_blank">
<div id="qrcode-container"></div>
</a>
</span>
<a href="bitcoin:{{$btc_address}}?amount={{$btc_amount}}" target="_blank" id="open-in-wallet-link">Open in Wallet</a>
</div>
<div class="copy-section">
<div class="title">Copy</div>
<span>To pay, send bitcoin to this address:</span>
<span class="input-wrapper">
<input onclick='copyToClipboard("btc-address", this, true)' class="full-width-input" id="btc-address" value="{{$btc_address}}" readonly>
<img onclick='copyToClipboard("btc-address", this)' src="{{ 'data:image/svg+xml;base64,' . base64_encode('<svg width="22" height="24" viewBox="0 0 22 24" fill="none" xmlns="http://www.w3.org/2000/svg" ><path d="M15.5 1H3.5C2.4 1 1.5 1.9 1.5 3V17H3.5V3H15.5V1ZM18.5 5H7.5C6.4 5 5.5 5.9 5.5 7V21C5.5 22.1 6.4 23 7.5 23H18.5C19.6 23 20.5 22.1 20.5 21V7C20.5 5.9 19.6 5 18.5 5ZM18.5 21H7.5V7H18.5V21Z" fill="#000"/></svg>') }}" class="icon" alt="Copy Icon">
</span>
<span>Amount of bitcoin (BTC) to send:</span>
<span class="input-wrapper">
<div class="full-width-input" id="btc-amount" onclick='copyToClipboard("btc-amount", this, true)'>
{{$btc_amount}}
</div>
<img onclick='copyToClipboard("btc-amount", this)' src="{{ 'data:image/svg+xml;base64,' . base64_encode('<svg width="22" height="24" viewBox="0 0 22 24" fill="none" xmlns="http://www.w3.org/2000/svg" ><path d="M15.5 1H3.5C2.4 1 1.5 1.9 1.5 3V17H3.5V3H15.5V1ZM18.5 5H7.5C6.4 5 5.5 5.9 5.5 7V21C5.5 22.1 6.4 23 7.5 23H18.5C19.6 23 20.5 22.1 20.5 21V7C20.5 5.9 19.6 5 18.5 5ZM18.5 21H7.5V7H18.5V21Z" fill="#000"/></svg>') }}" class="icon" alt="Copy Icon">
</span>
<div class="btc-value-wrapper">
<div class="btc-value">1 BTC = {{$btc_price}} {{$currency}}, updates in <span id="countdown"></span></div>
<span class="icon-refresh" onclick='refreshBTCPrice()'></span>
</div>
</div>
</div>
</div>
<form action="{{ route('client.payments.response') }}" method="post" id="server-response">
@csrf
<input type="hidden" name="gateway_response">
<input type="hidden" name="company_gateway_id" value="{{ $company_gateway_id }}">
<input type="hidden" name="payment_method_id" value="{{ $payment_method_id }}">
<input type="hidden" name="token">
<input type="hidden" name="amount" value="{{ $amount }}">
<input type="hidden" name="btc_price" value="{{ $btc_price }}">
<input type="hidden" name="btc_amount" value="{{ $btc_amount }}">
<input type="hidden" name="currency" value="{{ $currency }}">
<input type="hidden" name="payment_hash" value="{{ $payment_hash }}">
<input type="hidden" name="txid" value="">
</form>
<style type="text/css">
.sections-wrapper {
display: flex;
flex-direction: row;
justify-content: space-around;
/* Mobile devices */
@media (max-width: 768px) {
flex-direction: column; /* Change to column on smaller screens */
}
}
.copy-section {
width: 60%;
@media (max-width: 768px) {
width: 100%; /* Full width on smaller screens */
}
}
.title {
font-size: 17px;
font-weight: bold;
margin-bottom: 6px;
}
#open-in-wallet-link {
text-align: center;
text-decoration: underline;
width: 100%;
justify-content: center;
display: flex;
margin-top: 10px;
margin-bottom: 20px;
&:hover {
text-decoration: none;
}
}
.invoice-info-wrapper {
width: 100%;
text-transform: uppercase;
margin-bottom: 20px;
display: flex;
justify-content: space-between;
}
.invoice-number {
width: 50%;
float: left;
text-align: left;
}
.invoice-amount {
width: 50%;
float: right;
text-align: right;
text-transform: uppercase;
margin-bottom: 20px;
}
.blockonomics-payment-wrapper {
display: flex;
justify-content: center;
width: 100%;
}
.initial-state {
justify-content: center;
display: flex;
flex-direction: column;
width: 100%;
padding: 24px;
}
.input-wrapper {
display: flex;
justify-content: center;
align-items: center;
flex-direction: row;
width: 100%;
margin-bottom: 10px;
}
.full-width-input {
width: 100%;
max-width: 400px;
padding: 10px;
text-align: left;
border: 1px solid #ccc;
border-radius: 5px;
color: #444;
cursor: pointer;
position: relative;
}
.icon {
cursor: pointer;
width: 28px;
margin-left: 5px;
}
.icon-refresh::before {
content: '\27F3';
cursor: pointer;
margin-left: 5px;
width: 28px;
display: flex;
font-size: 32px;
margin-bottom: 5px;
}
.btc-value {
font-size: 14px;
text-align: center;
}
.btc-value-wrapper {
display: flex;
justify-content: center;
align-items: center;
flex-direction: row;
}
@keyframes rotating {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.rotating {
animation: rotating 2s linear infinite;
}
</style>
@endsection
@section('gateway_footer')
@vite('resources/js/clients/payments/blockonomics.js')
@endsection

View File

@ -103,6 +103,7 @@ use App\Http\Controllers\Reports\ClientReportController;
use App\Http\Controllers\Reports\CreditReportController;
use App\Http\Controllers\Reports\ReportExportController;
use App\Http\Controllers\Reports\VendorReportController;
use App\Http\Controllers\Gateways\BlockonomicsController;
use App\Http\Controllers\Reports\ExpenseReportController;
use App\Http\Controllers\Reports\InvoiceReportController;
use App\Http\Controllers\Reports\PaymentReportController;
@ -491,6 +492,8 @@ Route::post('api/v1/yodlee/balance', [YodleeController::class, 'balanceWebhook']
Route::get('api/v1/protected_download/{hash}', [ProtectedDownloadController::class, 'index'])->name('protected_download')->middleware('throttle:300,1');
Route::post('api/v1/ppcp/webhook', [PayPalPPCPPaymentDriver::class, 'processWebhookRequest'])->middleware('throttle:1000,1');
Route::get('api/v1/get-btc-price', [BlockonomicsController::class, 'getBTCPrice'])->middleware('throttle:1000,1');
Route::get('api/v1/get-blockonomics-qr-code', [BlockonomicsController::class, 'getQRCode'])->middleware('throttle:1000,1');
Route::get('quickbooks/authorize/{token}', [ImportQuickbooksController::class, 'authorizeQuickbooks'])->name('quickbooks.authorize');
Route::get('quickbooks/authorized', [ImportQuickbooksController::class, 'onAuthorized'])->name('quickbooks.authorized');

View File

@ -5622,5 +5622,23 @@
"GET",
"HEAD"
]
},
"generated::Xy7ZkLm8NpQ4Rt5V": {
"name": "generated::Xy7ZkLm8NpQ4Rt5V",
"domain": null,
"action": "App\\Http\\Controllers\\BlockonomicsController@getBTCPrice",
"uri": "api/v1/get-btc-price",
"method": [
"GET"
]
},
"generated::Jk2MnOp3QrS6Tu9W": {
"name": "generated::Jk2MnOp3QrS6Tu9W",
"domain": null,
"action": "App\\Http\\Controllers\\BlockonomicsController@getQRCode",
"uri": "api/v1/get-blockonomics-qr-code",
"method": [
"GET"
]
}
}