Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "fleetbase/core-api",
"version": "1.6.52",
"version": "1.6.53",
"description": "Core Framework and Resources for Fleetbase API",
"keywords": [
"fleetbase",
Expand Down
2 changes: 2 additions & 0 deletions config/sms.php
Original file line number Diff line number Diff line change
Expand Up @@ -110,11 +110,13 @@

'custom_http' => [
'enabled' => env('CUSTOM_HTTP_SMS_ENABLED', false),
'method' => env('CUSTOM_HTTP_SMS_METHOD', 'POST'),
'url' => env('CUSTOM_HTTP_SMS_URL', ''),
'from' => env('CUSTOM_HTTP_SMS_FROM', ''),
'auth_header' => env('CUSTOM_HTTP_SMS_AUTH_HEADER', ''),
'auth_token' => env('CUSTOM_HTTP_SMS_AUTH_TOKEN', ''),
'headers' => [],
'query_params' => [],
'body' => [
'to' => '{{to}}',
'text' => '{{text}}',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

use Fleetbase\Models\Transaction;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
public function up(): void
{
Schema::table('transactions', function (Blueprint $table) {
if (!Schema::hasColumn('transactions', 'settlement_status')) {
$table->string('settlement_status', 32)
->default(Transaction::SETTLEMENT_STATUS_UNPAID)
->after('status')
->index('transactions_settlement_status_index');
}
});

DB::table('transactions')
->whereNull('settlement_status')
->update(['settlement_status' => Transaction::SETTLEMENT_STATUS_UNPAID]);

DB::table('transactions')
->where('status', 'completed')
->update(['status' => Transaction::STATUS_SUCCESS]);

DB::table('transactions')
->where('status', 'paid')
->update([
'status' => Transaction::STATUS_SUCCESS,
'settlement_status' => Transaction::SETTLEMENT_STATUS_PAID,
'settled_at' => DB::raw('COALESCE(settled_at, updated_at, created_at)'),
]);

DB::table('transactions')
->whereIn('type', [
Transaction::TYPE_INVOICE_PAYMENT,
Transaction::TYPE_WALLET_DEPOSIT,
Transaction::TYPE_WALLET_WITHDRAWAL,
Transaction::TYPE_WALLET_TRANSFER_IN,
Transaction::TYPE_WALLET_TRANSFER_OUT,
'deposit',
'withdrawal',
'transfer_in',
'transfer_out',
])
->where('status', Transaction::STATUS_SUCCESS)
->where('settlement_status', Transaction::SETTLEMENT_STATUS_UNPAID)
->update([
'settlement_status' => Transaction::SETTLEMENT_STATUS_PAID,
'settled_at' => DB::raw('COALESCE(settled_at, updated_at, created_at)'),
]);
}

public function down(): void
{
Schema::table('transactions', function (Blueprint $table) {
if (Schema::hasColumn('transactions', 'settlement_status')) {
$table->dropIndex('transactions_settlement_status_index');
$table->dropColumn('settlement_status');
}
});
}
};
48 changes: 47 additions & 1 deletion src/Models/Transaction.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ class Transaction extends Model
'type',
'direction',
'status',
'settlement_status',

// Monetary (all in smallest currency unit / cents)
'amount',
Expand Down Expand Up @@ -190,6 +191,16 @@ class Transaction extends Model
public const STATUS_VOIDED = 'voided';
public const STATUS_EXPIRED = 'expired';

// =========================================================================
// Settlement Status Constants
// =========================================================================

public const SETTLEMENT_STATUS_UNPAID = 'unpaid';
public const SETTLEMENT_STATUS_PARTIALLY_PAID = 'partially_paid';
public const SETTLEMENT_STATUS_PAID = 'paid';
public const SETTLEMENT_STATUS_PARTIALLY_REFUNDED = 'partially_refunded';
public const SETTLEMENT_STATUS_REFUNDED = 'refunded';

// =========================================================================
// Type Constants — Platform-wide taxonomy
// =========================================================================
Expand Down Expand Up @@ -352,6 +363,14 @@ public function scopeFailed($query)
return $query->where('status', self::STATUS_FAILED);
}

/**
* Scope to paid or otherwise settled transactions.
*/
public function scopeSettled($query)
{
return $query->where('settlement_status', self::SETTLEMENT_STATUS_PAID);
}

/**
* Scope to a specific transaction type.
*/
Expand Down Expand Up @@ -485,7 +504,34 @@ public function isReversed(): bool
*/
public function isSettled(): bool
{
return $this->settled_at !== null;
return $this->settlement_status === self::SETTLEMENT_STATUS_PAID || $this->settled_at !== null;
}

/**
* Whether this transaction has not been settled.
*/
public function isUnpaid(): bool
{
return $this->settlement_status === self::SETTLEMENT_STATUS_UNPAID;
}

/**
* Whether this transaction has been partially settled.
*/
public function isPartiallyPaid(): bool
{
return $this->settlement_status === self::SETTLEMENT_STATUS_PARTIALLY_PAID;
}

/**
* Whether this transaction has been partially or fully refunded.
*/
public function isRefunded(): bool
{
return in_array($this->settlement_status, [
self::SETTLEMENT_STATUS_PARTIALLY_REFUNDED,
self::SETTLEMENT_STATUS_REFUNDED,
], true);
}

/**
Expand Down
24 changes: 23 additions & 1 deletion src/Services/CustomHttpSmsService.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ public function send(string $to, string $text, ?string $from = null, array $opti
];

$url = $this->renderTemplate((string) data_get($this->config, 'url'), $variables);
$method = strtoupper((string) data_get($this->config, 'method', 'POST'));
$headers = $this->renderTemplateValues((array) data_get($this->config, 'headers', []), $variables);
$queryParams = $this->renderTemplateValues((array) data_get($this->config, 'query_params', []), $variables);
$body = $this->renderTemplateValues((array) data_get($this->config, 'body', [
'to' => '{{to}}',
'text' => '{{text}}',
Expand All @@ -43,7 +45,12 @@ public function send(string $to, string $text, ?string $from = null, array $opti

Log::info('Sending SMS via custom HTTP gateway', ['to' => $to, 'url' => $url]);

$response = Http::withHeaders($headers)->asJson()->post($url, $body);
$request = Http::withHeaders($headers);
$response = match ($method) {
'GET' => $request->get($url, $queryParams),
'POST' => $request->asJson()->post($this->appendQueryParams($url, $queryParams), $body),
default => throw new \InvalidArgumentException("Unsupported custom HTTP SMS method: {$method}"),
};
$payload = $response->json();

if ($response->successful()) {
Expand Down Expand Up @@ -82,6 +89,11 @@ protected function validateParameters(string $to, string $text): void
if (empty($text)) {
throw new \InvalidArgumentException('Message text cannot be empty');
}

$method = strtoupper((string) data_get($this->config, 'method', 'POST'));
if (!in_array($method, ['GET', 'POST'], true)) {
throw new \InvalidArgumentException('Custom HTTP SMS method must be GET or POST');
}
}

protected function renderTemplateValues(array $values, array $variables): array
Expand All @@ -105,4 +117,14 @@ protected function renderTemplate(string $template, array $variables): string

return $template;
}

protected function appendQueryParams(string $url, array $queryParams = []): string
{
$queryParams = array_filter($queryParams, static fn ($value) => $value !== null && $value !== '');
if (empty($queryParams)) {
return $url;
}

return $url . (str_contains($url, '?') ? '&' : '?') . http_build_query($queryParams);
}
}
1 change: 1 addition & 0 deletions src/Support/EnvironmentMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ class EnvironmentMapper
'SMPP_PASSWORD' => 'services.sms.providers.smpp.password',
'SMPP_SOURCE_ADDR' => 'services.sms.providers.smpp.source_addr',
'CUSTOM_HTTP_SMS_URL' => 'services.sms.providers.custom_http.url',
'CUSTOM_HTTP_SMS_METHOD' => 'services.sms.providers.custom_http.method',
'CUSTOM_HTTP_SMS_AUTH_HEADER' => 'services.sms.providers.custom_http.auth_header',
'CUSTOM_HTTP_SMS_AUTH_TOKEN' => 'services.sms.providers.custom_http.auth_token',
'GOOGLE_MAPS_API_KEY' => 'services.google_maps.api_key',
Expand Down
71 changes: 69 additions & 2 deletions tests/Unit/MultiProviderSmsServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,19 @@
use Illuminate\Support\Facades\Http;
use Psr\Log\NullLogger;

if (!function_exists('config')) {
function config($key = null, $default = null)
{
$config = Container::getInstance()->make('config');

if ($key === null) {
return $config;
}

return $config->get($key, $default);
}
}

beforeEach(function () {
$app = new Container();

Expand Down Expand Up @@ -55,13 +68,15 @@
'source_addr' => 'FLEETBASE',
],
'custom_http' => [
'method' => 'POST',
'url' => 'https://sms-gateway.test/send',
'from' => 'Fleetbase',
'auth_header' => 'Authorization',
'auth_token' => 'Bearer token',
'headers' => [
'X-Tenant' => 'fleetbase',
],
'query_params' => [],
'body' => [
'recipient' => '{{to}}',
'message' => '{{text}}',
Expand Down Expand Up @@ -150,7 +165,7 @@
});
});

test('custom http sms service renders configured templates', function () {
test('custom http sms service renders configured post templates', function () {
Http::fake([
'https://sms-gateway.test/send' => Http::response([
'message_id' => 'custom-message-id',
Expand All @@ -169,7 +184,8 @@
]);

Http::assertSent(function ($request) {
return $request->url() === 'https://sms-gateway.test/send'
return $request->method() === 'POST'
&& $request->url() === 'https://sms-gateway.test/send'
&& $request->hasHeader('Authorization', 'Bearer token')
&& $request->hasHeader('X-Tenant', 'fleetbase')
&& $request['recipient'] === '+15551234567'
Expand All @@ -179,6 +195,57 @@
});
});

test('custom http sms service supports get method with rendered query params', function () {
Http::fake([
'https://sms-gateway.test/send*' => Http::response([
'message_id' => 'custom-get-message-id',
'status' => 'queued',
], 200),
]);

$result = (new CustomHttpSmsService([
'method' => 'GET',
'url' => 'https://sms-gateway.test/send',
'from' => 'Fleetbase',
'auth_header' => 'Authorization',
'auth_token' => 'Bearer {{unique_id}}',
'headers' => [
'X-Recipient' => '{{to}}',
],
'query_params' => [
'recipient' => '{{to}}',
'message' => '{{text}}',
'sender' => '{{from}}',
'reference' => '{{unique_id}}',
],
'body' => [
'should_not_send' => '{{text}}',
],
]))->send('+15551234567', 'Hello', null, [
'unique_id' => 'custom-get-123',
]);

expect($result)->toMatchArray([
'success' => true,
'message_id' => 'custom-get-message-id',
'status' => 'queued',
]);

Http::assertSent(function ($request) {
parse_str((string) parse_url($request->url(), PHP_URL_QUERY), $query);

return $request->method() === 'GET'
&& str_starts_with($request->url(), 'https://sms-gateway.test/send?')
&& $request->hasHeader('Authorization', 'Bearer custom-get-123')
&& $request->hasHeader('X-Recipient', '+15551234567')
&& $query['recipient'] === '+15551234567'
&& $query['message'] === 'Hello'
&& $query['sender'] === 'Fleetbase'
&& $query['reference'] === 'custom-get-123'
&& !isset($request['should_not_send']);
});
});

test('aws sns sms service publishes to phone number', function () {
$mock = new MockHandler();
$mock->append(new Result(['MessageId' => 'sns-message-id']));
Expand Down
Loading