Page MenuHomePhorge

No OneTemporary

This file is larger than 256 KB, so syntax highlighting was skipped.
diff --git a/src/app/Console/Commands/Data/Stats/CollectorCommand.php b/src/app/Console/Commands/Data/Stats/CollectorCommand.php
index 2874ebe3..d65ddca1 100644
--- a/src/app/Console/Commands/Data/Stats/CollectorCommand.php
+++ b/src/app/Console/Commands/Data/Stats/CollectorCommand.php
@@ -1,83 +1,83 @@
<?php
namespace App\Console\Commands\Data\Stats;
use App\Http\Controllers\API\V4\Admin\StatsController;
-use App\Providers\PaymentProvider;
+use App\Payment;
use App\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class CollectorCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'data:stats:collector';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Collects statictical data about the system (for charts)';
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$this->collectPayersCount();
}
/**
* Collect current payers count
*/
protected function collectPayersCount(): void
{
$tenant_id = \config('app.tenant_id');
// A subquery to get the all wallets with a successful payment
$payments = DB::table('payments')
->selectRaw('distinct wallet_id')
- ->where('status', PaymentProvider::STATUS_PAID);
+ ->where('status', Payment::STATUS_PAID);
// A subquery to get users' wallets (by entitlement) - one record per user
$wallets = DB::table('entitlements')
->selectRaw("min(wallet_id) as id, entitleable_id as user_id")
->where('entitleable_type', User::class)
->groupBy('entitleable_id');
// Count all non-degraded and non-deleted users with any successful payment
$count = DB::table('users')
->joinSub($wallets, 'wallets', function ($join) {
$join->on('users.id', '=', 'wallets.user_id');
})
->joinSub($payments, 'payments', function ($join) {
$join->on('wallets.id', '=', 'payments.wallet_id');
})
->whereNull('users.deleted_at')
->whereNot('users.status', '&', User::STATUS_DEGRADED)
->whereNot('users.status', '&', User::STATUS_SUSPENDED);
if ($tenant_id) {
$count->where('users.tenant_id', $tenant_id);
} else {
$count->whereNull('users.tenant_id');
}
$count = $count->count();
if ($count) {
DB::table('stats')->insert([
'tenant_id' => $tenant_id,
'type' => StatsController::TYPE_PAYERS,
'value' => $count,
]);
}
}
}
diff --git a/src/app/Documents/Receipt.php b/src/app/Documents/Receipt.php
index 14f48ad8..8f2479e2 100644
--- a/src/app/Documents/Receipt.php
+++ b/src/app/Documents/Receipt.php
@@ -1,278 +1,277 @@
<?php
namespace App\Documents;
use App\Payment;
-use App\Providers\PaymentProvider;
use App\User;
use App\Wallet;
use Barryvdh\DomPDF\Facade\Pdf;
use Carbon\Carbon;
class Receipt
{
/** @var \App\Wallet The wallet */
protected $wallet;
/** @var int Transactions date year */
protected $year;
/** @var int Transactions date month */
protected $month;
/** @var bool Enable fake data mode */
protected static $fakeMode = false;
/**
* Document constructor.
*
* @param \App\Wallet $wallet A wallet containing transactions
* @param int $year A year to list transactions from
* @param int $month A month to list transactions from
*
* @return void
*/
public function __construct(Wallet $wallet, int $year, int $month)
{
$this->wallet = $wallet;
$this->year = $year;
$this->month = $month;
}
/**
* Render the mail template with fake data
*
* @param string $type Output format ('html' or 'pdf')
*
* @return string HTML or PDF output
*/
public static function fakeRender(string $type = 'html'): string
{
$wallet = new Wallet(['currency' => 'CHF']);
$wallet->id = \App\Utils::uuidStr();
$wallet->owner = new User(['id' => 123456789]);
$receipt = new self($wallet, date('Y'), date('n'));
self::$fakeMode = true;
if ($type == 'pdf') {
return $receipt->pdfOutput();
} elseif ($type !== 'html') {
throw new \Exception("Unsupported output format");
}
return $receipt->htmlOutput();
}
/**
* Render the receipt in HTML format.
*
* @return string HTML content
*/
public function htmlOutput(): string
{
return $this->build()->render();
}
/**
* Render the receipt in PDF format.
*
* @return string PDF content
*/
public function pdfOutput(): string
{
// Parse ther HTML template
$html = $this->build()->render();
// Link fonts from public/fonts to storage/fonts so DomPdf can find them
if (!is_link(storage_path('fonts/Roboto-Regular.ttf'))) {
symlink(
public_path('fonts/Roboto-Regular.ttf'),
storage_path('fonts/Roboto-Regular.ttf')
);
symlink(
public_path('fonts/Roboto-Bold.ttf'),
storage_path('fonts/Roboto-Bold.ttf')
);
}
// Fix font and image paths
$html = str_replace('url(/fonts/', 'url(fonts/', $html);
$html = str_replace('src="/', 'src="', $html);
// TODO: The output file is about ~200KB, we could probably slim it down
// by using separate font files with small subset of languages when
// there are no Unicode characters used, e.g. only ASCII or Latin.
// Load PDF generator
$pdf = Pdf::loadHTML($html)->setPaper('a4', 'portrait');
return $pdf->output();
}
/**
* Build the document
*
* @return \Illuminate\View\View The template object
*/
protected function build()
{
$appName = \config('app.name');
$start = Carbon::create($this->year, $this->month, 1, 0, 0, 0);
$end = $start->copy()->endOfMonth();
$month = \trans('documents.month' . intval($this->month));
$title = \trans('documents.receipt-title', ['year' => $this->year, 'month' => $month]);
$company = $this->companyData();
if (self::$fakeMode) {
$customer = [
'id' => $this->wallet->owner->id,
'wallet_id' => $this->wallet->id,
'customer' => 'Freddie Krüger<br>7252 Westminster Lane<br>Forest Hills, NY 11375',
];
$items = collect([
(object) [
'amount' => 1234,
'updated_at' => $start->copy()->next(Carbon::MONDAY),
],
(object) [
'amount' => 10000,
'updated_at' => $start->copy()->next()->next(),
],
(object) [
'amount' => 1234,
'updated_at' => $start->copy()->next()->next()->next(Carbon::MONDAY),
],
(object) [
'amount' => 99,
'updated_at' => $start->copy()->next()->next()->next(),
],
]);
$items = $items->map(function ($payment) {
$payment->vatRate = new \App\VatRate();
$payment->vatRate->rate = 7.7;
$payment->credit_amount = $payment->amount + round($payment->amount * $payment->vatRate->rate / 100);
return $payment;
});
} else {
$customer = $this->customerData();
$items = $this->wallet->payments()
- ->where('status', PaymentProvider::STATUS_PAID)
+ ->where('status', Payment::STATUS_PAID)
->where('updated_at', '>=', $start)
->where('updated_at', '<', $end)
->where('amount', '<>', 0)
->orderBy('updated_at')
->get();
}
$vatRate = 0;
$totalVat = 0;
$total = 0; // excluding VAT
$items = $items->map(function ($item) use (&$total, &$totalVat, &$vatRate, $appName) {
$amount = $item->amount;
if ($item->vatRate && $item->vatRate->rate > 0) {
$vat = round($item->credit_amount * $item->vatRate->rate / 100);
$amount -= $vat;
$totalVat += $vat;
$vatRate = $item->vatRate->rate; // TODO: Multiple rates
}
$total += $amount;
$type = $item->type ?? null;
- if ($type == PaymentProvider::TYPE_REFUND) {
+ if ($type == Payment::TYPE_REFUND) {
$description = \trans('documents.receipt-refund');
- } elseif ($type == PaymentProvider::TYPE_CHARGEBACK) {
+ } elseif ($type == Payment::TYPE_CHARGEBACK) {
$description = \trans('documents.receipt-chargeback');
} else {
$description = \trans('documents.receipt-item-desc', ['site' => $appName]);
}
return [
'amount' => $this->wallet->money($amount),
'description' => $description,
'date' => $item->updated_at->toDateString(),
];
});
// Load the template
$view = view('documents.receipt')
->with([
'site' => $appName,
'title' => $title,
'company' => $company,
'customer' => $customer,
'items' => $items,
'subTotal' => $this->wallet->money($total),
'total' => $this->wallet->money($total + $totalVat),
'totalVat' => $this->wallet->money($totalVat),
'vatRate' => preg_replace('/([.,]00|0|[.,])$/', '', sprintf('%.2f', $vatRate)),
'vat' => $vatRate > 0,
]);
return $view;
}
/**
* Prepare customer data for the template
*
* @return array Customer data for the template
*/
protected function customerData(): array
{
$user = $this->wallet->owner;
$name = $user->name();
$settings = $user->getSettings(['organization', 'billing_address']);
$customer = trim(($settings['organization'] ?: $name) . "\n" . $settings['billing_address']);
$customer = str_replace("\n", '<br>', htmlentities($customer));
return [
'id' => $this->wallet->owner->id,
'wallet_id' => $this->wallet->id,
'customer' => $customer,
];
}
/**
* Prepare company data for the template
*
* @return array Company data for the template
*/
protected function companyData(): array
{
$header = \config('app.company.name') . "\n" . \config('app.company.address');
$header = str_replace("\n", '<br>', htmlentities($header));
$footerLineLength = 110;
$footer = \config('app.company.details');
$contact = \config('app.company.email');
$logo = \config('app.company.logo');
$theme = \config('app.theme');
if ($contact) {
$length = strlen($footer) + strlen($contact) + 3;
$contact = htmlentities($contact);
$footer .= ($length > $footerLineLength ? "\n" : ' | ')
. sprintf('<a href="mailto:%s">%s</a>', $contact, $contact);
}
if ($logo && strpos($logo, '/') === false) {
$logo = "/themes/$theme/images/$logo";
}
return [
'logo' => $logo ? "<img src=\"$logo\" width=300>" : '',
'header' => $header,
'footer' => $footer,
];
}
}
diff --git a/src/app/Http/Controllers/API/V4/Admin/StatsController.php b/src/app/Http/Controllers/API/V4/Admin/StatsController.php
index ff37611d..c64dc6e5 100644
--- a/src/app/Http/Controllers/API/V4/Admin/StatsController.php
+++ b/src/app/Http/Controllers/API/V4/Admin/StatsController.php
@@ -1,514 +1,514 @@
<?php
namespace App\Http\Controllers\API\V4\Admin;
-use App\Providers\PaymentProvider;
+use App\Payment;
use App\User;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class StatsController extends \App\Http\Controllers\Controller
{
public const COLOR_GREEN = '#48d368'; // '#28a745'
public const COLOR_GREEN_DARK = '#19692c';
public const COLOR_RED = '#e77681'; // '#dc3545'
public const COLOR_RED_DARK = '#a71d2a';
public const COLOR_BLUE = '#4da3ff'; // '#007bff'
public const COLOR_BLUE_DARK = '#0056b3';
public const COLOR_ORANGE = '#f1a539';
public const TYPE_PAYERS = 1;
/** @var array List of enabled charts */
protected $charts = [
'discounts',
'income',
'payers',
'users',
'users-all',
'vouchers',
];
/**
* Fetch chart data
*
* @param string $chart Name of the chart
*
* @return \Illuminate\Http\JsonResponse
*/
public function chart($chart)
{
if (!preg_match('/^[a-z-]+$/', $chart)) {
return $this->errorResponse(404);
}
$method = 'chart' . implode('', array_map('ucfirst', explode('-', $chart)));
if (!in_array($chart, $this->charts) || !method_exists($this, $method)) {
return $this->errorResponse(404);
}
$result = $this->{$method}();
return response()->json($result);
}
/**
* Get discounts chart
*/
protected function chartDiscounts(): array
{
$discounts = DB::table('wallets')
->selectRaw("discount, count(discount_id) as cnt")
->join('discounts', 'discounts.id', '=', 'wallets.discount_id')
->join('users', 'users.id', '=', 'wallets.user_id')
->where('discount', '>', 0)
->whereNull('users.deleted_at')
->groupBy('discounts.discount');
$addTenantScope = function ($builder, $tenantId) {
return $builder->where('users.tenant_id', $tenantId);
};
$discounts = $this->applyTenantScope($discounts, $addTenantScope)
->pluck('cnt', 'discount')->all();
$labels = array_keys($discounts);
$discounts = array_values($discounts);
// $labels = [10, 25, 30, 100];
// $discounts = [100, 120, 30, 50];
$labels = array_map(function ($item) {
return $item . '%';
}, $labels);
return $this->donutChart(\trans('app.chart-discounts'), $labels, $discounts);
}
/**
* Get income chart
*/
protected function chartIncome(): array
{
$weeks = 8;
$start = Carbon::now();
$labels = [];
while ($weeks > 0) {
$labels[] = $start->format('Y-W');
$weeks--;
if ($weeks) {
$start->subWeeks(1);
}
}
$labels = array_reverse($labels);
$start->startOfWeek(Carbon::MONDAY);
// FIXME: We're using wallets.currency instead of payments.currency and payments.currency_amount
// as I believe this way we have more precise amounts for this use-case (and default currency)
$query = DB::table('payments')
->selectRaw("date_format(updated_at, '%Y-%v') as period, sum(credit_amount) as amount, wallets.currency")
->join('wallets', 'wallets.id', '=', 'wallet_id')
->where('updated_at', '>=', $start->toDateString())
- ->where('status', PaymentProvider::STATUS_PAID)
- ->whereIn('type', [PaymentProvider::TYPE_ONEOFF, PaymentProvider::TYPE_RECURRING])
+ ->where('status', Payment::STATUS_PAID)
+ ->whereIn('type', [Payment::TYPE_ONEOFF, Payment::TYPE_RECURRING])
->groupByRaw('period, wallets.currency');
$addTenantScope = function ($builder, $tenantId) {
$where = sprintf(
'`wallets`.`user_id` IN (select `id` from `users` where `tenant_id` = %d)',
$tenantId
);
return $builder->whereRaw($where);
};
$currency = $this->currency();
$payments = [];
$this->applyTenantScope($query, $addTenantScope)
->get()
->each(function ($record) use (&$payments, $currency) {
$amount = $record->amount;
if ($record->currency != $currency) {
$amount = intval(round($amount * \App\Utils::exchangeRate($record->currency, $currency)));
}
if (isset($payments[$record->period])) {
$payments[$record->period] += $amount / 100;
} else {
$payments[$record->period] = $amount / 100;
}
});
// TODO: exclude refunds/chargebacks
$empty = array_fill_keys($labels, 0);
$payments = array_values(array_merge($empty, $payments));
// $payments = [1000, 1200.25, 3000, 1897.50, 2000, 1900, 2134, 3330];
$avg = collect($payments)->slice(0, count($labels) - 1)->avg();
// See https://frappe.io/charts/docs for format/options description
return [
'title' => \trans('app.chart-income', ['currency' => $currency]),
'type' => 'bar',
'colors' => [self::COLOR_BLUE],
'axisOptions' => [
'xIsSeries' => true,
],
'data' => [
'labels' => $labels,
'datasets' => [
[
// 'name' => 'Payments',
'values' => $payments
]
],
'yMarkers' => [
[
'label' => sprintf('average = %.2f', $avg),
'value' => $avg,
'options' => [ 'labelPos' => 'left' ] // default: 'right'
]
]
]
];
}
/**
* Get payers chart
*/
protected function chartPayers(): array
{
list($labels, $stats) = $this->getCollectedStats(self::TYPE_PAYERS, 54, fn($v) => intval($v));
// See https://frappe.io/charts/docs for format/options description
return [
'title' => \trans('app.chart-payers'),
'type' => 'line',
'colors' => [self::COLOR_GREEN],
'axisOptions' => [
'xIsSeries' => true,
'xAxisMode' => 'tick',
],
'lineOptions' => [
'hideDots' => true,
'regionFill' => true,
],
'data' => [
'labels' => $labels,
'datasets' => [
[
// 'name' => 'Existing',
'values' => $stats
]
]
]
];
}
/**
* Get created/deleted users chart
*/
protected function chartUsers(): array
{
$weeks = 8;
$start = Carbon::now();
$labels = [];
while ($weeks > 0) {
$labels[] = $start->format('Y-W');
$weeks--;
if ($weeks) {
$start->subWeeks(1);
}
}
$labels = array_reverse($labels);
$start->startOfWeek(Carbon::MONDAY);
$created = DB::table('users')
->selectRaw("date_format(created_at, '%Y-%v') as period, count(*) as cnt")
->where('created_at', '>=', $start->toDateString())
->groupByRaw('1');
$deleted = DB::table('users')
->selectRaw("date_format(deleted_at, '%Y-%v') as period, count(*) as cnt")
->where('deleted_at', '>=', $start->toDateString())
->groupByRaw('1');
$created = $this->applyTenantScope($created)->get();
$deleted = $this->applyTenantScope($deleted)->get();
$empty = array_fill_keys($labels, 0);
$created = array_values(array_merge($empty, $created->pluck('cnt', 'period')->all()));
$deleted = array_values(array_merge($empty, $deleted->pluck('cnt', 'period')->all()));
// $created = [5, 2, 4, 2, 0, 5, 2, 4];
// $deleted = [1, 2, 3, 1, 2, 1, 2, 3];
$avg = collect($created)->slice(0, count($labels) - 1)->avg();
// See https://frappe.io/charts/docs for format/options description
return [
'title' => \trans('app.chart-users'),
'type' => 'bar', // Required to fix https://github.com/frappe/charts/issues/294
'colors' => [self::COLOR_GREEN, self::COLOR_RED],
'axisOptions' => [
'xIsSeries' => true,
],
'data' => [
'labels' => $labels,
'datasets' => [
[
'name' => \trans('app.chart-created'),
'chartType' => 'bar',
'values' => $created
],
[
'name' => \trans('app.chart-deleted'),
'chartType' => 'line',
'values' => $deleted
]
],
'yMarkers' => [
[
'label' => sprintf('%s = %.1f', \trans('app.chart-average'), $avg),
'value' => collect($created)->avg(),
'options' => [ 'labelPos' => 'left' ] // default: 'right'
]
]
]
];
}
/**
* Get all users chart
*/
protected function chartUsersAll(): array
{
$weeks = 54;
$start = Carbon::now();
$labels = [];
while ($weeks > 0) {
$labels[] = $start->format('Y-W');
$weeks--;
if ($weeks) {
$start->subWeeks(1);
}
}
$labels = array_reverse($labels);
$start->startOfWeek(Carbon::MONDAY);
$created = DB::table('users')
->selectRaw("date_format(created_at, '%Y-%v') as period, count(*) as cnt")
->where('created_at', '>=', $start->toDateString())
->groupByRaw('1');
$deleted = DB::table('users')
->selectRaw("date_format(deleted_at, '%Y-%v') as period, count(*) as cnt")
->where('deleted_at', '>=', $start->toDateString())
->groupByRaw('1');
$created = $this->applyTenantScope($created)->get();
$deleted = $this->applyTenantScope($deleted)->get();
$count = $this->applyTenantScope(DB::table('users')->whereNull('deleted_at'))->count();
$empty = array_fill_keys($labels, 0);
$created = array_merge($empty, $created->pluck('cnt', 'period')->all());
$deleted = array_merge($empty, $deleted->pluck('cnt', 'period')->all());
$all = [];
foreach (array_reverse($labels) as $label) {
$all[] = $count;
$count -= $created[$label] - $deleted[$label];
}
$all = array_reverse($all);
// $start = 3000;
// for ($i = 0; $i < count($labels); $i++) {
// $all[$i] = $start + $i * 15;
// }
// See https://frappe.io/charts/docs for format/options description
return [
'title' => \trans('app.chart-allusers'),
'type' => 'line',
'colors' => [self::COLOR_GREEN],
'axisOptions' => [
'xIsSeries' => true,
'xAxisMode' => 'tick',
],
'lineOptions' => [
'hideDots' => true,
'regionFill' => true,
],
'data' => [
'labels' => $labels,
'datasets' => [
[
// 'name' => 'Existing',
'values' => $all
]
]
]
];
}
/**
* Get vouchers chart
*/
protected function chartVouchers(): array
{
$vouchers = DB::table('wallets')
->selectRaw("count(discount_id) as cnt, code")
->join('discounts', 'discounts.id', '=', 'wallets.discount_id')
->join('users', 'users.id', '=', 'wallets.user_id')
->where('discount', '>', 0)
->whereNotNull('code')
->whereNull('users.deleted_at')
->groupBy('discounts.code')
->havingRaw("count(discount_id) > 0")
->orderByRaw('1');
$addTenantScope = function ($builder, $tenantId) {
return $builder->where('users.tenant_id', $tenantId);
};
$vouchers = $this->applyTenantScope($vouchers, $addTenantScope)
->pluck('cnt', 'code')->all();
$labels = array_keys($vouchers);
$vouchers = array_values($vouchers);
// $labels = ["TEST", "NEW", "OTHER", "US"];
// $vouchers = [100, 120, 30, 50];
return $this->donutChart(\trans('app.chart-vouchers'), $labels, $vouchers);
}
protected static function donutChart($title, $labels, $data): array
{
// See https://frappe.io/charts/docs for format/options description
return [
'title' => $title,
'type' => 'donut',
'colors' => [
self::COLOR_BLUE,
self::COLOR_BLUE_DARK,
self::COLOR_GREEN,
self::COLOR_GREEN_DARK,
self::COLOR_ORANGE,
self::COLOR_RED,
self::COLOR_RED_DARK
],
'maxSlices' => 8,
'tooltipOptions' => [], // does not work without it (https://github.com/frappe/charts/issues/314)
'data' => [
'labels' => $labels,
'datasets' => [
[
'values' => $data
]
]
]
];
}
/**
* Add tenant scope to the queries when needed
*
* @param \Illuminate\Database\Query\Builder $query The query
* @param callable $addQuery Additional tenant-scope query-modifier
*
* @return \Illuminate\Database\Query\Builder
*/
protected function applyTenantScope($query, $addQuery = null)
{
// TODO: Per-tenant stats for admins
return $query;
}
/**
* Get the currency for stats
*
* @return string Currency code
*/
protected function currency()
{
$user = $this->guard()->user();
// For resellers return their wallet currency
if ($user->role == 'reseller') {
$currency = $user->wallet()->currency;
}
// System currency for others
return \config('app.currency');
}
/**
* Get collected stats for a specific type/period
*
* @param int $type Chart
* @param int $weeks Number of weeks back from now
* @param ?callable $itemCallback A callback to execute on every stat item
*
* @return array [ labels, stats ]
*/
protected function getCollectedStats(int $type, int $weeks, $itemCallback = null): array
{
$start = Carbon::now();
$labels = [];
while ($weeks > 0) {
$labels[] = $start->format('Y-W');
$weeks--;
if ($weeks) {
$start->subWeeks(1);
}
}
$labels = array_reverse($labels);
$start->startOfWeek(Carbon::MONDAY);
// Get the stats grouped by tenant and week
$stats = DB::table('stats')
->selectRaw("tenant_id, date_format(created_at, '%Y-%v') as period, avg(value) as cnt")
->where('type', $type)
->where('created_at', '>=', $start->toDateString())
->groupByRaw('1,2');
// Get the query result and sum up per-tenant stats
$result = [];
$this->applyTenantScope($stats)->get()
->each(function ($item) use (&$result) {
$result[$item->period] = ($result[$item->period] ?? 0) + $item->cnt;
});
// Process the result, e.g. convert values to int
if ($itemCallback) {
$result = array_map($itemCallback, $result);
}
// Fill the missing weeks with zeros
$result = array_values(array_merge(array_fill_keys($labels, 0), $result));
return [$labels, $result];
}
}
diff --git a/src/app/Http/Controllers/API/V4/PaymentsController.php b/src/app/Http/Controllers/API/V4/PaymentsController.php
index a471e7a9..bc1ee188 100644
--- a/src/app/Http/Controllers/API/V4/PaymentsController.php
+++ b/src/app/Http/Controllers/API/V4/PaymentsController.php
@@ -1,519 +1,519 @@
<?php
namespace App\Http\Controllers\API\V4;
use App\Http\Controllers\Controller;
+use App\Payment;
use App\Providers\PaymentProvider;
use App\Tenant;
use App\Wallet;
-use App\Payment;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class PaymentsController extends Controller
{
/**
* Get the auto-payment mandate info.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function mandate()
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $user->wallets()->first();
$mandate = self::walletMandate($wallet);
return response()->json($mandate);
}
/**
* Create a new auto-payment mandate.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function mandateCreate(Request $request)
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $user->wallets()->first();
// Input validation
if ($errors = self::mandateValidate($request, $wallet)) {
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
$wallet->setSettings([
'mandate_amount' => $request->amount,
'mandate_balance' => $request->balance,
]);
$mandate = [
'currency' => $wallet->currency,
'description' => Tenant::getConfig($user->tenant_id, 'app.name') . ' Auto-Payment Setup',
'methodId' => $request->methodId ?: PaymentProvider::METHOD_CREDITCARD,
];
// Normally the auto-payment setup operation is 0, if the balance is below the threshold
// we'll top-up the wallet with the configured auto-payment amount
if ($wallet->balance < intval($request->balance * 100)) {
$mandate['amount'] = intval($request->amount * 100);
self::addTax($wallet, $mandate);
}
$provider = PaymentProvider::factory($wallet);
$result = $provider->createMandate($wallet, $mandate);
$result['status'] = 'success';
return response()->json($result);
}
/**
* Revoke the auto-payment mandate.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function mandateDelete()
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $user->wallets()->first();
$provider = PaymentProvider::factory($wallet);
$provider->deleteMandate($wallet);
$wallet->setSetting('mandate_disabled', null);
return response()->json([
'status' => 'success',
'message' => \trans('app.mandate-delete-success'),
]);
}
/**
* Update a new auto-payment mandate.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function mandateUpdate(Request $request)
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $user->wallets()->first();
// Input validation
if ($errors = self::mandateValidate($request, $wallet)) {
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
$wallet->setSettings([
'mandate_amount' => $request->amount,
'mandate_balance' => $request->balance,
// Re-enable the mandate to give it a chance to charge again
// after it has been disabled (e.g. because the mandate amount was too small)
'mandate_disabled' => null,
]);
// Trigger auto-payment if the balance is below the threshold
if ($wallet->balance < intval($request->balance * 100)) {
\App\Jobs\WalletCharge::dispatch($wallet);
}
$result = self::walletMandate($wallet);
$result['status'] = 'success';
$result['message'] = \trans('app.mandate-update-success');
return response()->json($result);
}
/**
* Validate an auto-payment mandate request.
*
* @param \Illuminate\Http\Request $request The API request.
* @param \App\Wallet $wallet The wallet
*
* @return array|null List of errors on error or Null on success
*/
protected static function mandateValidate(Request $request, Wallet $wallet)
{
$rules = [
'amount' => 'required|numeric',
'balance' => 'required|numeric|min:0',
];
// Check required fields
$v = Validator::make($request->all(), $rules);
// TODO: allow comma as a decimal point?
if ($v->fails()) {
return $v->errors()->toArray();
}
$amount = (int) ($request->amount * 100);
// Validate the minimum value
// It has to be at least minimum payment amount and must cover current debt
if (
$wallet->balance < 0
- && $wallet->balance <= PaymentProvider::MIN_AMOUNT * -1
+ && $wallet->balance <= Payment::MIN_AMOUNT * -1
&& $wallet->balance + $amount < 0
) {
return ['amount' => \trans('validation.minamountdebt')];
}
- if ($amount < PaymentProvider::MIN_AMOUNT) {
- $min = $wallet->money(PaymentProvider::MIN_AMOUNT);
+ if ($amount < Payment::MIN_AMOUNT) {
+ $min = $wallet->money(Payment::MIN_AMOUNT);
return ['amount' => \trans('validation.minamount', ['amount' => $min])];
}
return null;
}
/**
* Create a new payment.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function store(Request $request)
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $user->wallets()->first();
$rules = [
'amount' => 'required|numeric',
];
// Check required fields
$v = Validator::make($request->all(), $rules);
// TODO: allow comma as a decimal point?
if ($v->fails()) {
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
$amount = (int) ($request->amount * 100);
// Validate the minimum value
- if ($amount < PaymentProvider::MIN_AMOUNT) {
- $min = $wallet->money(PaymentProvider::MIN_AMOUNT);
+ if ($amount < Payment::MIN_AMOUNT) {
+ $min = $wallet->money(Payment::MIN_AMOUNT);
$errors = ['amount' => \trans('validation.minamount', ['amount' => $min])];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
$currency = $request->currency;
$request = [
- 'type' => PaymentProvider::TYPE_ONEOFF,
+ 'type' => Payment::TYPE_ONEOFF,
'currency' => $currency,
'amount' => $amount,
'methodId' => $request->methodId ?: PaymentProvider::METHOD_CREDITCARD,
'description' => Tenant::getConfig($user->tenant_id, 'app.name') . ' Payment',
];
self::addTax($wallet, $request);
$provider = PaymentProvider::factory($wallet, $currency);
$result = $provider->payment($wallet, $request);
$result['status'] = 'success';
return response()->json($result);
}
/**
* Delete a pending payment.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
// TODO currently unused
// public function cancel(Request $request)
// {
// $user = $this->guard()->user();
// // TODO: Wallet selection
// $wallet = $user->wallets()->first();
// $paymentId = $request->payment;
// $user_owns_payment = Payment::where('id', $paymentId)
// ->where('wallet_id', $wallet->id)
// ->exists();
// if (!$user_owns_payment) {
// return $this->errorResponse(404);
// }
// $provider = PaymentProvider::factory($wallet);
// if ($provider->cancel($wallet, $paymentId)) {
// $result = ['status' => 'success'];
// return response()->json($result);
// }
// return $this->errorResponse(404);
// }
/**
* Update payment status (and balance).
*
* @param string $provider Provider name
*
* @return \Illuminate\Http\Response The response
*/
public function webhook($provider)
{
$code = 200;
if ($provider = PaymentProvider::factory($provider)) {
$code = $provider->webhook();
}
return response($code < 400 ? 'Success' : 'Server error', $code);
}
/**
* Top up a wallet with a "recurring" payment.
*
* @param \App\Wallet $wallet The wallet to charge
*
* @return bool True if the payment has been initialized
*/
public static function topUpWallet(Wallet $wallet): bool
{
$settings = $wallet->getSettings(['mandate_disabled', 'mandate_balance', 'mandate_amount']);
\Log::debug("Requested top-up for wallet {$wallet->id}");
if (!empty($settings['mandate_disabled'])) {
\Log::debug("Top-up for wallet {$wallet->id}: mandate disabled");
return false;
}
$min_balance = (int) (floatval($settings['mandate_balance']) * 100);
$amount = (int) (floatval($settings['mandate_amount']) * 100);
// The wallet balance is greater than the auto-payment threshold
if ($wallet->balance >= $min_balance) {
// Do nothing
return false;
}
$provider = PaymentProvider::factory($wallet);
$mandate = (array) $provider->getMandate($wallet);
if (empty($mandate['isValid'])) {
\Log::debug("Top-up for wallet {$wallet->id}: mandate invalid");
return false;
}
// The defined top-up amount is not enough
// Disable auto-payment and notify the user
if ($wallet->balance + $amount < 0) {
// Disable (not remove) the mandate
$wallet->setSetting('mandate_disabled', 1);
\App\Jobs\PaymentMandateDisabledEmail::dispatch($wallet);
return false;
}
$request = [
- 'type' => PaymentProvider::TYPE_RECURRING,
+ 'type' => Payment::TYPE_RECURRING,
'currency' => $wallet->currency,
'amount' => $amount,
'methodId' => PaymentProvider::METHOD_CREDITCARD,
'description' => Tenant::getConfig($wallet->owner->tenant_id, 'app.name') . ' Recurring Payment',
];
self::addTax($wallet, $request);
$result = $provider->payment($wallet, $request);
return !empty($result);
}
/**
* Returns auto-payment mandate info for the specified wallet
*
* @param \App\Wallet $wallet A wallet object
*
* @return array A mandate metadata
*/
public static function walletMandate(Wallet $wallet): array
{
$provider = PaymentProvider::factory($wallet);
$settings = $wallet->getSettings(['mandate_disabled', 'mandate_balance', 'mandate_amount']);
// Get the Mandate info
$mandate = (array) $provider->getMandate($wallet);
- $mandate['amount'] = (int) (PaymentProvider::MIN_AMOUNT / 100);
+ $mandate['amount'] = (int) (Payment::MIN_AMOUNT / 100);
$mandate['balance'] = 0;
$mandate['isDisabled'] = !empty($mandate['id']) && $settings['mandate_disabled'];
foreach (['amount', 'balance'] as $key) {
if (($value = $settings["mandate_{$key}"]) !== null) {
$mandate[$key] = $value;
}
}
return $mandate;
}
/**
* List supported payment methods.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function paymentMethods(Request $request)
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $user->wallets()->first();
$methods = PaymentProvider::paymentMethods($wallet, $request->type);
\Log::debug("Provider methods" . var_export(json_encode($methods), true));
return response()->json($methods);
}
/**
* Check for pending payments.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function hasPayments(Request $request)
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $user->wallets()->first();
$exists = Payment::where('wallet_id', $wallet->id)
- ->where('type', PaymentProvider::TYPE_ONEOFF)
+ ->where('type', Payment::TYPE_ONEOFF)
->whereIn('status', [
- PaymentProvider::STATUS_OPEN,
- PaymentProvider::STATUS_PENDING,
- PaymentProvider::STATUS_AUTHORIZED
+ Payment::STATUS_OPEN,
+ Payment::STATUS_PENDING,
+ Payment::STATUS_AUTHORIZED
])
->exists();
return response()->json([
'status' => 'success',
'hasPending' => $exists
]);
}
/**
* List pending payments.
*
* @param \Illuminate\Http\Request $request The API request.
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function payments(Request $request)
{
$user = $this->guard()->user();
// TODO: Wallet selection
$wallet = $user->wallets()->first();
$pageSize = 10;
$page = intval(request()->input('page')) ?: 1;
$hasMore = false;
$result = Payment::where('wallet_id', $wallet->id)
- ->where('type', PaymentProvider::TYPE_ONEOFF)
+ ->where('type', Payment::TYPE_ONEOFF)
->whereIn('status', [
- PaymentProvider::STATUS_OPEN,
- PaymentProvider::STATUS_PENDING,
- PaymentProvider::STATUS_AUTHORIZED
+ Payment::STATUS_OPEN,
+ Payment::STATUS_PENDING,
+ Payment::STATUS_AUTHORIZED
])
->orderBy('created_at', 'desc')
->limit($pageSize + 1)
->offset($pageSize * ($page - 1))
->get();
if (count($result) > $pageSize) {
$result->pop();
$hasMore = true;
}
$result = $result->map(function ($item) use ($wallet) {
$provider = PaymentProvider::factory($item->provider);
$payment = $provider->getPayment($item->id);
$entry = [
'id' => $item->id,
'createdAt' => $item->created_at->format('Y-m-d H:i'),
'type' => $item->type,
'description' => $item->description,
'amount' => $item->amount,
'currency' => $wallet->currency,
// note: $item->currency/$item->currency_amount might be different
'status' => $item->status,
'isCancelable' => $payment['isCancelable'],
'checkoutUrl' => $payment['checkoutUrl']
];
return $entry;
});
return response()->json([
'status' => 'success',
'list' => $result,
'count' => count($result),
'hasMore' => $hasMore,
'page' => $page,
]);
}
/**
* Calculates tax for the payment, fills the request with additional properties
*/
protected static function addTax(Wallet $wallet, array &$request): void
{
$request['vat_rate_id'] = null;
$request['credit_amount'] = $request['amount'];
if ($rate = $wallet->vatRate()) {
$request['vat_rate_id'] = $rate->id;
switch (\config('app.vat.mode')) {
case 1:
// In this mode tax is added on top of the payment. The amount
// to pay grows, but we keep wallet balance without tax.
$request['amount'] = $request['amount'] + round($request['amount'] * $rate->rate / 100);
break;
default:
// In this mode tax is "swallowed" by the vendor. The payment
// amount does not change
break;
}
}
}
}
diff --git a/src/app/Http/Controllers/API/V4/PolicyController.php b/src/app/Http/Controllers/API/V4/PolicyController.php
index 3239ae79..7bad11a5 100644
--- a/src/app/Http/Controllers/API/V4/PolicyController.php
+++ b/src/app/Http/Controllers/API/V4/PolicyController.php
@@ -1,438 +1,437 @@
<?php
namespace App\Http\Controllers\API\V4;
use App\Http\Controllers\Controller;
-use App\Providers\PaymentProvider;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
class PolicyController extends Controller
{
/**
* Take a greylist policy request
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function greylist()
{
$data = \request()->input();
$request = new \App\Policy\Greylist\Request($data);
$shouldDefer = $request->shouldDefer();
if ($shouldDefer) {
return response()->json(
['response' => 'DEFER_IF_PERMIT', 'reason' => "Greylisted for 5 minutes. Try again later."],
403
);
}
$prependGreylist = $request->headerGreylist();
$result = [
'response' => 'DUNNO',
'prepend' => [$prependGreylist]
];
return response()->json($result, 200);
}
/*
* Apply a sensible rate limitation to a request.
*
* @return \Illuminate\Http\JsonResponse
*/
public function ratelimit()
{
$data = \request()->input();
$sender = strtolower($data['sender']);
if (strpos($sender, '+') !== false) {
list($local, $rest) = explode('+', $sender);
list($rest, $domain) = explode('@', $sender);
$sender = "{$local}@{$domain}";
}
list($local, $domain) = explode('@', $sender);
if (in_array($sender, \config('app.ratelimit_whitelist', []), true)) {
return response()->json(['response' => 'DUNNO'], 200);
}
//
// Examine the individual sender
//
$user = \App\User::withTrashed()->where('email', $sender)->first();
if (!$user) {
$alias = \App\UserAlias::where('alias', $sender)->first();
if (!$alias) {
// external sender through where this policy is applied
return response()->json(['response' => 'DUNNO'], 200);
}
$user = $alias->user;
}
if ($user->isDeleted() || $user->isSuspended()) {
// use HOLD, so that it is silent (as opposed to REJECT)
return response()->json(['response' => 'HOLD', 'reason' => 'Sender deleted or suspended'], 403);
}
//
// Examine the domain
//
$domain = \App\Domain::withTrashed()->where('namespace', $domain)->first();
if (!$domain) {
// external sender through where this policy is applied
return response()->json(['response' => 'DUNNO'], 200);
}
if ($domain->isDeleted() || $domain->isSuspended()) {
// use HOLD, so that it is silent (as opposed to REJECT)
return response()->json(['response' => 'HOLD', 'reason' => 'Sender domain deleted or suspended'], 403);
}
// see if the user or domain is whitelisted
// use ./artisan policy:ratelimit:whitelist:create <email|namespace>
$whitelist = \App\Policy\RateLimitWhitelist::where(
[
'whitelistable_type' => \App\User::class,
'whitelistable_id' => $user->id
]
)->first();
if ($whitelist) {
return response()->json(['response' => 'DUNNO'], 200);
}
$whitelist = \App\Policy\RateLimitWhitelist::where(
[
'whitelistable_type' => \App\Domain::class,
'whitelistable_id' => $domain->id
]
)->first();
if ($whitelist) {
return response()->json(['response' => 'DUNNO'], 200);
}
// user nor domain whitelisted, continue scrutinizing request
$recipients = $data['recipients'];
sort($recipients);
$recipientCount = count($recipients);
$recipientHash = hash('sha256', implode(',', $recipients));
//
// Retrieve the wallet to get to the owner
//
$wallet = $user->wallet();
// wait, there is no wallet?
if (!$wallet) {
return response()->json(['response' => 'HOLD', 'reason' => 'Sender without a wallet'], 403);
}
$owner = $wallet->owner;
// find or create the request
$request = \App\Policy\RateLimit::where(
[
'recipient_hash' => $recipientHash,
'user_id' => $user->id
]
)->where('updated_at', '>=', \Carbon\Carbon::now()->subHour())->first();
if (!$request) {
$request = \App\Policy\RateLimit::create(
[
'user_id' => $user->id,
'owner_id' => $owner->id,
'recipient_hash' => $recipientHash,
'recipient_count' => $recipientCount
]
);
// ensure the request has an up to date timestamp
} else {
$request->updated_at = \Carbon\Carbon::now();
$request->save();
}
// excempt owners that have made at least two payments and currently maintain a positive balance.
$payments = $wallet->payments
->where('amount', '>', 0)
->where('status', 'paid');
if ($payments->count() >= 2 && $wallet->balance > 0) {
return response()->json(['response' => 'DUNNO'], 200);
}
//
// Examine the rates at which the owner (or its users) is sending
//
$ownerRates = \App\Policy\RateLimit::where('owner_id', $owner->id)
->where('updated_at', '>=', \Carbon\Carbon::now()->subHour());
if ($ownerRates->count() >= 10) {
$result = [
'response' => 'DEFER_IF_PERMIT',
'reason' => 'The account is at 10 messages per hour, cool down.'
];
// automatically suspend (recursively) if 2.5 times over the original limit and younger than two months
$ageThreshold = \Carbon\Carbon::now()->subMonthsWithoutOverflow(2);
if ($ownerRates->count() >= 25 && $owner->created_at > $ageThreshold) {
$wallet->entitlements->each(
function ($entitlement) {
if ($entitlement->entitleable_type == \App\Domain::class) {
$entitlement->entitleable->suspend();
}
if ($entitlement->entitleable_type == \App\User::class) {
$entitlement->entitleable->suspend();
}
}
);
}
return response()->json($result, 403);
}
$ownerRates = \App\Policy\RateLimit::where('owner_id', $owner->id)
->where('updated_at', '>=', \Carbon\Carbon::now()->subHour())
->sum('recipient_count');
if ($ownerRates >= 100) {
$result = [
'response' => 'DEFER_IF_PERMIT',
'reason' => 'The account is at 100 recipients per hour, cool down.'
];
// automatically suspend if 2.5 times over the original limit and younger than two months
$ageThreshold = \Carbon\Carbon::now()->subMonthsWithoutOverflow(2);
if ($ownerRates >= 250 && $owner->created_at > $ageThreshold) {
$wallet->entitlements->each(
function ($entitlement) {
if ($entitlement->entitleable_type == \App\Domain::class) {
$entitlement->entitleable->suspend();
}
if ($entitlement->entitleable_type == \App\User::class) {
$entitlement->entitleable->suspend();
}
}
);
}
return response()->json($result, 403);
}
//
// Examine the rates at which the user is sending (if not also the owner
//
if ($user->id != $owner->id) {
$userRates = \App\Policy\RateLimit::where('user_id', $user->id)
->where('updated_at', '>=', \Carbon\Carbon::now()->subHour());
if ($userRates->count() >= 10) {
$result = [
'response' => 'DEFER_IF_PERMIT',
'reason' => 'User is at 10 messages per hour, cool down.'
];
// automatically suspend if 2.5 times over the original limit and younger than two months
$ageThreshold = \Carbon\Carbon::now()->subMonthsWithoutOverflow(2);
if ($userRates->count() >= 25 && $user->created_at > $ageThreshold) {
$user->suspend();
}
return response()->json($result, 403);
}
$userRates = \App\Policy\RateLimit::where('user_id', $user->id)
->where('updated_at', '>=', \Carbon\Carbon::now()->subHour())
->sum('recipient_count');
if ($userRates >= 100) {
$result = [
'response' => 'DEFER_IF_PERMIT',
'reason' => 'User is at 100 recipients per hour, cool down.'
];
// automatically suspend if 2.5 times over the original limit
$ageThreshold = \Carbon\Carbon::now()->subMonthsWithoutOverflow(2);
if ($userRates >= 250 && $user->created_at > $ageThreshold) {
$user->suspend();
}
return response()->json($result, 403);
}
}
return response()->json(['response' => 'DUNNO'], 200);
}
/*
* Apply the sender policy framework to a request.
*
* @return \Illuminate\Http\JsonResponse
*/
public function senderPolicyFramework()
{
$data = \request()->input();
if (!array_key_exists('client_address', $data)) {
\Log::error("SPF: Request without client_address: " . json_encode($data));
return response()->json(
[
'response' => 'DEFER_IF_PERMIT',
'reason' => 'Temporary error. Please try again later.'
],
403
);
}
list($netID, $netType) = \App\Utils::getNetFromAddress($data['client_address']);
// This network can not be recognized.
if (!$netID) {
\Log::error("SPF: Request without recognizable network: " . json_encode($data));
return response()->json(
[
'response' => 'DEFER_IF_PERMIT',
'reason' => 'Temporary error. Please try again later.'
],
403
);
}
$senderLocal = 'unknown';
$senderDomain = 'unknown';
if (strpos($data['sender'], '@') !== false) {
list($senderLocal, $senderDomain) = explode('@', $data['sender']);
if (strlen($senderLocal) >= 255) {
$senderLocal = substr($senderLocal, 0, 255);
}
}
if ($data['sender'] === null) {
$data['sender'] = '';
}
// Compose the cache key we want.
$cacheKey = "{$netType}_{$netID}_{$senderDomain}";
$result = \App\Policy\SPF\Cache::get($cacheKey);
if (!$result) {
$environment = new \SPFLib\Check\Environment(
$data['client_address'],
$data['client_name'],
$data['sender']
);
$result = (new \SPFLib\Checker())->check($environment);
\App\Policy\SPF\Cache::set($cacheKey, serialize($result));
} else {
$result = unserialize($result);
}
$fail = false;
$prependSPF = '';
switch ($result->getCode()) {
case \SPFLib\Check\Result::CODE_ERROR_PERMANENT:
$fail = true;
$prependSPF = "Received-SPF: Permerror";
break;
case \SPFLib\Check\Result::CODE_ERROR_TEMPORARY:
$prependSPF = "Received-SPF: Temperror";
break;
case \SPFLib\Check\Result::CODE_FAIL:
$fail = true;
$prependSPF = "Received-SPF: Fail";
break;
case \SPFLib\Check\Result::CODE_SOFTFAIL:
$prependSPF = "Received-SPF: Softfail";
break;
case \SPFLib\Check\Result::CODE_NEUTRAL:
$prependSPF = "Received-SPF: Neutral";
break;
case \SPFLib\Check\Result::CODE_PASS:
$prependSPF = "Received-SPF: Pass";
break;
case \SPFLib\Check\Result::CODE_NONE:
$prependSPF = "Received-SPF: None";
break;
}
$prependSPF .= " identity=mailfrom;";
$prependSPF .= " client-ip={$data['client_address']};";
$prependSPF .= " helo={$data['client_name']};";
$prependSPF .= " envelope-from={$data['sender']};";
if ($fail) {
// TODO: check the recipient's policy, such as using barracuda for anti-spam and anti-virus as a relay for
// inbound mail to a local recipient address.
$objects = \App\Utils::findObjectsByRecipientAddress($data['recipient']);
if (!empty($objects)) {
// check if any of the recipient objects have whitelisted the helo, first one wins.
foreach ($objects as $object) {
if (method_exists($object, 'senderPolicyFrameworkWhitelist')) {
$result = $object->senderPolicyFrameworkWhitelist($data['client_name']);
if ($result) {
$response = [
'response' => 'DUNNO',
'prepend' => ["Received-SPF: Pass Check skipped at recipient's discretion"],
'reason' => 'HELO name whitelisted'
];
return response()->json($response, 200);
}
}
}
}
$result = [
'response' => 'REJECT',
'prepend' => [$prependSPF],
'reason' => "Prohibited by Sender Policy Framework"
];
return response()->json($result, 403);
}
$result = [
'response' => 'DUNNO',
'prepend' => [$prependSPF],
'reason' => "Don't know"
];
return response()->json($result, 200);
}
}
diff --git a/src/app/Http/Controllers/API/V4/WalletsController.php b/src/app/Http/Controllers/API/V4/WalletsController.php
index 91bc6a9f..719f7479 100644
--- a/src/app/Http/Controllers/API/V4/WalletsController.php
+++ b/src/app/Http/Controllers/API/V4/WalletsController.php
@@ -1,278 +1,279 @@
<?php
namespace App\Http\Controllers\API\V4;
+use App\Payment;
use App\Transaction;
use App\Wallet;
use App\Http\Controllers\ResourceController;
use App\Providers\PaymentProvider;
use Carbon\Carbon;
use Illuminate\Http\Request;
/**
* API\WalletsController
*/
class WalletsController extends ResourceController
{
/**
* Return data of the specified wallet.
*
* @param string $id A wallet identifier
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function show($id)
{
$wallet = Wallet::find($id);
if (empty($wallet) || !$this->checkTenant($wallet->owner)) {
return $this->errorResponse(404);
}
// Only owner (or admin) has access to the wallet
if (!$this->guard()->user()->canRead($wallet)) {
return $this->errorResponse(403);
}
$result = $wallet->toArray();
- $provider = \App\Providers\PaymentProvider::factory($wallet);
+ $provider = PaymentProvider::factory($wallet);
$result['provider'] = $provider->name();
$result['notice'] = $this->getWalletNotice($wallet);
return response()->json($result);
}
/**
* Download a receipt in pdf format.
*
* @param string $id Wallet identifier
* @param string $receipt Receipt identifier (YYYY-MM)
*
* @return \Illuminate\Http\Response
*/
public function receiptDownload($id, $receipt)
{
$wallet = Wallet::find($id);
if (empty($wallet) || !$this->checkTenant($wallet->owner)) {
abort(404);
}
// Only owner (or admin) has access to the wallet
if (!$this->guard()->user()->canRead($wallet)) {
abort(403);
}
list ($year, $month) = explode('-', $receipt);
if (empty($year) || empty($month) || $year < 2000 || $month < 1 || $month > 12) {
abort(404);
}
if ($receipt >= date('Y-m')) {
abort(404);
}
$params = [
'id' => sprintf('%04d-%02d', $year, $month),
'site' => \config('app.name')
];
$filename = \trans('documents.receipt-filename', $params);
$receipt = new \App\Documents\Receipt($wallet, (int) $year, (int) $month);
$content = $receipt->pdfOutput();
return response($content)
->withHeaders([
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
'Content-Length' => strlen($content),
]);
}
/**
* Fetch wallet receipts list.
*
* @param string $id Wallet identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function receipts($id)
{
$wallet = Wallet::find($id);
if (empty($wallet) || !$this->checkTenant($wallet->owner)) {
return $this->errorResponse(404);
}
// Only owner (or admin) has access to the wallet
if (!$this->guard()->user()->canRead($wallet)) {
return $this->errorResponse(403);
}
$result = $wallet->payments()
->selectRaw('distinct date_format(updated_at, "%Y-%m") as ident')
- ->where('status', PaymentProvider::STATUS_PAID)
+ ->where('status', Payment::STATUS_PAID)
->where('amount', '<>', 0)
->orderBy('ident', 'desc')
->get()
->whereNotIn('ident', [date('Y-m')]) // exclude current month
->pluck('ident');
return response()->json([
'status' => 'success',
'list' => $result,
'count' => count($result),
'hasMore' => false,
'page' => 1,
]);
}
/**
* Fetch wallet transactions.
*
* @param string $id Wallet identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function transactions($id)
{
$wallet = Wallet::find($id);
if (empty($wallet) || !$this->checkTenant($wallet->owner)) {
return $this->errorResponse(404);
}
// Only owner (or admin) has access to the wallet
if (!$this->guard()->user()->canRead($wallet)) {
return $this->errorResponse(403);
}
$pageSize = 10;
$page = intval(request()->input('page')) ?: 1;
$hasMore = false;
$isAdmin = $this instanceof Admin\WalletsController;
if ($transaction = request()->input('transaction')) {
// Get sub-transactions for the specified transaction ID, first
// check access rights to the transaction's wallet
/** @var ?\App\Transaction $transaction */
$transaction = $wallet->transactions()->where('id', $transaction)->first();
if (!$transaction) {
return $this->errorResponse(404);
}
$result = Transaction::where('transaction_id', $transaction->id)->get();
} else {
// Get main transactions (paged)
$result = $wallet->transactions()
// FIXME: Do we know which (type of) transaction has sub-transactions
// without the sub-query?
->selectRaw("*, (SELECT count(*) FROM transactions sub "
. "WHERE sub.transaction_id = transactions.id) AS cnt")
->whereNull('transaction_id')
->latest()
->limit($pageSize + 1)
->offset($pageSize * ($page - 1))
->get();
if (count($result) > $pageSize) {
$result->pop();
$hasMore = true;
}
}
$result = $result->map(function ($item) use ($isAdmin, $wallet) {
$entry = [
'id' => $item->id,
'createdAt' => $item->created_at->format('Y-m-d H:i'),
'type' => $item->type,
'description' => $item->shortDescription(),
'amount' => $item->amount,
'currency' => $wallet->currency,
'hasDetails' => !empty($item->cnt),
];
if ($isAdmin && $item->user_email) {
$entry['user'] = $item->user_email;
}
return $entry;
});
return response()->json([
'status' => 'success',
'list' => $result,
'count' => count($result),
'hasMore' => $hasMore,
'page' => $page,
]);
}
/**
* Returns human readable notice about the wallet state.
*
* @param \App\Wallet $wallet The wallet
*/
protected function getWalletNotice(Wallet $wallet): ?string
{
// there is no credit
if ($wallet->balance < 0) {
return \trans('app.wallet-notice-nocredit');
}
// the discount is 100%, no credit is needed
if ($wallet->discount && $wallet->discount->discount == 100) {
return null;
}
$plan = $wallet->plan();
$freeMonths = $plan ? $plan->free_months : 0;
$trialEnd = $freeMonths ? $wallet->owner->created_at->copy()->addMonthsWithoutOverflow($freeMonths) : null;
// the owner is still in the trial period
if ($trialEnd && $trialEnd > Carbon::now()) {
// notice of trial ending if less than 2 weeks left
if ($trialEnd < Carbon::now()->addWeeks(2)) {
return \trans('app.wallet-notice-trial-end');
}
return \trans('app.wallet-notice-trial');
}
if ($until = $wallet->balanceLastsUntil()) {
if ($until->isToday()) {
return \trans('app.wallet-notice-today');
}
// Once in a while we got e.g. "3 weeks" instead of expected "4 weeks".
// It's because $until uses full seconds, but $now is more precise.
// We make sure both have the same time set.
$now = Carbon::now()->setTimeFrom($until);
$diffOptions = [
'syntax' => Carbon::DIFF_ABSOLUTE,
'parts' => 1,
];
if ($now->diff($until)->days > 31) {
$diffOptions['parts'] = 2;
}
$params = [
'date' => $until->toDateString(),
'days' => $now->diffForHumans($until, $diffOptions),
];
return \trans('app.wallet-notice-date', $params);
}
return null;
}
}
diff --git a/src/app/Jobs/PaymentEmail.php b/src/app/Jobs/PaymentEmail.php
index 36c2ef83..8807001b 100644
--- a/src/app/Jobs/PaymentEmail.php
+++ b/src/app/Jobs/PaymentEmail.php
@@ -1,102 +1,101 @@
<?php
namespace App\Jobs;
use App\Payment;
-use App\Providers\PaymentProvider;
use App\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
class PaymentEmail implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
/** @var int The number of times the job may be attempted. */
public $tries = 2;
/** @var int The number of seconds to wait before retrying the job. */
public $backoff = 10;
/** @var bool Delete the job if the wallet no longer exist. */
public $deleteWhenMissingModels = true;
/** @var \App\Payment A payment object */
protected $payment;
/** @var ?\App\User A wallet controller */
protected $controller;
/**
* Create a new job instance.
*
* @param \App\Payment $payment A payment object
* @param \App\User $controller A wallet controller
*
* @return void
*/
public function __construct(Payment $payment, User $controller = null)
{
$this->payment = $payment;
$this->controller = $controller;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$wallet = $this->payment->wallet;
if (empty($this->controller)) {
$this->controller = $wallet->owner;
}
if (empty($this->controller)) {
return;
}
- if ($this->payment->status == PaymentProvider::STATUS_PAID) {
+ if ($this->payment->status == Payment::STATUS_PAID) {
$mail = new \App\Mail\PaymentSuccess($this->payment, $this->controller);
$label = "Success";
} elseif (
- $this->payment->status == PaymentProvider::STATUS_EXPIRED
- || $this->payment->status == PaymentProvider::STATUS_FAILED
+ $this->payment->status == Payment::STATUS_EXPIRED
+ || $this->payment->status == Payment::STATUS_FAILED
) {
$mail = new \App\Mail\PaymentFailure($this->payment, $this->controller);
$label = "Failure";
} else {
return;
}
list($to, $cc) = \App\Mail\Helper::userEmails($this->controller);
if (!empty($to) || !empty($cc)) {
$params = [
'to' => $to,
'cc' => $cc,
'add' => " for {$wallet->id}",
];
\App\Mail\Helper::sendMail($mail, $this->controller->tenant_id, $params);
}
/*
// Send the email to all wallet controllers too
if ($wallet->owner->id == $this->controller->id) {
$this->wallet->controllers->each(function ($controller) {
self::dispatch($this->payment, $controller);
}
});
*/
}
}
diff --git a/src/app/Payment.php b/src/app/Payment.php
index d6cd0723..b926140a 100644
--- a/src/app/Payment.php
+++ b/src/app/Payment.php
@@ -1,156 +1,192 @@
<?php
namespace App;
-use App\Providers\PaymentProvider;
use Dyrynda\Database\Support\NullableFields;
use Illuminate\Database\Eloquent\Model;
/**
* A payment operation on a wallet.
*
* @property int $amount Amount of money in cents of system currency (payment provider)
* @property int $credit_amount Amount of money in cents of system currency (wallet balance)
* @property string $description Payment description
* @property string $id Mollie's Payment ID
* @property ?string $vat_rate_id VAT rate identifier
* @property \App\Wallet $wallet The wallet
* @property string $wallet_id The ID of the wallet
* @property string $currency Currency of this payment
* @property int $currency_amount Amount of money in cents of $currency
*/
class Payment extends Model
{
use NullableFields;
+ public const STATUS_OPEN = 'open';
+ public const STATUS_CANCELED = 'canceled';
+ public const STATUS_PENDING = 'pending';
+ public const STATUS_AUTHORIZED = 'authorized';
+ public const STATUS_EXPIRED = 'expired';
+ public const STATUS_FAILED = 'failed';
+ public const STATUS_PAID = 'paid';
+
+ public const TYPE_ONEOFF = 'oneoff';
+ public const TYPE_RECURRING = 'recurring';
+ public const TYPE_MANDATE = 'mandate';
+ public const TYPE_REFUND = 'refund';
+ public const TYPE_CHARGEBACK = 'chargeback';
+
+ /** const int Minimum amount of money in a single payment (in cents) */
+ public const MIN_AMOUNT = 1000;
+
/** @var bool Indicates that the model should be timestamped or not */
public $incrementing = false;
/** @var string The "type" of the auto-incrementing ID */
protected $keyType = 'string';
/** @var array<string, string> The attributes that should be cast */
protected $casts = [
'amount' => 'integer',
'credit_amount' => 'integer',
'currency_amount' => 'integer',
];
/** @var array<int,string> The attributes that are mass assignable */
protected $fillable = [
'id',
'wallet_id',
'amount',
'credit_amount',
'description',
'provider',
'status',
'vat_rate_id',
'type',
'currency',
'currency_amount',
];
/** @var array<int, string> The attributes that can be not set */
protected $nullable = [
'vat_rate_id',
];
/**
* Create a payment record in DB from array.
*
* @param array $payment Payment information (required: id, type, wallet_id, currency, amount, currency_amount)
*
* @return \App\Payment Payment object
*/
public static function createFromArray(array $payment): Payment
{
$db_payment = new Payment();
$db_payment->id = $payment['id'];
$db_payment->description = $payment['description'] ?? '';
- $db_payment->status = $payment['status'] ?? PaymentProvider::STATUS_OPEN;
+ $db_payment->status = $payment['status'] ?? self::STATUS_OPEN;
$db_payment->amount = $payment['amount'] ?? 0;
$db_payment->credit_amount = $payment['credit_amount'] ?? ($payment['amount'] ?? 0);
$db_payment->vat_rate_id = $payment['vat_rate_id'] ?? null;
$db_payment->type = $payment['type'];
$db_payment->wallet_id = $payment['wallet_id'];
$db_payment->provider = $payment['provider'] ?? '';
$db_payment->currency = $payment['currency'];
$db_payment->currency_amount = $payment['currency_amount'];
$db_payment->save();
return $db_payment;
}
+ /**
+ * Apply the successful payment's pecunia to the wallet
+ *
+ * @param string $method Payment method name
+ */
+ public function credit($method): void
+ {
+ // TODO: Possibly we should sanity check that payment is paid, and not negative?
+ // TODO: Localization?
+ $description = $this->type == self::TYPE_RECURRING ? 'Auto-payment' : 'Payment';
+ $description .= " transaction {$this->id} using {$method}";
+
+ $this->wallet->credit($this, $description);
+
+ // Unlock the disabled auto-payment mandate
+ if ($this->wallet->balance >= 0) {
+ $this->wallet->setSetting('mandate_disabled', null);
+ }
+ }
+
/**
* Creates a payment and transaction records for the refund/chargeback operation.
* Deducts an amount of pecunia from the wallet.
*
* @param array $refund A refund or chargeback data (id, type, amount, currency, description)
*
* @return ?\App\Payment A payment object for the refund
*/
public function refund(array $refund): ?Payment
{
if (empty($refund) || empty($refund['amount'])) {
return null;
}
// Convert amount to wallet currency (use the same exchange rate as for the original payment)
// Note: We assume a refund is always using the same currency
$exchange_rate = $this->amount / $this->currency_amount;
$credit_amount = $amount = (int) round($refund['amount'] * $exchange_rate);
// Set appropriate credit_amount if original credit_amount != original amount
if ($this->amount != $this->credit_amount) {
$credit_amount = (int) round($amount * ($this->credit_amount / $this->amount));
}
// Apply the refund to the wallet balance
- $method = $refund['type'] == PaymentProvider::TYPE_CHARGEBACK ? 'chargeback' : 'refund';
+ $method = $refund['type'] == self::TYPE_CHARGEBACK ? 'chargeback' : 'refund';
$this->wallet->{$method}($credit_amount, $refund['description'] ?? '');
$refund['amount'] = $amount * -1;
$refund['credit_amount'] = $credit_amount * -1;
$refund['currency_amount'] = round($amount * -1 / $exchange_rate);
$refund['currency'] = $this->currency;
$refund['wallet_id'] = $this->wallet_id;
$refund['provider'] = $this->provider;
$refund['vat_rate_id'] = $this->vat_rate_id;
- $refund['status'] = PaymentProvider::STATUS_PAID;
+ $refund['status'] = self::STATUS_PAID;
// FIXME: Refunds/chargebacks are out of the reseller comissioning for now
return self::createFromArray($refund);
}
/**
* Ensure the currency is appropriately cased.
*/
public function setCurrencyAttribute($currency)
{
$this->attributes['currency'] = strtoupper($currency);
}
/**
* The wallet to which this payment belongs.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function wallet()
{
return $this->belongsTo(Wallet::class, 'wallet_id', 'id');
}
/**
* The VAT rate assigned to this payment.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function vatRate()
{
return $this->belongsTo(VatRate::class, 'vat_rate_id', 'id');
}
}
diff --git a/src/app/Providers/Payment/Coinbase.php b/src/app/Providers/Payment/Coinbase.php
index 26d063af..11a3244b 100644
--- a/src/app/Providers/Payment/Coinbase.php
+++ b/src/app/Providers/Payment/Coinbase.php
@@ -1,410 +1,398 @@
<?php
namespace App\Providers\Payment;
use App\Payment;
use App\Utils;
use App\Wallet;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Request;
class Coinbase extends \App\Providers\PaymentProvider
{
/** @var \GuzzleHttp\Client|null HTTP client instance */
private $client = null;
/** @var \GuzzleHttp\Client|null test HTTP client instance */
public static $testClient = null;
private const SATOSHI_MULTIPLIER = 10000000;
/**
* Get a link to the customer in the provider's control panel
*
* @param \App\Wallet $wallet The wallet
*
* @return string|null The string representing <a> tag
*/
public function customerLink(Wallet $wallet): ?string
{
return null;
}
/**
* Create a new auto-payment mandate for a wallet.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data:
* - amount: Value in cents (optional)
* - currency: The operation currency
* - description: Operation desc.
* - methodId: Payment method
*
* @return array Provider payment data:
* - id: Operation identifier
* - redirectUrl: the location to redirect to
*/
public function createMandate(Wallet $wallet, array $payment): ?array
{
throw new \Exception("not implemented");
}
/**
* Revoke the auto-payment mandate for the wallet.
*
* @param \App\Wallet $wallet The wallet
*
* @return bool True on success, False on failure
*/
public function deleteMandate(Wallet $wallet): bool
{
throw new \Exception("not implemented");
}
/**
* Get a auto-payment mandate for the wallet.
*
* @param \App\Wallet $wallet The wallet
*
* @return array|null Mandate information:
* - id: Mandate identifier
* - method: user-friendly payment method desc.
* - methodId: Payment method
* - isPending: the process didn't complete yet
* - isValid: the mandate is valid
*/
public function getMandate(Wallet $wallet): ?array
{
throw new \Exception("not implemented");
}
/**
* Get a provider name
*
* @return string Provider name
*/
public function name(): string
{
return 'coinbase';
}
/**
* Creates HTTP client for connections to coinbase
*
* @return \GuzzleHttp\Client HTTP client instance
*/
private function client()
{
if (self::$testClient) {
return self::$testClient;
}
if (!$this->client) {
$this->client = new \GuzzleHttp\Client(
[
'http_errors' => false, // No exceptions from Guzzle
'base_uri' => 'https://api.commerce.coinbase.com/',
'verify' => \config('services.coinbase.api_verify_tls'),
'headers' => [
'X-CC-Api-Key' => \config('services.coinbase.key'),
'X-CC-Version' => '2018-03-22',
],
'connect_timeout' => 10,
'timeout' => 10,
'on_stats' => function (\GuzzleHttp\TransferStats $stats) {
$threshold = \config('logging.slow_log');
if ($threshold && ($sec = $stats->getTransferTime()) > $threshold) {
$url = $stats->getEffectiveUri();
$method = $stats->getRequest()->getMethod();
\Log::warning(sprintf("[STATS] %s %s: %.4f sec.", $method, $url, $sec));
}
},
]
);
}
return $this->client;
}
/**
* Create a new payment.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data:
* - amount: Value in cents
* - currency: The operation currency
* - type: oneoff/recurring
* - description: Operation desc.
* - methodId: Payment method
*
* @return array Provider payment data:
* - id: Operation identifier
* - redirectUrl: the location to redirect to
*/
public function payment(Wallet $wallet, array $payment): ?array
{
- if ($payment['type'] == self::TYPE_RECURRING) {
+ if ($payment['type'] == Payment::TYPE_RECURRING) {
throw new \Exception("not supported");
}
$amount = $payment['amount'] / 100;
$post = [
'json' => [
"name" => \config('app.name'),
"description" => $payment['description'],
"pricing_type" => "fixed_price",
'local_price' => [
'currency' => $wallet->currency,
'amount' => sprintf('%.2f', $amount),
],
'redirect_url' => self::redirectUrl()
]
];
$response = $this->client()->request('POST', '/charges/', $post);
$code = $response->getStatusCode();
if ($code == 429) {
$this->logError("Ratelimiting", $response);
throw new \Exception("Failed to create coinbase charge due to rate-limiting: {$code}");
}
if ($code !== 201) {
$this->logError("Failed to create coinbase charge", $response);
throw new \Exception("Failed to create coinbase charge: {$code}");
}
$json = json_decode($response->getBody(), true);
// Store the payment reference in database
- $payment['status'] = self::STATUS_OPEN;
+ $payment['status'] = Payment::STATUS_OPEN;
//We take the code instead of the id because it fits into our current db schema and the id doesn't
$payment['id'] = $json['data']['code'];
//We store in satoshis (the database stores it as INTEGER type)
$payment['currency_amount'] = $json['data']['pricing']['bitcoin']['amount'] * self::SATOSHI_MULTIPLIER;
$payment['currency'] = 'BTC';
$this->storePayment($payment, $wallet->id);
return [
'id' => $payment['id'],
'newWindowUrl' => $json['data']['hosted_url']
];
}
/**
* Log an error for a failed request to the meet server
*
* @param string $str The error string
* @param object $response Guzzle client response
*/
private function logError(string $str, $response)
{
$code = $response->getStatusCode();
if ($code != 200 && $code != 201) {
\Log::error(var_export($response));
$decoded = json_decode($response->getBody(), true);
$message = "";
if (
is_array($decoded) && array_key_exists('error', $decoded) &&
is_array($decoded['error']) && array_key_exists('message', $decoded['error'])
) {
$message = $decoded['error']['message'];
}
\Log::error("$str [$code]: $message");
}
}
/**
* Cancel a pending payment.
*
* @param \App\Wallet $wallet The wallet
* @param string $paymentId Payment Id
*
* @return bool True on success, False on failure
*/
public function cancel(Wallet $wallet, $paymentId): bool
{
$response = $this->client()->request('POST', "/charges/{$paymentId}/cancel");
if ($response->getStatusCode() == 200) {
$db_payment = Payment::find($paymentId);
- $db_payment->status = self::STATUS_CANCELED;
+ $db_payment->status = Payment::STATUS_CANCELED;
$db_payment->save();
} else {
$this->logError("Failed to cancel payment", $response);
return false;
}
return true;
}
/**
* Create a new automatic payment operation.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data (see self::payment())
*
* @return array Provider payment/session data:
* - id: Operation identifier
*/
protected function paymentRecurring(Wallet $wallet, array $payment): ?array
{
throw new \Exception("not available with coinbase");
}
private static function verifySignature($payload, $sigHeader)
{
$secret = \config('services.coinbase.webhook_secret');
$computedSignature = \hash_hmac('sha256', $payload, $secret);
if (!\hash_equals($sigHeader, $computedSignature)) {
throw new \Exception("Coinbase request signature verification failed");
}
}
/**
* Update payment status (and balance).
*
* @return int HTTP response code
*/
public function webhook(): int
{
// We cannot just use php://input as it's already "emptied" by the framework
$request = Request::instance();
$payload = $request->getContent();
$sigHeader = $request->header('X-CC-Webhook-Signature');
self::verifySignature($payload, $sigHeader);
$data = \json_decode($payload, true);
$event = $data['event'];
$type = $event['type'];
\Log::info("Coinbase webhook called " . $type);
if ($type == 'charge:created') {
return 200;
}
if ($type == 'charge:confirmed') {
return 200;
}
if ($type == 'charge:pending') {
return 200;
}
$payment_id = $event['data']['code'];
if (empty($payment_id)) {
\Log::warning(sprintf('Failed to find the payment for (%s)', $payment_id));
return 200;
}
$payment = Payment::find($payment_id);
if (empty($payment)) {
return 200;
}
- $newStatus = self::STATUS_PENDING;
+ $newStatus = Payment::STATUS_PENDING;
// Even if we receive the payment delayed, we still have the money, and therefore credit it.
if ($type == 'charge:resolved' || $type == 'charge:delayed') {
// The payment is paid. Update the balance
- if ($payment->status != self::STATUS_PAID && $payment->amount > 0) {
+ if ($payment->status != Payment::STATUS_PAID && $payment->amount > 0) {
$credit = true;
}
- $newStatus = self::STATUS_PAID;
+ $newStatus = Payment::STATUS_PAID;
} elseif ($type == 'charge:failed') {
// Note: I didn't find a way to get any description of the problem with a payment
\Log::info(sprintf('Coinbase payment failed (%s)', $payment->id));
- $newStatus = self::STATUS_FAILED;
+ $newStatus = Payment::STATUS_FAILED;
}
DB::beginTransaction();
// This is a sanity check, just in case the payment provider api
// sent us open -> paid -> open -> paid. So, we lock the payment after
// recivied a "final" state.
- $pending_states = [self::STATUS_OPEN, self::STATUS_PENDING, self::STATUS_AUTHORIZED];
+ $pending_states = [Payment::STATUS_OPEN, Payment::STATUS_PENDING, Payment::STATUS_AUTHORIZED];
if (in_array($payment->status, $pending_states)) {
$payment->status = $newStatus;
$payment->save();
}
if (!empty($credit)) {
- self::creditPayment($payment);
+ $payment->credit('Coinbase');
}
DB::commit();
return 200;
}
- /**
- * Apply the successful payment's pecunia to the wallet
- */
- protected static function creditPayment($payment)
- {
- // TODO: Localization?
- $description = 'Payment';
- $description .= " transaction {$payment->id} using Coinbase";
-
- $payment->wallet->credit($payment, $description);
- }
-
/**
* List supported payment methods.
*
* @param string $type The payment type for which we require a method (oneoff/recurring).
* @param string $currency Currency code
*
* @return array Array of array with available payment methods:
* - id: id of the method
* - name: User readable name of the payment method
* - minimumAmount: Minimum amount to be charged in cents
* - currency: Currency used for the method
* - exchangeRate: The projected exchange rate (actual rate is determined during payment)
* - icon: An icon (icon name) representing the method
*/
public function providerPaymentMethods(string $type, string $currency): array
{
$availableMethods = [];
- if ($type == self::TYPE_ONEOFF) {
+ if ($type == Payment::TYPE_ONEOFF) {
$availableMethods['bitcoin'] = [
'id' => 'bitcoin',
'name' => "Bitcoin",
'minimumAmount' => 0.001,
'currency' => 'BTC'
];
}
return $availableMethods;
}
/**
* Get a payment.
*
* @param string $paymentId Payment identifier
*
* @return array Payment information:
* - id: Payment identifier
* - status: Payment status
* - isCancelable: The payment can be canceled
* - checkoutUrl: The checkout url to complete the payment or null if none
*/
public function getPayment($paymentId): array
{
$payment = Payment::find($paymentId);
return [
'id' => $payment->id,
'status' => $payment->status,
'isCancelable' => true,
'checkoutUrl' => "https://commerce.coinbase.com/charges/{$paymentId}"
];
}
}
diff --git a/src/app/Providers/Payment/Mollie.php b/src/app/Providers/Payment/Mollie.php
index 9961feb2..4d957ba4 100644
--- a/src/app/Providers/Payment/Mollie.php
+++ b/src/app/Providers/Payment/Mollie.php
@@ -1,640 +1,629 @@
<?php
namespace App\Providers\Payment;
use App\Payment;
use App\Utils;
use App\Wallet;
use Illuminate\Support\Facades\DB;
use Mollie\Api\Exceptions\ApiException;
use Mollie\Api\Types;
class Mollie extends \App\Providers\PaymentProvider
{
/**
* Get a link to the customer in the provider's control panel
*
* @param \App\Wallet $wallet The wallet
*
* @return string|null The string representing <a> tag
*/
public function customerLink(Wallet $wallet): ?string
{
$customer_id = self::mollieCustomerId($wallet, false);
if (!$customer_id) {
return null;
}
return sprintf(
'<a href="https://www.mollie.com/dashboard/customers/%s" target="_blank">%s</a>',
$customer_id,
$customer_id
);
}
/**
* Validates that mollie available.
*
* @throws \Mollie\Api\Exceptions\ApiException on failure
* @return bool true on success
*/
public static function healthcheck()
{
mollie()->methods()->allActive();
return true;
}
/**
* Create a new auto-payment mandate for a wallet.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data:
* - amount: Value in cents (optional)
* - currency: The operation currency
* - description: Operation desc.
* - methodId: Payment method
*
* @return array Provider payment data:
* - id: Operation identifier
* - redirectUrl: the location to redirect to
*/
public function createMandate(Wallet $wallet, array $payment): ?array
{
// Register the user in Mollie, if not yet done
$customer_id = self::mollieCustomerId($wallet, true);
if (!isset($payment['amount'])) {
$payment['amount'] = 0;
}
$amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']);
$payment['currency_amount'] = $amount;
$request = [
'amount' => [
'currency' => $payment['currency'],
'value' => sprintf('%.2f', $amount / 100),
],
'customerId' => $customer_id,
'sequenceType' => 'first',
'description' => $payment['description'],
'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'),
'redirectUrl' => self::redirectUrl(),
'locale' => 'en_US',
'method' => $payment['methodId']
];
// Create the payment in Mollie
$response = mollie()->payments()->create($request);
if ($response->mandateId) {
$wallet->setSetting('mollie_mandate_id', $response->mandateId);
}
// Store the payment reference in database
$payment['status'] = $response->status;
$payment['id'] = $response->id;
- $payment['type'] = self::TYPE_MANDATE;
+ $payment['type'] = Payment::TYPE_MANDATE;
$this->storePayment($payment, $wallet->id);
return [
'id' => $response->id,
'redirectUrl' => $response->getCheckoutUrl(),
];
}
/**
* Revoke the auto-payment mandate for the wallet.
*
* @param \App\Wallet $wallet The wallet
*
* @return bool True on success, False on failure
*/
public function deleteMandate(Wallet $wallet): bool
{
// Get the Mandate info
$mandate = self::mollieMandate($wallet);
// Revoke the mandate on Mollie
if ($mandate) {
$mandate->revoke();
$wallet->setSetting('mollie_mandate_id', null);
}
return true;
}
/**
* Get a auto-payment mandate for the wallet.
*
* @param \App\Wallet $wallet The wallet
*
* @return array|null Mandate information:
* - id: Mandate identifier
* - method: user-friendly payment method desc.
* - methodId: Payment method
* - isPending: the process didn't complete yet
* - isValid: the mandate is valid
*/
public function getMandate(Wallet $wallet): ?array
{
// Get the Mandate info
$mandate = self::mollieMandate($wallet);
if (empty($mandate)) {
return null;
}
$result = [
'id' => $mandate->id,
'isPending' => $mandate->isPending(),
'isValid' => $mandate->isValid(),
'method' => self::paymentMethod($mandate, 'Unknown method'),
'methodId' => $mandate->method
];
return $result;
}
/**
* Get a provider name
*
* @return string Provider name
*/
public function name(): string
{
return 'mollie';
}
/**
* Create a new payment.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data:
* - amount: Value in cents
* - currency: The operation currency
* - type: oneoff/recurring
* - description: Operation desc.
* - methodId: Payment method
*
* @return array Provider payment data:
* - id: Operation identifier
* - redirectUrl: the location to redirect to
*/
public function payment(Wallet $wallet, array $payment): ?array
{
- if ($payment['type'] == self::TYPE_RECURRING) {
+ if ($payment['type'] == Payment::TYPE_RECURRING) {
return $this->paymentRecurring($wallet, $payment);
}
// Register the user in Mollie, if not yet done
$customer_id = self::mollieCustomerId($wallet, true);
$amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']);
$payment['currency_amount'] = $amount;
// Note: Required fields: description, amount/currency, amount/value
$request = [
'amount' => [
'currency' => $payment['currency'],
// a number with two decimals is required (note that JPK and ISK don't require decimals,
// but we're not using them currently)
'value' => sprintf('%.2f', $amount / 100),
],
'customerId' => $customer_id,
'sequenceType' => $payment['type'],
'description' => $payment['description'],
'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'),
'locale' => 'en_US',
'method' => $payment['methodId'],
'redirectUrl' => self::redirectUrl() // required for non-recurring payments
];
// TODO: Additional payment parameters for better fraud protection:
// billingEmail - for bank transfers, Przelewy24, but not creditcard
// billingAddress (it is a structured field not just text)
// Create the payment in Mollie
$response = mollie()->payments()->create($request);
// Store the payment reference in database
$payment['status'] = $response->status;
$payment['id'] = $response->id;
$this->storePayment($payment, $wallet->id);
return [
'id' => $payment['id'],
'redirectUrl' => $response->getCheckoutUrl(),
];
}
-
/**
* Cancel a pending payment.
*
* @param \App\Wallet $wallet The wallet
* @param string $paymentId Payment Id
*
* @return bool True on success, False on failure
*/
public function cancel(Wallet $wallet, $paymentId): bool
{
$response = mollie()->payments()->delete($paymentId);
$db_payment = Payment::find($paymentId);
$db_payment->status = $response->status;
$db_payment->save();
return true;
}
-
/**
* Create a new automatic payment operation.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data (see self::payment())
*
* @return array Provider payment/session data:
* - id: Operation identifier
*/
protected function paymentRecurring(Wallet $wallet, array $payment): ?array
{
// Check if there's a valid mandate
$mandate = self::mollieMandate($wallet);
if (empty($mandate) || !$mandate->isValid() || $mandate->isPending()) {
\Log::debug("Recurring payment for {$wallet->id}: no valid Mollie mandate");
return null;
}
$customer_id = self::mollieCustomerId($wallet, true);
// Note: Required fields: description, amount/currency, amount/value
$amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']);
$payment['currency_amount'] = $amount;
$request = [
'amount' => [
'currency' => $payment['currency'],
// a number with two decimals is required
'value' => sprintf('%.2f', $amount / 100),
],
'customerId' => $customer_id,
'sequenceType' => $payment['type'],
'description' => $payment['description'],
'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'),
'locale' => 'en_US',
'method' => $payment['methodId'],
'mandateId' => $mandate->id
];
\Log::debug("Recurring payment for {$wallet->id}: " . json_encode($request));
// Create the payment in Mollie
$response = mollie()->payments()->create($request);
// Store the payment reference in database
$payment['status'] = $response->status;
$payment['id'] = $response->id;
DB::beginTransaction();
$payment = $this->storePayment($payment, $wallet->id);
// Mollie can return 'paid' status immediately, so we don't
// have to wait for the webhook. What's more, the webhook would ignore
// the payment because it will be marked as paid before the webhook.
// Let's handle paid status here too.
if ($response->isPaid()) {
self::creditPayment($payment, $response);
$notify = true;
} elseif ($response->isFailed()) {
// Note: I didn't find a way to get any description of the problem with a payment
\Log::info(sprintf('Mollie payment failed (%s)', $response->id));
// Disable the mandate
$wallet->setSetting('mandate_disabled', 1);
$notify = true;
}
DB::commit();
if (!empty($notify)) {
\App\Jobs\PaymentEmail::dispatch($payment);
}
return [
'id' => $payment['id'],
];
}
/**
* Update payment status (and balance).
*
* @return int HTTP response code
*/
public function webhook(): int
{
$payment_id = \request()->input('id');
if (empty($payment_id)) {
return 200;
}
$payment = Payment::find($payment_id);
if (empty($payment)) {
// Mollie recommends to return "200 OK" even if the payment does not exist
return 200;
}
try {
// Get the payment details from Mollie
// TODO: Consider https://github.com/mollie/mollie-api-php/issues/502 when it's fixed
$mollie_payment = mollie()->payments()->get($payment_id);
$refunds = [];
if ($mollie_payment->isPaid()) {
// The payment is paid. Update the balance, and notify the user
- if ($payment->status != self::STATUS_PAID && $payment->amount > 0) {
+ if ($payment->status != Payment::STATUS_PAID && $payment->amount > 0) {
$credit = true;
- $notify = $payment->type == self::TYPE_RECURRING;
+ $notify = $payment->type == Payment::TYPE_RECURRING;
}
// The payment has been (partially) refunded.
// Let's process refunds with status "refunded".
if ($mollie_payment->hasRefunds()) {
foreach ($mollie_payment->refunds() as $refund) {
if ($refund->isTransferred() && $refund->amount->value) {
$refunds[] = [
'id' => $refund->id,
'description' => $refund->description,
'amount' => round(floatval($refund->amount->value) * 100),
- 'type' => self::TYPE_REFUND,
+ 'type' => Payment::TYPE_REFUND,
'currency' => $refund->amount->currency
];
}
}
}
// The payment has been (partially) charged back.
// Let's process chargebacks (they have no states as refunds)
if ($mollie_payment->hasChargebacks()) {
foreach ($mollie_payment->chargebacks() as $chargeback) {
if ($chargeback->amount->value) {
$refunds[] = [
'id' => $chargeback->id,
'amount' => round(floatval($chargeback->amount->value) * 100),
- 'type' => self::TYPE_CHARGEBACK,
+ 'type' => Payment::TYPE_CHARGEBACK,
'currency' => $chargeback->amount->currency
];
}
}
}
// In case there were multiple auto-payment setup requests (e.g. caused by a double
// form submission) we end up with multiple payment records and mollie_mandate_id
// pointing to the one from the last payment not the successful one.
// We make sure to use mandate id from the successful "first" payment.
if (
- $payment->type == self::TYPE_MANDATE
+ $payment->type == Payment::TYPE_MANDATE
&& $mollie_payment->mandateId
&& $mollie_payment->sequenceType == Types\SequenceType::SEQUENCETYPE_FIRST
) {
$payment->wallet->setSetting('mollie_mandate_id', $mollie_payment->mandateId);
}
} elseif ($mollie_payment->isFailed()) {
// Note: I didn't find a way to get any description of the problem with a payment
\Log::info(sprintf('Mollie payment failed (%s)', $payment->id));
// Disable the mandate
- if ($payment->type == self::TYPE_RECURRING) {
+ if ($payment->type == Payment::TYPE_RECURRING) {
$notify = true;
$payment->wallet->setSetting('mandate_disabled', 1);
}
}
DB::beginTransaction();
// This is a sanity check, just in case the payment provider api
// sent us open -> paid -> open -> paid. So, we lock the payment after
// recivied a "final" state.
- $pending_states = [self::STATUS_OPEN, self::STATUS_PENDING, self::STATUS_AUTHORIZED];
+ $pending_states = [Payment::STATUS_OPEN, Payment::STATUS_PENDING, Payment::STATUS_AUTHORIZED];
if (in_array($payment->status, $pending_states)) {
$payment->status = $mollie_payment->status;
$payment->save();
}
if (!empty($credit)) {
self::creditPayment($payment, $mollie_payment);
}
foreach ($refunds as $refund) {
$payment->refund($refund);
}
DB::commit();
if (!empty($notify)) {
\App\Jobs\PaymentEmail::dispatch($payment);
}
} catch (\Mollie\Api\Exceptions\ApiException $e) {
\Log::warning(sprintf('Mollie api call failed (%s)', $e->getMessage()));
}
return 200;
}
/**
* Get Mollie customer identifier for specified wallet.
* Create one if does not exist yet.
*
* @param \App\Wallet $wallet The wallet
* @param bool $create Create the customer if does not exist yet
*
* @return ?string Mollie customer identifier
*/
protected static function mollieCustomerId(Wallet $wallet, bool $create = false): ?string
{
$customer_id = $wallet->getSetting('mollie_id');
// Register the user in Mollie
if (empty($customer_id) && $create) {
$customer = mollie()->customers()->create([
'name' => $wallet->owner->name(),
'email' => $wallet->id . '@private.' . \config('app.domain'),
]);
$customer_id = $customer->id;
$wallet->setSetting('mollie_id', $customer->id);
}
return $customer_id;
}
/**
* Get the active Mollie auto-payment mandate
*/
protected static function mollieMandate(Wallet $wallet)
{
$settings = $wallet->getSettings(['mollie_id', 'mollie_mandate_id']);
// Get the manadate reference we already have
if ($settings['mollie_id'] && $settings['mollie_mandate_id']) {
try {
return mollie()->mandates()->getForId($settings['mollie_id'], $settings['mollie_mandate_id']);
} catch (ApiException $e) {
// FIXME: What about 404?
if ($e->getCode() == 410) {
// The mandate is gone, remove the reference
$wallet->setSetting('mollie_mandate_id', null);
return null;
}
// TODO: Maybe we shouldn't always throw? It make sense in the job
// but for example when we're just fetching wallet info...
throw $e;
}
}
}
/**
* Apply the successful payment's pecunia to the wallet
*/
protected static function creditPayment($payment, $mollie_payment)
{
// Extract the payment method for transaction description
$method = self::paymentMethod($mollie_payment, 'Mollie');
- // TODO: Localization?
- $description = $payment->type == self::TYPE_RECURRING ? 'Auto-payment' : 'Payment';
- $description .= " transaction {$payment->id} using {$method}";
-
- $payment->wallet->credit($payment, $description);
-
- // Unlock the disabled auto-payment mandate
- if ($payment->wallet->balance >= 0) {
- $payment->wallet->setSetting('mandate_disabled', null);
- }
+ $payment->credit($method);
}
/**
* Extract payment method description from Mollie payment/mandate details
*/
protected static function paymentMethod($object, $default = ''): string
{
$details = $object->details;
// Mollie supports 3 methods here
switch ($object->method) {
case self::METHOD_CREDITCARD:
// If the customer started, but never finished the 'first' payment
// card details will be empty, and mandate will be 'pending'.
if (empty($details->cardNumber)) {
return 'Credit Card';
}
return sprintf(
'%s (**** **** **** %s)',
$details->cardLabel ?: 'Card', // @phpstan-ignore-line
$details->cardNumber
);
case self::METHOD_DIRECTDEBIT:
return sprintf('Direct Debit (%s)', $details->customerAccount);
case self::METHOD_PAYPAL:
return sprintf('PayPal (%s)', $details->consumerAccount);
}
return $default;
}
/**
* List supported payment methods.
*
* @param string $type The payment type for which we require a method (oneoff/recurring).
* @param string $currency Currency code
*
* @return array Array of array with available payment methods:
* - id: id of the method
* - name: User readable name of the payment method
* - minimumAmount: Minimum amount to be charged in cents
* - currency: Currency used for the method
* - exchangeRate: The projected exchange rate (actual rate is determined during payment)
* - icon: An icon (icon name) representing the method
*/
public function providerPaymentMethods(string $type, string $currency): array
{
// Prefer methods in the system currency
$providerMethods = (array) mollie()->methods()->allActive(
[
'sequenceType' => $type,
'amount' => [
'value' => '1.00',
'currency' => $currency
]
]
);
// Get EUR methods (e.g. bank transfers are in EUR only)
if ($currency != 'EUR') {
$eurMethods = (array) mollie()->methods()->allActive(
[
'sequenceType' => $type,
'amount' => [
'value' => '1.00',
'currency' => 'EUR'
]
]
);
// Later provider methods will override earlier ones
$providerMethods = array_merge($eurMethods, $providerMethods);
}
$availableMethods = [];
foreach ($providerMethods as $method) {
$availableMethods[$method->id] = [
'id' => $method->id,
'name' => $method->description,
'minimumAmount' => round(floatval($method->minimumAmount->value) * 100), // Converted to cents
'currency' => $method->minimumAmount->currency,
'exchangeRate' => \App\Utils::exchangeRate($currency, $method->minimumAmount->currency)
];
}
return $availableMethods;
}
/**
* Get a payment.
*
* @param string $paymentId Payment identifier
*
* @return array Payment information:
* - id: Payment identifier
* - status: Payment status
* - isCancelable: The payment can be canceled
* - checkoutUrl: The checkout url to complete the payment or null if none
*/
public function getPayment($paymentId): array
{
$payment = mollie()->payments()->get($paymentId);
return [
'id' => $payment->id,
'status' => $payment->status,
'isCancelable' => $payment->isCancelable,
'checkoutUrl' => $payment->getCheckoutUrl()
];
}
}
diff --git a/src/app/Providers/Payment/Stripe.php b/src/app/Providers/Payment/Stripe.php
index f13efb50..76bc47aa 100644
--- a/src/app/Providers/Payment/Stripe.php
+++ b/src/app/Providers/Payment/Stripe.php
@@ -1,560 +1,551 @@
<?php
namespace App\Providers\Payment;
use App\Payment;
use App\Utils;
use App\Wallet;
use App\WalletSetting;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Request;
use Stripe as StripeAPI;
class Stripe extends \App\Providers\PaymentProvider
{
/**
* Class constructor.
*/
public function __construct()
{
StripeAPI\Stripe::setApiKey(\config('services.stripe.key'));
}
/**
* Get a link to the customer in the provider's control panel
*
* @param \App\Wallet $wallet The wallet
*
* @return string|null The string representing <a> tag
*/
public function customerLink(Wallet $wallet): ?string
{
$customer_id = self::stripeCustomerId($wallet, false);
if (!$customer_id) {
return null;
}
$location = 'https://dashboard.stripe.com';
$key = \config('services.stripe.key');
if (strpos($key, 'sk_test_') === 0) {
$location .= '/test';
}
return sprintf(
'<a href="%s/customers/%s" target="_blank">%s</a>',
$location,
$customer_id,
$customer_id
);
}
/**
* Create a new auto-payment mandate for a wallet.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data:
* - amount: Value in cents (not used)
* - currency: The operation currency
* - description: Operation desc.
*
* @return array Provider payment/session data:
* - id: Session identifier
*/
public function createMandate(Wallet $wallet, array $payment): ?array
{
// Register the user in Stripe, if not yet done
$customer_id = self::stripeCustomerId($wallet, true);
$request = [
'customer' => $customer_id,
'cancel_url' => self::redirectUrl(), // required
'success_url' => self::redirectUrl(), // required
'payment_method_types' => ['card'], // required
'locale' => 'en',
'mode' => 'setup',
];
// Note: Stripe does not allow to set amount for 'setup' operation
// We'll dispatch WalletCharge job when we receive a webhook request
$session = StripeAPI\Checkout\Session::create($request);
$payment['amount'] = 0;
$payment['credit_amount'] = 0;
$payment['currency_amount'] = 0;
$payment['vat_rate_id'] = null;
$payment['id'] = $session->setup_intent;
- $payment['type'] = self::TYPE_MANDATE;
+ $payment['type'] = Payment::TYPE_MANDATE;
$this->storePayment($payment, $wallet->id);
return [
'id' => $session->id,
];
}
/**
* Revoke the auto-payment mandate.
*
* @param \App\Wallet $wallet The wallet
*
* @return bool True on success, False on failure
*/
public function deleteMandate(Wallet $wallet): bool
{
// Get the Mandate info
$mandate = self::stripeMandate($wallet);
if ($mandate) {
// Remove the reference
$wallet->setSetting('stripe_mandate_id', null);
// Detach the payment method on Stripe
$pm = StripeAPI\PaymentMethod::retrieve($mandate->payment_method);
$pm->detach();
}
return true;
}
/**
* Get a auto-payment mandate for a wallet.
*
* @param \App\Wallet $wallet The wallet
*
* @return array|null Mandate information:
* - id: Mandate identifier
* - method: user-friendly payment method desc.
* - isPending: the process didn't complete yet
* - isValid: the mandate is valid
*/
public function getMandate(Wallet $wallet): ?array
{
// Get the Mandate info
$mandate = self::stripeMandate($wallet);
if (empty($mandate)) {
return null;
}
$pm = StripeAPI\PaymentMethod::retrieve($mandate->payment_method);
$result = [
'id' => $mandate->id,
'isPending' => $mandate->status != 'succeeded' && $mandate->status != 'canceled',
'isValid' => $mandate->status == 'succeeded',
'method' => self::paymentMethod($pm, 'Unknown method')
];
return $result;
}
/**
* Get a provider name
*
* @return string Provider name
*/
public function name(): string
{
return 'stripe';
}
/**
* Create a new payment.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data:
* - amount: Value in cents
* - currency: The operation currency
* - type: first/oneoff/recurring
* - description: Operation desc.
*
* @return array Provider payment/session data:
* - id: Session identifier
*/
public function payment(Wallet $wallet, array $payment): ?array
{
- if ($payment['type'] == self::TYPE_RECURRING) {
+ if ($payment['type'] == Payment::TYPE_RECURRING) {
return $this->paymentRecurring($wallet, $payment);
}
// Register the user in Stripe, if not yet done
$customer_id = self::stripeCustomerId($wallet, true);
$amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']);
$payment['currency_amount'] = $amount;
$request = [
'customer' => $customer_id,
'cancel_url' => self::redirectUrl(), // required
'success_url' => self::redirectUrl(), // required
'payment_method_types' => ['card'], // required
'locale' => 'en',
'line_items' => [
[
'name' => $payment['description'],
'amount' => $amount,
'currency' => \strtolower($payment['currency']),
'quantity' => 1,
]
]
];
$session = StripeAPI\Checkout\Session::create($request);
// Store the payment reference in database
$payment['id'] = $session->payment_intent;
$this->storePayment($payment, $wallet->id);
return [
'id' => $session->id,
];
}
/**
* Create a new automatic payment operation.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data (see self::payment())
*
* @return array Provider payment/session data:
* - id: Session identifier
*/
protected function paymentRecurring(Wallet $wallet, array $payment): ?array
{
// Check if there's a valid mandate
$mandate = self::stripeMandate($wallet);
if (empty($mandate)) {
return null;
}
$amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']);
$payment['currency_amount'] = $amount;
$request = [
'amount' => $amount,
'currency' => \strtolower($payment['currency']),
'description' => $payment['description'],
'receipt_email' => $wallet->owner->email,
'customer' => $mandate->customer,
'payment_method' => $mandate->payment_method,
'off_session' => true,
'confirm' => true,
];
$intent = StripeAPI\PaymentIntent::create($request);
// Store the payment reference in database
$payment['id'] = $intent->id;
$this->storePayment($payment, $wallet->id);
return [
'id' => $payment['id'],
];
}
/**
* Update payment status (and balance).
*
* @return int HTTP response code
*/
public function webhook(): int
{
// We cannot just use php://input as it's already "emptied" by the framework
// $payload = file_get_contents('php://input');
$request = Request::instance();
$payload = $request->getContent();
$sig_header = $request->header('Stripe-Signature');
// Parse and validate the input
try {
$event = StripeAPI\Webhook::constructEvent(
$payload,
$sig_header,
\config('services.stripe.webhook_secret')
);
} catch (\Exception $e) {
\Log::error("Invalid payload: " . $e->getMessage());
// Invalid payload
return 400;
}
switch ($event->type) {
case StripeAPI\Event::PAYMENT_INTENT_CANCELED:
case StripeAPI\Event::PAYMENT_INTENT_PAYMENT_FAILED:
case StripeAPI\Event::PAYMENT_INTENT_SUCCEEDED:
$intent = $event->data->object; // @phpstan-ignore-line
$payment = Payment::find($intent->id);
- if (empty($payment) || $payment->type == self::TYPE_MANDATE) {
+ if (empty($payment) || $payment->type == Payment::TYPE_MANDATE) {
return 404;
}
switch ($intent->status) {
case StripeAPI\PaymentIntent::STATUS_CANCELED:
- $status = self::STATUS_CANCELED;
+ $status = Payment::STATUS_CANCELED;
break;
case StripeAPI\PaymentIntent::STATUS_SUCCEEDED:
- $status = self::STATUS_PAID;
+ $status = Payment::STATUS_PAID;
break;
default:
- $status = self::STATUS_FAILED;
+ $status = Payment::STATUS_FAILED;
}
DB::beginTransaction();
- if ($status == self::STATUS_PAID) {
+ if ($status == Payment::STATUS_PAID) {
// Update the balance, if it wasn't already
- if ($payment->status != self::STATUS_PAID) {
+ if ($payment->status != Payment::STATUS_PAID) {
$this->creditPayment($payment, $intent);
}
} else {
if (!empty($intent->last_payment_error)) {
// See https://stripe.com/docs/error-codes for more info
\Log::info(sprintf(
'Stripe payment failed (%s): %s',
$payment->id,
json_encode($intent->last_payment_error)
));
}
}
- if ($payment->status != self::STATUS_PAID) {
+ if ($payment->status != Payment::STATUS_PAID) {
$payment->status = $status;
$payment->save();
- if ($status != self::STATUS_CANCELED && $payment->type == self::TYPE_RECURRING) {
+ if ($status != Payment::STATUS_CANCELED && $payment->type == Payment::TYPE_RECURRING) {
// Disable the mandate
- if ($status == self::STATUS_FAILED) {
+ if ($status == Payment::STATUS_FAILED) {
$payment->wallet->setSetting('mandate_disabled', 1);
}
// Notify the user
\App\Jobs\PaymentEmail::dispatch($payment);
}
}
DB::commit();
break;
case StripeAPI\Event::SETUP_INTENT_SUCCEEDED:
case StripeAPI\Event::SETUP_INTENT_SETUP_FAILED:
case StripeAPI\Event::SETUP_INTENT_CANCELED:
$intent = $event->data->object; // @phpstan-ignore-line
$payment = Payment::find($intent->id);
- if (empty($payment) || $payment->type != self::TYPE_MANDATE) {
+ if (empty($payment) || $payment->type != Payment::TYPE_MANDATE) {
return 404;
}
switch ($intent->status) {
case StripeAPI\SetupIntent::STATUS_CANCELED:
- $status = self::STATUS_CANCELED;
+ $status = Payment::STATUS_CANCELED;
break;
case StripeAPI\SetupIntent::STATUS_SUCCEEDED:
- $status = self::STATUS_PAID;
+ $status = Payment::STATUS_PAID;
break;
default:
- $status = self::STATUS_FAILED;
+ $status = Payment::STATUS_FAILED;
}
- if ($status == self::STATUS_PAID) {
+ if ($status == Payment::STATUS_PAID) {
$payment->wallet->setSetting('stripe_mandate_id', $intent->id);
$threshold = intval((float) $payment->wallet->getSetting('mandate_balance') * 100);
// Top-up the wallet if balance is below the threshold
- if ($payment->wallet->balance < $threshold && $payment->status != self::STATUS_PAID) {
+ if ($payment->wallet->balance < $threshold && $payment->status != Payment::STATUS_PAID) {
\App\Jobs\WalletCharge::dispatch($payment->wallet);
}
}
$payment->status = $status;
$payment->save();
break;
default:
\Log::debug("Unhandled Stripe event: " . var_export($payload, true));
break;
}
return 200;
}
/**
* Get Stripe customer identifier for specified wallet.
* Create one if does not exist yet.
*
* @param \App\Wallet $wallet The wallet
* @param bool $create Create the customer if does not exist yet
*
* @return string|null Stripe customer identifier
*/
protected static function stripeCustomerId(Wallet $wallet, bool $create = false): ?string
{
$customer_id = $wallet->getSetting('stripe_id');
// Register the user in Stripe
if (empty($customer_id) && $create) {
$customer = StripeAPI\Customer::create([
'name' => $wallet->owner->name(),
// Stripe will display the email on Checkout page, editable,
// and use it to send the receipt (?), use the user email here
// 'email' => $wallet->id . '@private.' . \config('app.domain'),
'email' => $wallet->owner->email,
]);
$customer_id = $customer->id;
$wallet->setSetting('stripe_id', $customer->id);
}
return $customer_id;
}
/**
* Get the active Stripe auto-payment mandate (Setup Intent)
*/
protected static function stripeMandate(Wallet $wallet)
{
// Note: Stripe also has 'Mandate' objects, but we do not use these
if ($mandate_id = $wallet->getSetting('stripe_mandate_id')) {
$mandate = StripeAPI\SetupIntent::retrieve($mandate_id);
// @phpstan-ignore-next-line
if ($mandate && $mandate->status != 'canceled') {
return $mandate;
}
}
}
/**
* Apply the successful payment's pecunia to the wallet
*/
protected static function creditPayment(Payment $payment, $intent)
{
$method = 'Stripe';
// Extract the payment method for transaction description
if (
!empty($intent->charges)
&& ($charge = $intent->charges->data[0])
&& ($pm = $charge->payment_method_details)
) {
$method = self::paymentMethod($pm);
}
- // TODO: Localization?
- $description = $payment->type == self::TYPE_RECURRING ? 'Auto-payment' : 'Payment';
- $description .= " transaction {$payment->id} using {$method}";
-
- $payment->wallet->credit($payment, $description);
-
- // Unlock the disabled auto-payment mandate
- if ($payment->wallet->balance >= 0) {
- $payment->wallet->setSetting('mandate_disabled', null);
- }
+ $payment->credit($method);
}
/**
* Extract payment method description from Stripe payment details
*/
protected static function paymentMethod($details, $default = ''): string
{
switch ($details->type) {
case 'card':
// TODO: card number
return \sprintf(
'%s (**** **** **** %s)',
\ucfirst($details->card->brand) ?: 'Card',
$details->card->last4
);
}
return $default;
}
/**
* List supported payment methods.
*
* @param string $type The payment type for which we require a method (oneoff/recurring).
* @param string $currency Currency code
*
* @return array Array of array with available payment methods:
* - id: id of the method
* - name: User readable name of the payment method
* - minimumAmount: Minimum amount to be charged in cents
* - currency: Currency used for the method
* - exchangeRate: The projected exchange rate (actual rate is determined during payment)
* - icon: An icon (icon name) representing the method
*/
public function providerPaymentMethods(string $type, string $currency): array
{
//TODO get this from the stripe API?
$availableMethods = [];
switch ($type) {
- case self::TYPE_ONEOFF:
+ case Payment::TYPE_ONEOFF:
$availableMethods = [
self::METHOD_CREDITCARD => [
'id' => self::METHOD_CREDITCARD,
'name' => "Credit Card",
- 'minimumAmount' => self::MIN_AMOUNT,
+ 'minimumAmount' => Payment::MIN_AMOUNT,
'currency' => $currency,
'exchangeRate' => 1.0
],
self::METHOD_PAYPAL => [
'id' => self::METHOD_PAYPAL,
'name' => "PayPal",
- 'minimumAmount' => self::MIN_AMOUNT,
+ 'minimumAmount' => Payment::MIN_AMOUNT,
'currency' => $currency,
'exchangeRate' => 1.0
]
];
break;
- case self::TYPE_RECURRING:
+ case Payment::TYPE_RECURRING:
$availableMethods = [
self::METHOD_CREDITCARD => [
'id' => self::METHOD_CREDITCARD,
'name' => "Credit Card",
- 'minimumAmount' => self::MIN_AMOUNT, // Converted to cents,
+ 'minimumAmount' => Payment::MIN_AMOUNT, // Converted to cents,
'currency' => $currency,
'exchangeRate' => 1.0
]
];
break;
}
return $availableMethods;
}
/**
* Get a payment.
*
* @param string $paymentId Payment identifier
*
* @return array Payment information:
* - id: Payment identifier
* - status: Payment status
* - isCancelable: The payment can be canceled
* - checkoutUrl: The checkout url to complete the payment or null if none
*/
public function getPayment($paymentId): array
{
\Log::info("Stripe::getPayment does not yet retrieve a checkoutUrl.");
$payment = StripeAPI\PaymentIntent::retrieve($paymentId);
return [
'id' => $payment->id,
'status' => $payment->status,
'isCancelable' => false,
'checkoutUrl' => null
];
}
}
diff --git a/src/app/Providers/PaymentProvider.php b/src/app/Providers/PaymentProvider.php
index d85fb049..ad78a1ac 100644
--- a/src/app/Providers/PaymentProvider.php
+++ b/src/app/Providers/PaymentProvider.php
@@ -1,346 +1,329 @@
<?php
namespace App\Providers;
-use App\Transaction;
use App\Payment;
+use App\Transaction;
use App\Wallet;
use Illuminate\Support\Facades\Cache;
abstract class PaymentProvider
{
- public const STATUS_OPEN = 'open';
- public const STATUS_CANCELED = 'canceled';
- public const STATUS_PENDING = 'pending';
- public const STATUS_AUTHORIZED = 'authorized';
- public const STATUS_EXPIRED = 'expired';
- public const STATUS_FAILED = 'failed';
- public const STATUS_PAID = 'paid';
-
- public const TYPE_ONEOFF = 'oneoff';
- public const TYPE_RECURRING = 'recurring';
- public const TYPE_MANDATE = 'mandate';
- public const TYPE_REFUND = 'refund';
- public const TYPE_CHARGEBACK = 'chargeback';
-
public const METHOD_CREDITCARD = 'creditcard';
public const METHOD_PAYPAL = 'paypal';
public const METHOD_BANKTRANSFER = 'banktransfer';
public const METHOD_DIRECTDEBIT = 'directdebit';
public const METHOD_BITCOIN = 'bitcoin';
public const PROVIDER_MOLLIE = 'mollie';
public const PROVIDER_STRIPE = 'stripe';
public const PROVIDER_COINBASE = 'coinbase';
- /** const int Minimum amount of money in a single payment (in cents) */
- public const MIN_AMOUNT = 1000;
-
private static $paymentMethodIcons = [
self::METHOD_CREDITCARD => ['prefix' => 'far', 'name' => 'credit-card'],
self::METHOD_PAYPAL => ['prefix' => 'fab', 'name' => 'paypal'],
self::METHOD_BANKTRANSFER => ['prefix' => 'fas', 'name' => 'building-columns'],
self::METHOD_BITCOIN => ['prefix' => 'fab', 'name' => 'bitcoin'],
];
/**
* Detect the name of the provider
*
* @param \App\Wallet|string|null $provider_or_wallet
* @return string The name of the provider
*/
private static function providerName($provider_or_wallet = null): string
{
if ($provider_or_wallet instanceof Wallet) {
$settings = $provider_or_wallet->getSettings(['stripe_id', 'mollie_id']);
if ($settings['stripe_id']) {
$provider = self::PROVIDER_STRIPE;
} elseif ($settings['mollie_id']) {
$provider = self::PROVIDER_MOLLIE;
}
} else {
$provider = $provider_or_wallet;
}
if (empty($provider)) {
$provider = \config('services.payment_provider') ?: self::PROVIDER_MOLLIE;
}
return \strtolower($provider);
}
/**
* Factory method
*
* @param \App\Wallet|string|null $provider_or_wallet
*/
public static function factory($provider_or_wallet = null, $currency = null)
{
if (\strtolower($currency) == 'btc') {
return new \App\Providers\Payment\Coinbase();
}
switch (self::providerName($provider_or_wallet)) {
case self::PROVIDER_STRIPE:
return new \App\Providers\Payment\Stripe();
case self::PROVIDER_MOLLIE:
return new \App\Providers\Payment\Mollie();
case self::PROVIDER_COINBASE:
return new \App\Providers\Payment\Coinbase();
default:
throw new \Exception("Invalid payment provider: {$provider_or_wallet}");
}
}
/**
* Create a new auto-payment mandate for a wallet.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data:
* - amount: Value in cents (wallet currency)
* - credit_amount: Balance'able base amount in cents (wallet currency)
* - vat_rate_id: VAT rate id
* - currency: The operation currency
* - description: Operation desc.
* - methodId: Payment method
*
* @return array Provider payment data:
* - id: Operation identifier
* - redirectUrl: the location to redirect to
*/
abstract public function createMandate(Wallet $wallet, array $payment): ?array;
/**
* Revoke the auto-payment mandate for a wallet.
*
* @param \App\Wallet $wallet The wallet
*
* @return bool True on success, False on failure
*/
abstract public function deleteMandate(Wallet $wallet): bool;
/**
* Get a auto-payment mandate for a wallet.
*
* @param \App\Wallet $wallet The wallet
*
* @return array|null Mandate information:
* - id: Mandate identifier
* - method: user-friendly payment method desc.
* - methodId: Payment method
* - isPending: the process didn't complete yet
* - isValid: the mandate is valid
*/
abstract public function getMandate(Wallet $wallet): ?array;
/**
* Get a link to the customer in the provider's control panel
*
* @param \App\Wallet $wallet The wallet
*
* @return string|null The string representing <a> tag
*/
abstract public function customerLink(Wallet $wallet): ?string;
/**
* Get a provider name
*
* @return string Provider name
*/
abstract public function name(): string;
/**
* Create a new payment.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data:
* - amount: Value in cents (wallet currency)
* - credit_amount: Balance'able base amount in cents (wallet currency)
* - vat_rate_id: Vat rate id
* - currency: The operation currency
* - type: first/oneoff/recurring
* - description: Operation description
* - methodId: Payment method
*
* @return array Provider payment/session data:
* - id: Operation identifier
* - redirectUrl
*/
abstract public function payment(Wallet $wallet, array $payment): ?array;
/**
* Update payment status (and balance).
*
* @return int HTTP response code
*/
abstract public function webhook(): int;
/**
* Create a payment record in DB
*
* @param array $payment Payment information
* @param string $wallet_id Wallet ID
*
* @return \App\Payment Payment object
*/
protected function storePayment(array $payment, $wallet_id): Payment
{
$payment['wallet_id'] = $wallet_id;
$payment['provider'] = $this->name();
return Payment::createFromArray($payment);
}
/**
* Convert a value from $sourceCurrency to $targetCurrency
*
* @param int $amount Amount in cents of $sourceCurrency
* @param string $sourceCurrency Currency from which to convert
* @param string $targetCurrency Currency to convert to
*
* @return int Exchanged amount in cents of $targetCurrency
*/
protected function exchange(int $amount, string $sourceCurrency, string $targetCurrency): int
{
return intval(round($amount * \App\Utils::exchangeRate($sourceCurrency, $targetCurrency)));
}
/**
* List supported payment methods from this provider
*
* @param string $type The payment type for which we require a method (oneoff/recurring).
* @param string $currency Currency code
*
* @return array Array of array with available payment methods:
* - id: id of the method
* - name: User readable name of the payment method
* - minimumAmount: Minimum amount to be charged in cents
* - currency: Currency used for the method
* - exchangeRate: The projected exchange rate (actual rate is determined during payment)
* - icon: An icon (icon name) representing the method
*/
abstract public function providerPaymentMethods(string $type, string $currency): array;
/**
* Get a payment.
*
* @param string $paymentId Payment identifier
*
* @return array Payment information:
* - id: Payment identifier
* - status: Payment status
* - isCancelable: The payment can be canceled
* - checkoutUrl: The checkout url to complete the payment or null if none
*/
abstract public function getPayment($paymentId): array;
/**
* Return an array of whitelisted payment methods with override values.
*
* @param string $type The payment type for which we require a method.
*
* @return array Array of methods
*/
protected static function paymentMethodsWhitelist($type): array
{
$methods = [];
switch ($type) {
- case self::TYPE_ONEOFF:
+ case Payment::TYPE_ONEOFF:
$methods = explode(',', \config('app.payment.methods_oneoff'));
break;
- case PaymentProvider::TYPE_RECURRING:
+ case Payment::TYPE_RECURRING:
$methods = explode(',', \config('app.payment.methods_recurring'));
break;
default:
\Log::error("Unknown payment type: " . $type);
}
$methods = array_map('strtolower', array_map('trim', $methods));
return $methods;
}
/**
* Return an array of whitelisted payment methods with override values.
*
* @param string $type The payment type for which we require a method.
*
* @return array Array of methods
*/
private static function applyMethodWhitelist($type, $availableMethods): array
{
$methods = [];
// Use only whitelisted methods, and apply values from whitelist (overriding the backend)
$whitelistMethods = self::paymentMethodsWhitelist($type);
foreach ($whitelistMethods as $id) {
if (array_key_exists($id, $availableMethods)) {
$method = $availableMethods[$id];
$method['icon'] = self::$paymentMethodIcons[$id];
$methods[] = $method;
}
}
return $methods;
}
/**
* List supported payment methods for $wallet
*
* @param \App\Wallet $wallet The wallet
* @param string $type The payment type for which we require a method (oneoff/recurring).
*
* @return array Array of array with available payment methods:
* - id: id of the method
* - name: User readable name of the payment method
* - minimumAmount: Minimum amount to be charged in cents
* - currency: Currency used for the method
* - exchangeRate: The projected exchange rate (actual rate is determined during payment)
* - icon: An icon (icon name) representing the method
*/
public static function paymentMethods(Wallet $wallet, $type): array
{
$providerName = self::providerName($wallet);
$cacheKey = "methods-{$providerName}-{$type}-{$wallet->currency}";
if ($methods = Cache::get($cacheKey)) {
\Log::debug("Using payment method cache" . var_export($methods, true));
return $methods;
}
$provider = PaymentProvider::factory($providerName);
$methods = $provider->providerPaymentMethods($type, $wallet->currency);
if (!empty(\config('services.coinbase.key'))) {
$coinbaseProvider = PaymentProvider::factory(self::PROVIDER_COINBASE);
$methods = array_merge($methods, $coinbaseProvider->providerPaymentMethods($type, $wallet->currency));
}
$methods = self::applyMethodWhitelist($type, $methods);
\Log::debug("Loaded payment methods" . var_export($methods, true));
Cache::put($cacheKey, $methods, now()->addHours(1));
return $methods;
}
/**
* Returns the full URL for the wallet page, used when returning from an external payment page.
* Depending on the request origin it will return a URL for the User or Reseller UI.
*
* @return string The redirect URL
*/
public static function redirectUrl(): string
{
$url = \App\Utils::serviceUrl('/wallet');
$domain = preg_replace('/:[0-9]+$/', '', request()->getHttpHost());
if (strpos($domain, 'reseller') === 0) {
$url = preg_replace('|^(https?://)([^/]+)|', '\\1' . $domain, $url);
}
return $url;
}
}
diff --git a/src/composer.json b/src/composer.json
index 0f927622..23dae1e8 100644
--- a/src/composer.json
+++ b/src/composer.json
@@ -1,85 +1,85 @@
{
"name": "kolab/kolab4",
"type": "project",
"description": "Kolab 4",
"keywords": [
"framework",
"laravel"
],
"license": "MIT",
"repositories": [
{
"type": "vcs",
"url": "https://git.kolab.org/diffusion/PNL/php-net_ldap3.git"
}
],
"require": {
- "php": "^8.0",
+ "php": "^8.1",
"bacon/bacon-qr-code": "^2.0",
"barryvdh/laravel-dompdf": "^2.0.0",
"doctrine/dbal": "^3.3.2",
"dyrynda/laravel-nullable-fields": "^4.2.0",
"guzzlehttp/guzzle": "^7.4.1",
"kolab/net_ldap3": "dev-master",
"laravel/framework": "^9.2",
"laravel/horizon": "^5.9",
"laravel/octane": "^1.2",
"laravel/passport": "^11.3",
"laravel/tinker": "^2.7",
"mlocati/spf-lib": "^3.1",
"mollie/laravel-mollie": "^2.19",
"pear/crypt_gpg": "^1.6.6",
- "predis/predis": "^1.1.10",
+ "predis/predis": "^2.0",
"sabre/vobject": "^4.5",
- "spatie/laravel-translatable": "^5.2",
+ "spatie/laravel-translatable": "^6.3",
"spomky-labs/otphp": "~10.0.0",
- "stripe/stripe-php": "^7.29"
+ "stripe/stripe-php": "^10.7"
},
"require-dev": {
"code-lts/doctum": "^5.5.1",
"laravel/dusk": "~7.5.0",
"mockery/mockery": "^1.5",
"nunomaduro/larastan": "^2.0",
"phpstan/phpstan": "^1.4",
"phpunit/phpunit": "^9",
"squizlabs/php_codesniffer": "^3.6"
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"autoload": {
"psr-4": {
"App\\": "app/"
},
"classmap": [
"database/seeds",
"include"
]
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"minimum-stability": "stable",
"prefer-stable": true,
"scripts": {
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi"
]
}
}
diff --git a/src/tests/Browser/PaymentCoinbaseTest.php b/src/tests/Browser/PaymentCoinbaseTest.php
index 7803e246..30a87cd1 100644
--- a/src/tests/Browser/PaymentCoinbaseTest.php
+++ b/src/tests/Browser/PaymentCoinbaseTest.php
@@ -1,82 +1,81 @@
<?php
namespace Tests\Browser;
-use App\Providers\PaymentProvider;
use App\Wallet;
use Tests\Browser;
use Tests\Browser\Components\Dialog;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\Browser\Pages\Wallet as WalletPage;
use Tests\TestCaseDusk;
class PaymentCoinbaseTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('payment-test@kolabnow.com');
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('payment-test@kolabnow.com');
parent::tearDown();
}
/**
* Test the payment process
*
* @group coinbase
*/
public function testPayment(): void
{
$user = $this->getTestUser('payment-test@kolabnow.com', [
'password' => 'simple123',
]);
$this->browse(function (Browser $browser) use ($user) {
$browser->visit(new Home())
->submitLogon('payment-test@kolabnow.com', 'simple123', true, ['paymentProvider' => 'mollie'])
->on(new Dashboard())
->click('@links .link-wallet')
->on(new WalletPage())
->assertSeeIn('@main button', 'Add credit')
->click('@main button')
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Top up your wallet')
->waitFor('#payment-method-selection .link-bitcoin svg')
->click('#payment-method-selection .link-bitcoin');
})
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Top up your wallet')
->assertFocused('#amount')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Continue')
// Test error handling
->type('@body #amount', 'aaa')
->click('@button-action')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->assertSeeIn('#amount + span + .invalid-feedback', 'The amount must be a number.')
// Submit valid data
->type('@body #amount', '12.34')
// Note we use double click to assert it does not create redundant requests
->click('@button-action')
->click('@button-action');
})
->waitUntilMissing('@payment-dialog');
$this->assertSame(1, $user->wallets()->first()->payments()->count());
});
}
}
diff --git a/src/tests/Browser/PaymentMollieTest.php b/src/tests/Browser/PaymentMollieTest.php
index cb4ad130..5af5b3b5 100644
--- a/src/tests/Browser/PaymentMollieTest.php
+++ b/src/tests/Browser/PaymentMollieTest.php
@@ -1,293 +1,293 @@
<?php
namespace Tests\Browser;
-use App\Providers\PaymentProvider;
+use App\Payment;
use App\Wallet;
use Tests\Browser;
use Tests\Browser\Components\Dialog;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\Browser\Pages\PaymentMollie;
use Tests\Browser\Pages\Wallet as WalletPage;
use Tests\TestCaseDusk;
class PaymentMollieTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('payment-test@kolabnow.com');
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('payment-test@kolabnow.com');
parent::tearDown();
}
/**
* Test the payment process
*
* @group mollie
*/
public function testPayment(): void
{
$user = $this->getTestUser('payment-test@kolabnow.com', [
'password' => 'simple123',
]);
$this->browse(function (Browser $browser) use ($user) {
$browser->visit(new Home())
->submitLogon('payment-test@kolabnow.com', 'simple123', true, ['paymentProvider' => 'mollie'])
->on(new Dashboard())
->click('@links .link-wallet')
->on(new WalletPage())
->assertSeeIn('@main button', 'Add credit')
->click('@main button')
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Top up your wallet')
->waitFor('#payment-method-selection .link-creditcard svg')
->waitFor('#payment-method-selection .link-paypal svg')
->waitFor('#payment-method-selection .link-banktransfer svg')
->click('#payment-method-selection .link-creditcard');
})
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Top up your wallet')
->assertFocused('#amount')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Continue')
// Test error handling
->type('@body #amount', 'aaa')
->click('@button-action')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->assertSeeIn('#amount + span + .invalid-feedback', 'The amount must be a number.')
// Submit valid data
->type('@body #amount', '12.34')
// Note we use double click to assert it does not create redundant requests
->click('@button-action')
->click('@button-action');
})
->on(new PaymentMollie())
->assertSeeIn('@title', $user->tenant->title . ' Payment')
->assertSeeIn('@amount', 'CHF 12.34')
->submitPayment()
->waitForLocation('/wallet')
->on(new WalletPage())
->assertSeeIn('@main .card-title', 'Account balance 12,34 CHF');
$this->assertSame(1, $user->wallets()->first()->payments()->count());
});
}
/**
* Test the auto-payment setup process
*
* @group mollie
*/
public function testAutoPaymentSetup(): void
{
$user = $this->getTestUser('payment-test@kolabnow.com', [
'password' => 'simple123',
]);
$this->browse(function (Browser $browser) use ($user) {
$browser->visit(new Home())
->submitLogon('payment-test@kolabnow.com', 'simple123', true, ['paymentProvider' => 'mollie'])
->on(new Dashboard())
->click('@links .link-wallet')
->on(new WalletPage())
->assertMissing('@body #mandate-form .alert')
->click('@main #mandate-form button')
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Set up auto-payment')
->waitFor('#payment-method-selection .link-creditcard svg')
->assertMissing('#payment-method-selection .link-paypal')
->assertMissing('#payment-method-selection .link-banktransfer')
->click('#payment-method-selection .link-creditcard');
})
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Set up auto-payment')
->assertSeeIn('@body label[for="mandate_amount"]', 'Fill up by')
- ->assertValue('@body #mandate_amount', PaymentProvider::MIN_AMOUNT / 100)
+ ->assertValue('@body #mandate_amount', Payment::MIN_AMOUNT / 100)
->assertSeeIn('@body label[for="mandate_balance"]', 'when account balance is below') // phpcs:ignore
->assertValue('@body #mandate_balance', '0')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Continue')
// Test error handling
->type('@body #mandate_amount', 'aaa')
->type('@body #mandate_balance', '-1')
->click('@button-action')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->assertVisible('@body #mandate_amount.is-invalid')
->assertVisible('@body #mandate_balance.is-invalid')
->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.')
->assertSeeIn('#mandate_balance + span + .invalid-feedback', 'The balance must be at least 0.')
->type('@body #mandate_amount', 'aaa')
->type('@body #mandate_balance', '0')
->click('@button-action')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->assertVisible('@body #mandate_amount.is-invalid')
->assertMissing('@body #mandate_balance.is-invalid')
->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.')
->assertMissing('#mandate_balance + span + .invalid-feedback')
// Submit valid data
->type('@body #mandate_amount', '100')
->type('@body #mandate_balance', '0')
// Note we use double click to assert it does not create redundant requests
->click('@button-action')
->click('@button-action');
})
->on(new PaymentMollie())
->assertSeeIn('@title', $user->tenant->title . ' Auto-Payment Setup')
->assertMissing('@amount')
->submitPayment()
->waitForLocation('/wallet')
->visit('/wallet?paymentProvider=mollie')
->waitFor('#mandate-info')
->assertPresent('#mandate-info p:first-child')
->assertSeeIn(
'#mandate-info p:first-child',
'Auto-payment is set to fill up your account by 100 CHF ' .
'every time your account balance gets under 0 CHF.'
)
->assertSeeIn(
'#mandate-info p:nth-child(2)',
'Mastercard (**** **** **** 9399)'
)
->assertMissing('@body .alert');
$this->assertSame(1, $user->wallets()->first()->payments()->count());
});
// Test updating (disabled) auto-payment
$this->browse(function (Browser $browser) use ($user) {
$wallet = $user->wallets()->first();
$wallet->setSetting('mandate_disabled', 1);
$browser->refresh()
->on(new WalletPage())
->waitFor('#mandate-info')
->assertSeeIn(
'#mandate-info .disabled-mandate',
'The configured auto-payment has been disabled'
)
->assertSeeIn('#mandate-info button.btn-primary', 'Change auto-payment')
->click('#mandate-info button.btn-primary')
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Update auto-payment')
->assertSeeIn(
'@body form .disabled-mandate',
'The auto-payment is disabled.'
)
->assertValue('@body #mandate_amount', '100')
->assertValue('@body #mandate_balance', '0')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Submit')
// Test error handling
->type('@body #mandate_amount', 'aaa')
->click('@button-action')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->assertVisible('@body #mandate_amount.is-invalid')
->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.')
// Submit valid data
->type('@body #mandate_amount', '50')
->click('@button-action');
})
->waitUntilMissing('#payment-dialog')
->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been updated.')
// make sure the "disabled" text isn't there
->assertMissing('#mandate-info .disabled-mandate')
->click('#mandate-info button.btn-primary')
->assertMissing('form .disabled-mandate')
->click('button.modal-cancel');
});
// Test deleting auto-payment
$this->browse(function (Browser $browser) {
$browser->on(new WalletPage())
->waitFor('#mandate-info')
->assertSeeIn('#mandate-info * button.btn-danger', 'Cancel auto-payment')
->assertVisible('#mandate-info * button.btn-danger')
->click('#mandate-info * button.btn-danger')
->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been removed.')
->assertVisible('#mandate-form')
->assertMissing('#mandate-info');
});
// Test pending and failed mandate
$this->browse(function (Browser $browser) {
$browser->on(new WalletPage())
->assertMissing('@body #mandate-form .alert')
->click('@main #mandate-form button')
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Set up auto-payment')
->waitFor('#payment-method-selection .link-creditcard')
->click('#payment-method-selection .link-creditcard');
})
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Set up auto-payment')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Continue')
// Submit valid data
->type('@body #mandate_amount', '100')
->type('@body #mandate_balance', '0')
->click('@button-action');
})
->on(new PaymentMollie())
->submitPayment('open')
->waitForLocation('/wallet')
->visit('/wallet?paymentProvider=mollie')
->on(new WalletPage())
->assertSeeIn(
'#mandate-info .alert-warning',
'The setup of the automatic payment is still in progress.'
)
// Delete the mandate
->click('#mandate-info * button.btn-danger')
->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been removed.')
->assertMissing('@body #mandate-form .alert')
// Create a new mandate
->click('@main #mandate-form button')
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Set up auto-payment')
->waitFor('#payment-method-selection .link-creditcard')
->click('#payment-method-selection .link-creditcard');
})
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Set up auto-payment')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Continue')
// Submit valid data
->type('@body #mandate_amount', '100')
->type('@body #mandate_balance', '0')
->click('@button-action');
})
->on(new PaymentMollie())
->submitPayment('failed')
->waitForLocation('/wallet')
->visit('/wallet?paymentProvider=mollie')
->on(new WalletPage())
->waitFor('#mandate-form .alert-danger')
->assertSeeIn(
'#mandate-form .alert-danger',
'The setup of automatic payments failed. Restart the process to enable'
)
->click('@main button')
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
$browser->waitFor('#mandate-form')
->assertMissing('#mandate-info');
});
});
}
}
diff --git a/src/tests/Browser/PaymentStripeTest.php b/src/tests/Browser/PaymentStripeTest.php
index ce0db9e0..7b203c7e 100644
--- a/src/tests/Browser/PaymentStripeTest.php
+++ b/src/tests/Browser/PaymentStripeTest.php
@@ -1,234 +1,234 @@
<?php
namespace Tests\Browser;
-use App\Providers\PaymentProvider;
+use App\Payment;
use App\Wallet;
use Tests\Browser;
use Tests\Browser\Components\Dialog;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\Browser\Pages\PaymentStripe;
use Tests\Browser\Pages\Wallet as WalletPage;
use Tests\TestCaseDusk;
class PaymentStripeTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('payment-test@kolabnow.com');
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('payment-test@kolabnow.com');
parent::tearDown();
}
/**
* Test the payment process
*
* @group stripe
*/
public function testPayment(): void
{
$user = $this->getTestUser('payment-test@kolabnow.com', [
'password' => 'simple123',
]);
$this->browse(function (Browser $browser) use ($user) {
$browser->visit(new Home())
->submitLogon('payment-test@kolabnow.com', 'simple123', true, ['paymentProvider' => 'stripe'])
->on(new Dashboard())
->click('@links .link-wallet')
->on(new WalletPage())
->assertSeeIn('@main button', 'Add credit')
->click('@main button')
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Top up your wallet')
->waitFor('#payment-method-selection .link-creditcard svg')
->waitFor('#payment-method-selection .link-paypal svg')
->assertMissing('#payment-method-selection .link-banktransfer svg')
->click('#payment-method-selection .link-creditcard');
})
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Top up your wallet')
->assertFocused('#amount')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Continue')
// Test error handling
->type('@body #amount', 'aaa')
->click('@button-action')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->assertSeeIn('#amount + span + .invalid-feedback', 'The amount must be a number.')
// Submit valid data
->type('@body #amount', '12.34')
// Note we use double click to assert it does not create redundant requests
->click('@button-action')
->click('@button-action');
})
->on(new PaymentStripe())
->assertSeeIn('@title', $user->tenant->title . ' Payment')
->assertSeeIn('@amount', 'CHF 12.34')
->assertValue('@email-input', $user->email)
->submitValidCreditCard();
// Now it should redirect back to wallet page and in background
// use the webhook to update payment status (and balance).
// Looks like in test-mode the webhook is executed before redirect
// so we can expect balance updated on the wallet page
$browser->waitForLocation('/wallet', 30) // need more time than default 5 sec.
->on(new WalletPage())
->assertSeeIn('@main .card-title', 'Account balance 12,34 CHF');
});
}
/**
* Test the auto-payment setup process
*
* @group stripe
*/
public function testAutoPaymentSetup(): void
{
$user = $this->getTestUser('payment-test@kolabnow.com', [
'password' => 'simple123',
]);
// Test creating auto-payment
$this->browse(function (Browser $browser) use ($user) {
$browser->visit(new Home())
->submitLogon('payment-test@kolabnow.com', 'simple123', true, ['paymentProvider' => 'stripe'])
->on(new Dashboard())
->click('@links .link-wallet')
->on(new WalletPage())
->assertMissing('@body #mandate-form .alert')
->click('@main #mandate-form button')
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Set up auto-payment')
->waitFor('#payment-method-selection .link-creditcard')
->assertMissing('#payment-method-selection .link-paypal')
->assertMissing('#payment-method-selection .link-banktransfer')
->click('#payment-method-selection .link-creditcard');
})
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Set up auto-payment')
->assertSeeIn('@body label[for="mandate_amount"]', 'Fill up by')
- ->assertValue('@body #mandate_amount', PaymentProvider::MIN_AMOUNT / 100)
+ ->assertValue('@body #mandate_amount', Payment::MIN_AMOUNT / 100)
->assertSeeIn('@body label[for="mandate_balance"]', 'when account balance is below') // phpcs:ignore
->assertValue('@body #mandate_balance', '0')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Continue')
// Test error handling
->type('@body #mandate_amount', 'aaa')
->type('@body #mandate_balance', '-1')
->click('@button-action')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->assertVisible('@body #mandate_amount.is-invalid')
->assertVisible('@body #mandate_balance.is-invalid')
->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.')
->assertSeeIn('#mandate_balance + span + .invalid-feedback', 'The balance must be at least 0.')
->type('@body #mandate_amount', 'aaa')
->type('@body #mandate_balance', '0')
->click('@button-action')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->assertVisible('@body #mandate_amount.is-invalid')
->assertMissing('@body #mandate_balance.is-invalid')
->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.')
->assertMissing('#mandate_balance + span + .invalid-feedback')
// Submit valid data
->type('@body #mandate_amount', '100')
->type('@body #mandate_balance', '0')
// Note we use double click to assert it does not create redundant requests
->click('@button-action')
->click('@button-action');
})
->on(new PaymentStripe())
->assertMissing('@title')
->assertMissing('@amount')
->assertValue('@email-input', $user->email)
->submitValidCreditCard()
->waitForLocation('/wallet', 30) // need more time than default 5 sec.
->visit('/wallet?paymentProvider=stripe')
->waitFor('#mandate-info')
->assertPresent('#mandate-info p:first-child')
->assertSeeIn(
'#mandate-info p:first-child',
'Auto-payment is set to fill up your account by 100 CHF ' .
'every time your account balance gets under 0 CHF.'
)
->assertSeeIn(
'#mandate-info p:nth-child(2)',
'Visa (**** **** **** 4242)'
)
->assertMissing('@body .alert');
});
// Test updating (disabled) auto-payment
$this->browse(function (Browser $browser) use ($user) {
$wallet = $user->wallets()->first();
$wallet->setSetting('mandate_disabled', 1);
$browser->refresh()
->on(new WalletPage())
->waitFor('#mandate-info')
->assertSeeIn(
'#mandate-info .disabled-mandate',
'The configured auto-payment has been disabled'
)
->assertSeeIn('#mandate-info button.btn-primary', 'Change auto-payment')
->click('#mandate-info button.btn-primary')
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Update auto-payment')
->assertSeeIn(
'@body form .disabled-mandate',
'The auto-payment is disabled.'
)
->assertValue('@body #mandate_amount', '100')
->assertValue('@body #mandate_balance', '0')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Submit')
// Test error handling
->type('@body #mandate_amount', 'aaa')
->click('@button-action')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->assertVisible('@body #mandate_amount.is-invalid')
->assertSeeIn('#mandate_amount + span + .invalid-feedback', 'The amount must be a number.')
// Submit valid data
->type('@body #mandate_amount', '50')
->click('@button-action');
})
->waitUntilMissing('#payment-dialog')
->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been updated.')
// make sure the "disabled" text isn't there
->assertMissing('#mandate-info .disabled-mandate')
->click('#mandate-info button.btn-primary')
->assertMissing('form .disabled-mandate')
->click('button.modal-cancel');
});
// Test deleting auto-payment
$this->browse(function (Browser $browser) {
$browser->on(new WalletPage())
->waitFor('#mandate-info')
->assertSeeIn('#mandate-info * button.btn-danger', 'Cancel auto-payment')
->assertVisible('#mandate-info * button.btn-danger')
->click('#mandate-info * button.btn-danger')
->assertToast(Toast::TYPE_SUCCESS, 'The auto-payment has been removed.')
->assertVisible('#mandate-form')
->assertMissing('#mandate-info');
});
}
}
diff --git a/src/tests/Browser/Reseller/PaymentMollieTest.php b/src/tests/Browser/Reseller/PaymentMollieTest.php
index acecc065..5ccff4c3 100644
--- a/src/tests/Browser/Reseller/PaymentMollieTest.php
+++ b/src/tests/Browser/Reseller/PaymentMollieTest.php
@@ -1,96 +1,95 @@
<?php
namespace Tests\Browser\Reseller;
-use App\Providers\PaymentProvider;
use App\Wallet;
use Tests\Browser;
use Tests\Browser\Components\Dialog;
use Tests\Browser\Components\Toast;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\Browser\Pages\PaymentMollie;
use Tests\Browser\Pages\Wallet as WalletPage;
use Tests\TestCaseDusk;
class PaymentMollieTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
self::useResellerUrl();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$user = $this->getTestUser('reseller@' . \config('app.domain'));
$wallet = $user->wallets()->first();
$wallet->payments()->delete();
$wallet->balance = 0;
$wallet->save();
parent::tearDown();
}
/**
* Test the payment process
*
* @group mollie
*/
public function testPayment(): void
{
$this->browse(function (Browser $browser) {
$user = $this->getTestUser('reseller@' . \config('app.domain'));
$wallet = $user->wallets()->first();
$wallet->payments()->delete();
$wallet->balance = 0;
$wallet->save();
$browser->visit(new Home())
->submitLogon($user->email, \App\Utils::generatePassphrase(), true, ['paymentProvider' => 'mollie'])
->on(new Dashboard())
->click('@links .link-wallet')
->on(new WalletPage())
->assertSeeIn('@main button', 'Add credit')
->click('@main button')
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Top up your wallet')
->waitFor('#payment-method-selection .link-creditcard svg')
->waitFor('#payment-method-selection .link-paypal svg')
->waitFor('#payment-method-selection .link-banktransfer svg')
->click('#payment-method-selection .link-creditcard');
})
->with(new Dialog('@payment-dialog'), function (Browser $browser) {
$browser->assertSeeIn('@title', 'Top up your wallet')
->assertFocused('#amount')
->assertSeeIn('@button-cancel', 'Cancel')
->assertSeeIn('@button-action', 'Continue')
// Test error handling
->type('@body #amount', 'aaa')
->click('@button-action')
->assertToast(Toast::TYPE_ERROR, 'Form validation error')
->assertSeeIn('#amount + span + .invalid-feedback', 'The amount must be a number.')
// Submit valid data
->type('@body #amount', '12.34')
// Note we use double click to assert it does not create redundant requests
->click('@button-action')
->click('@button-action');
})
->on(new PaymentMollie())
->assertSeeIn('@title', $user->tenant->title . ' Payment')
->assertSeeIn('@amount', 'CHF 12.34')
->submitPayment()
->waitForLocation('/wallet')
->on(new WalletPage())
->assertSeeIn('@main .card-title', 'Account balance 12,34 CHF');
$this->assertSame(1, $wallet->payments()->count());
});
}
}
diff --git a/src/tests/Browser/Reseller/WalletTest.php b/src/tests/Browser/Reseller/WalletTest.php
index 2c0c379c..ecf30fd4 100644
--- a/src/tests/Browser/Reseller/WalletTest.php
+++ b/src/tests/Browser/Reseller/WalletTest.php
@@ -1,254 +1,253 @@
<?php
namespace Tests\Browser\Reseller;
use App\Payment;
-use App\Providers\PaymentProvider;
use App\Transaction;
use App\Wallet;
use Carbon\Carbon;
use Tests\Browser;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\Browser\Pages\Wallet as WalletPage;
use Tests\TestCaseDusk;
class WalletTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
self::useResellerUrl();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$reseller = $this->getTestUser('reseller@' . \config('app.domain'));
$wallet = $reseller->wallets()->first();
$wallet->balance = 0;
$wallet->save();
$wallet->payments()->delete();
$wallet->transactions()->delete();
parent::tearDown();
}
/**
* Test wallet page (unauthenticated)
*/
public function testWalletUnauth(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$browser->visit('/wallet')->on(new Home());
});
}
/**
* Test wallet "box" on Dashboard
*/
public function testDashboard(): void
{
$reseller = $this->getTestUser('reseller@' . \config('app.domain'));
Wallet::where('user_id', $reseller->id)->update(['balance' => 125]);
// Positive balance
$this->browse(function (Browser $browser) {
$browser->visit(new Home())
->submitLogon('reseller@' . \config('app.domain'), \App\Utils::generatePassphrase(), true)
->on(new Dashboard())
->assertSeeIn('@links .link-wallet svg + span', 'Wallet')
->assertSeeIn('@links .link-wallet .badge.bg-success', '1,25 CHF');
});
Wallet::where('user_id', $reseller->id)->update(['balance' => -1234]);
// Negative balance
$this->browse(function (Browser $browser) {
$browser->visit(new Dashboard())
->assertSeeIn('@links .link-wallet svg + span', 'Wallet')
->assertSeeIn('@links .link-wallet .badge.bg-danger', '-12,34 CHF');
});
}
/**
* Test wallet page
*
* @depends testDashboard
*/
public function testWallet(): void
{
$reseller = $this->getTestUser('reseller@' . \config('app.domain'));
Wallet::where('user_id', $reseller->id)->update(['balance' => -1234]);
$this->browse(function (Browser $browser) {
$browser->click('@links .link-wallet')
->on(new WalletPage())
->assertSeeIn('#wallet .card-title', 'Account balance -12,34 CHF')
->assertSeeIn('#wallet .card-title .text-danger', '-12,34 CHF')
->assertSeeIn('#wallet .card-text', 'You are out of credit');
});
}
/**
* Test Receipts tab
*
* @depends testWallet
*/
public function testReceipts(): void
{
$user = $this->getTestUser('reseller@' . \config('app.domain'));
$plan = \App\Plan::withObjectTenantContext($user)->where('title', 'individual')->first();
$wallet = $user->wallets()->first();
$wallet->payments()->delete();
$user->assignPlan($plan);
$user->created_at = Carbon::now();
$user->save();
// Assert Receipts tab content when there's no receipts available
$this->browse(function (Browser $browser) {
$browser->visit(new WalletPage())
->assertSeeIn('#wallet .card-title', 'Account balance 0,00 CHF')
->assertSeeIn('#wallet .card-title .text-success', '0,00 CHF')
->assertSeeIn('#wallet .card-text', 'You are in your free trial period.') // TODO
->assertSeeIn('@nav #tab-receipts', 'Receipts')
->with('@receipts-tab', function (Browser $browser) {
$browser->waitUntilMissing('.app-loader')
->assertSeeIn('p', 'There are no receipts for payments')
->assertDontSeeIn('p', 'Here you can download')
->assertMissing('select')
->assertMissing('button');
});
});
// Create some sample payments
$receipts = [];
$date = Carbon::create(intval(date('Y')) - 1, 3, 30);
$payment = Payment::create([
'id' => 'AAA1',
- 'status' => PaymentProvider::STATUS_PAID,
- 'type' => PaymentProvider::TYPE_ONEOFF,
+ 'status' => Payment::STATUS_PAID,
+ 'type' => Payment::TYPE_ONEOFF,
'description' => 'Paid in March',
'wallet_id' => $wallet->id,
'provider' => 'stripe',
'amount' => 1111,
'credit_amount' => 1111,
'currency_amount' => 1111,
'currency' => 'CHF',
]);
$payment->updated_at = $date;
$payment->save();
$receipts[] = $date->format('Y-m');
$date = Carbon::create(intval(date('Y')) - 1, 4, 30);
$payment = Payment::create([
'id' => 'AAA2',
- 'status' => PaymentProvider::STATUS_PAID,
- 'type' => PaymentProvider::TYPE_ONEOFF,
+ 'status' => Payment::STATUS_PAID,
+ 'type' => Payment::TYPE_ONEOFF,
'description' => 'Paid in April',
'wallet_id' => $wallet->id,
'provider' => 'stripe',
'amount' => 1111,
'credit_amount' => 1111,
'currency_amount' => 1111,
'currency' => 'CHF',
]);
$payment->updated_at = $date;
$payment->save();
$receipts[] = $date->format('Y-m');
// Assert Receipts tab with receipts available
$this->browse(function (Browser $browser) use ($receipts) {
$browser->refresh()
->on(new WalletPage())
->assertSeeIn('@nav #tab-receipts', 'Receipts')
->with('@receipts-tab', function (Browser $browser) use ($receipts) {
$browser->waitUntilMissing('.app-loader')
->assertDontSeeIn('p', 'There are no receipts for payments')
->assertSeeIn('p', 'Here you can download')
->assertSeeIn('button', 'Download')
->assertElementsCount('select > option', 2)
->assertSeeIn('select > option:nth-child(1)', $receipts[1])
->assertSeeIn('select > option:nth-child(2)', $receipts[0]);
// Download a receipt file
$browser->select('select', $receipts[0])
->click('button')
->pause(2000);
$files = glob(__DIR__ . '/../downloads/*.pdf');
$filename = pathinfo($files[0], PATHINFO_BASENAME);
$this->assertTrue(strpos($filename, $receipts[0]) !== false);
$content = $browser->readDownloadedFile($filename, 0);
$this->assertStringStartsWith("%PDF-1.", $content);
$browser->removeDownloadedFile($filename);
});
});
}
/**
* Test History tab
*
* @depends testWallet
*/
public function testHistory(): void
{
$user = $this->getTestUser('reseller@' . \config('app.domain'));
$wallet = $user->wallets()->first();
$wallet->transactions()->delete();
// Create some sample transactions
$transactions = $this->createTestTransactions($wallet);
$transactions = array_reverse($transactions);
$pages = array_chunk($transactions, 10 /* page size*/);
$this->browse(function (Browser $browser) use ($pages) {
$browser->on(new WalletPage())
->assertSeeIn('@nav #tab-history', 'History')
->click('@nav #tab-history')
->with('@history-tab', function (Browser $browser) use ($pages) {
$browser->waitUntilMissing('.app-loader')
->assertElementsCount('table tbody tr', 10)
->assertMissing('table td.email')
->assertSeeIn('.more-loader button', 'Load more');
foreach ($pages[0] as $idx => $transaction) {
$selector = 'table tbody tr:nth-child(' . ($idx + 1) . ')';
$priceStyle = $transaction->type == Transaction::WALLET_AWARD ? 'text-success' : 'text-danger';
$browser->assertSeeIn("$selector td.description", $transaction->shortDescription())
->assertMissing("$selector td.selection button")
->assertVisible("$selector td.price.{$priceStyle}");
// TODO: Test more transaction details
}
// Load the next page
$browser->click('.more-loader button')
->waitUntilMissing('.app-loader')
->assertElementsCount('table tbody tr', 12)
->assertMissing('.more-loader button');
$debitEntry = null;
foreach ($pages[1] as $idx => $transaction) {
$selector = 'table tbody tr:nth-child(' . ($idx + 1 + 10) . ')';
$priceStyle = $transaction->type == Transaction::WALLET_CREDIT ? 'text-success' : 'text-danger';
$browser->assertSeeIn("$selector td.description", $transaction->shortDescription());
if ($transaction->type == Transaction::WALLET_DEBIT) {
$debitEntry = $selector;
} else {
$browser->assertMissing("$selector td.selection button");
}
}
});
});
}
}
diff --git a/src/tests/Browser/WalletTest.php b/src/tests/Browser/WalletTest.php
index e91123ed..e376f97f 100644
--- a/src/tests/Browser/WalletTest.php
+++ b/src/tests/Browser/WalletTest.php
@@ -1,279 +1,278 @@
<?php
namespace Tests\Browser;
use App\Payment;
-use App\Providers\PaymentProvider;
use App\Transaction;
use App\Wallet;
use Carbon\Carbon;
use Tests\Browser;
use Tests\Browser\Pages\Dashboard;
use Tests\Browser\Pages\Home;
use Tests\Browser\Pages\Wallet as WalletPage;
use Tests\TestCaseDusk;
class WalletTest extends TestCaseDusk
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('wallets-controller@kolabnow.com');
$john = $this->getTestUser('john@kolab.org');
Wallet::where('user_id', $john->id)->update(['balance' => -1234, 'currency' => 'CHF']);
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('wallets-controller@kolabnow.com');
$john = $this->getTestUser('john@kolab.org');
Wallet::where('user_id', $john->id)->update(['balance' => 0]);
parent::tearDown();
}
/**
* Test wallet page (unauthenticated)
*/
public function testWalletUnauth(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$browser->visit('/wallet')->on(new Home());
});
}
/**
* Test wallet "box" on Dashboard
*/
public function testDashboard(): void
{
// Test that the page requires authentication
$this->browse(function (Browser $browser) {
$browser->visit(new Home())
->submitLogon('john@kolab.org', 'simple123', true)
->on(new Dashboard())
->assertSeeIn('@links .link-wallet svg + span', 'Wallet')
->assertSeeIn('@links .link-wallet .badge', '-12,34 CHF');
});
}
/**
* Test wallet page
*
* @depends testDashboard
*/
public function testWallet(): void
{
$this->browse(function (Browser $browser) {
$browser->click('@links .link-wallet')
->on(new WalletPage())
->assertSeeIn('#wallet .card-title', 'Account balance -12,34 CHF')
->assertSeeIn('#wallet .card-title .text-danger', '-12,34 CHF')
->assertSeeIn('#wallet .card-text', 'You are out of credit');
});
}
/**
* Test Receipts tab
*/
public function testReceipts(): void
{
$user = $this->getTestUser('wallets-controller@kolabnow.com', ['password' => 'simple123']);
$plan = \App\Plan::withObjectTenantContext($user)->where('title', 'individual')->first();
$user->assignPlan($plan);
$wallet = $user->wallets()->first();
$wallet->payments()->delete();
// Log out John and log in the test user
$this->browse(function (Browser $browser) {
$browser->visit('/logout')
->waitForLocation('/login')
->on(new Home())
->submitLogon('wallets-controller@kolabnow.com', 'simple123', true);
});
// Assert Receipts tab content when there's no receipts available
$this->browse(function (Browser $browser) {
$browser->on(new Dashboard())
->click('@links .link-wallet')
->on(new WalletPage())
->assertSeeIn('#wallet .card-title', 'Account balance 0,00 CHF')
->assertSeeIn('#wallet .card-title .text-success', '0,00 CHF')
->assertSeeIn('#wallet .card-text', 'You are in your free trial period.')
->assertSeeIn('@nav #tab-receipts', 'Receipts')
->with('@receipts-tab', function (Browser $browser) {
$browser->waitUntilMissing('.app-loader')
->assertSeeIn('p', 'There are no receipts for payments')
->assertDontSeeIn('p', 'Here you can download')
->assertMissing('select')
->assertMissing('button');
});
});
// Create some sample payments
$receipts = [];
$date = Carbon::create(intval(date('Y')) - 1, 3, 30);
$payment = Payment::create([
'id' => 'AAA1',
- 'status' => PaymentProvider::STATUS_PAID,
- 'type' => PaymentProvider::TYPE_ONEOFF,
+ 'status' => Payment::STATUS_PAID,
+ 'type' => Payment::TYPE_ONEOFF,
'description' => 'Paid in March',
'wallet_id' => $wallet->id,
'provider' => 'stripe',
'amount' => 1111,
'credit_amount' => 1111,
'currency_amount' => 1111,
'currency' => 'CHF',
]);
$payment->updated_at = $date;
$payment->save();
$receipts[] = $date->format('Y-m');
$date = Carbon::create(intval(date('Y')) - 1, 4, 30);
$payment = Payment::create([
'id' => 'AAA2',
- 'status' => PaymentProvider::STATUS_PAID,
- 'type' => PaymentProvider::TYPE_ONEOFF,
+ 'status' => Payment::STATUS_PAID,
+ 'type' => Payment::TYPE_ONEOFF,
'description' => 'Paid in April',
'wallet_id' => $wallet->id,
'provider' => 'stripe',
'amount' => 1111,
'credit_amount' => 1111,
'currency_amount' => 1111,
'currency' => 'CHF',
]);
$payment->updated_at = $date;
$payment->save();
$receipts[] = $date->format('Y-m');
// Assert Receipts tab with receipts available
$this->browse(function (Browser $browser) use ($receipts) {
$browser->refresh()
->on(new WalletPage())
->assertSeeIn('@nav #tab-receipts', 'Receipts')
->with('@receipts-tab', function (Browser $browser) use ($receipts) {
$browser->waitUntilMissing('.app-loader')
->assertDontSeeIn('p', 'There are no receipts for payments')
->assertSeeIn('p', 'Here you can download')
->assertSeeIn('button', 'Download')
->assertElementsCount('select > option', 2)
->assertSeeIn('select > option:nth-child(1)', $receipts[1])
->assertSeeIn('select > option:nth-child(2)', $receipts[0]);
// Download a receipt file
$browser->select('select', $receipts[0])
->click('button')
->pause(2000);
$files = glob(__DIR__ . '/downloads/*.pdf');
$filename = pathinfo($files[0], PATHINFO_BASENAME);
$this->assertTrue(strpos($filename, $receipts[0]) !== false);
$content = $browser->readDownloadedFile($filename, 0);
$this->assertStringStartsWith("%PDF-1.", $content);
$browser->removeDownloadedFile($filename);
});
});
}
/**
* Test History tab
*/
public function testHistory(): void
{
$user = $this->getTestUser('wallets-controller@kolabnow.com', ['password' => 'simple123']);
// Log out John and log in the test user
$this->browse(function (Browser $browser) {
$browser->visit('/logout')
->waitForLocation('/login')
->on(new Home())
->submitLogon('wallets-controller@kolabnow.com', 'simple123', true);
});
$package_kolab = \App\Package::where('title', 'kolab')->first();
$user->assignPackage($package_kolab);
$wallet = $user->wallets()->first();
// Create some sample transactions
$transactions = $this->createTestTransactions($wallet);
$transactions = array_reverse($transactions);
$pages = array_chunk($transactions, 10 /* page size*/);
$this->browse(function (Browser $browser) use ($pages) {
$browser->on(new Dashboard())
->click('@links .link-wallet')
->on(new WalletPage())
->assertSeeIn('@nav #tab-history', 'History')
->click('@nav #tab-history')
->with('@history-tab', function (Browser $browser) use ($pages) {
$browser->waitUntilMissing('.app-loader')
->assertElementsCount('table tbody tr', 10)
->assertMissing('table td.email')
->assertSeeIn('.more-loader button', 'Load more');
foreach ($pages[0] as $idx => $transaction) {
$selector = 'table tbody tr:nth-child(' . ($idx + 1) . ')';
$priceStyle = $transaction->type == Transaction::WALLET_AWARD ? 'text-success' : 'text-danger';
$browser->assertSeeIn("$selector td.description", $transaction->shortDescription())
->assertMissing("$selector td.selection button")
->assertVisible("$selector td.price.{$priceStyle}");
// TODO: Test more transaction details
}
// Load the next page
$browser->click('.more-loader button')
->waitUntilMissing('.app-loader')
->assertElementsCount('table tbody tr', 12)
->assertMissing('.more-loader button');
$debitEntry = null;
foreach ($pages[1] as $idx => $transaction) {
$selector = 'table tbody tr:nth-child(' . ($idx + 1 + 10) . ')';
$priceStyle = $transaction->type == Transaction::WALLET_CREDIT ? 'text-success' : 'text-danger';
$browser->assertSeeIn("$selector td.description", $transaction->shortDescription());
if ($transaction->type == Transaction::WALLET_DEBIT) {
$debitEntry = $selector;
} else {
$browser->assertMissing("$selector td.selection button");
}
}
// Load sub-transactions
$browser->click("$debitEntry td.selection button")
->waitUntilMissing('.app-loader')
->assertElementsCount("$debitEntry td.description ul li", 2)
->assertMissing("$debitEntry td.selection button");
});
});
}
/**
* Test that non-controller user has no access to wallet
*/
public function testAccessDenied(): void
{
$this->browse(function (Browser $browser) {
$browser->visit('/logout')
->on(new Home())
->submitLogon('jack@kolab.org', 'simple123', true)
->on(new Dashboard())
->assertMissing('@links .link-wallet')
->visit('/wallet')
->assertErrorPage(403, "Only account owners can access a wallet.");
});
}
}
diff --git a/src/tests/Feature/Console/Data/Stats/CollectorTest.php b/src/tests/Feature/Console/Data/Stats/CollectorTest.php
index be3aec48..231508d9 100644
--- a/src/tests/Feature/Console/Data/Stats/CollectorTest.php
+++ b/src/tests/Feature/Console/Data/Stats/CollectorTest.php
@@ -1,78 +1,78 @@
<?php
namespace Tests\Feature\Console\Data\Stats;
use App\Http\Controllers\API\V4\Admin\StatsController;
-use App\Providers\PaymentProvider;
+use App\Payment;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class CollectorTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
DB::table('stats')->truncate();
DB::table('payments')->truncate();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
DB::table('stats')->truncate();
DB::table('payments')->truncate();
parent::tearDown();
}
/**
* Test the command
*/
public function testHandle(): void
{
$code = \Artisan::call("data:stats:collector");
$output = trim(\Artisan::output());
$this->assertSame(0, $code);
$stats = DB::table('stats')->get();
$this->assertSame(0, $stats->count());
$john = $this->getTestUser('john@kolab.org');
$wallet = $john->wallet();
\App\Payment::create([
'id' => 'test1',
'description' => '',
- 'status' => PaymentProvider::STATUS_PAID,
+ 'status' => Payment::STATUS_PAID,
'amount' => 1000,
'credit_amount' => 1000,
- 'type' => PaymentProvider::TYPE_ONEOFF,
+ 'type' => Payment::TYPE_ONEOFF,
'wallet_id' => $wallet->id,
'provider' => 'mollie',
'currency' => $wallet->currency,
'currency_amount' => 1000,
]);
$code = \Artisan::call("data:stats:collector");
$output = trim(\Artisan::output());
$this->assertSame(0, $code);
$stats = DB::table('stats')->get();
$this->assertSame(1, $stats->count());
$this->assertSame(StatsController::TYPE_PAYERS, $stats[0]->type);
$this->assertEquals(\config('app.tenant_id'), $stats[0]->tenant_id);
$this->assertEquals(4, $stats[0]->value); // there's 4 users in john's wallet
// TODO: More precise tests (degraded users)
}
}
diff --git a/src/tests/Feature/Controller/Admin/StatsTest.php b/src/tests/Feature/Controller/Admin/StatsTest.php
index 58a42d39..ccc21779 100644
--- a/src/tests/Feature/Controller/Admin/StatsTest.php
+++ b/src/tests/Feature/Controller/Admin/StatsTest.php
@@ -1,262 +1,261 @@
<?php
namespace Tests\Feature\Controller\Admin;
use App\Http\Controllers\API\V4\Admin\StatsController;
use App\Payment;
-use App\Providers\PaymentProvider;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class StatsTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
self::useAdminUrl();
Payment::query()->delete();
DB::table('wallets')->update(['discount_id' => null]);
$this->deleteTestUser('test-stats@' . \config('app.domain'));
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
Payment::query()->delete();
DB::table('wallets')->update(['discount_id' => null]);
$this->deleteTestUser('test-stats@' . \config('app.domain'));
parent::tearDown();
}
/**
* Test charts (GET /api/v4/stats/chart/<chart>)
*/
public function testChart(): void
{
$user = $this->getTestUser('john@kolab.org');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
// Non-admin user
$response = $this->actingAs($user)->get("api/v4/stats/chart/discounts");
$response->assertStatus(403);
// Unknown chart name
$response = $this->actingAs($admin)->get("api/v4/stats/chart/unknown");
$response->assertStatus(404);
// 'discounts' chart
$response = $this->actingAs($admin)->get("api/v4/stats/chart/discounts");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('Discounts', $json['title']);
$this->assertSame('donut', $json['type']);
$this->assertSame([], $json['data']['labels']);
$this->assertSame([['values' => []]], $json['data']['datasets']);
// 'income' chart
$response = $this->actingAs($admin)->get("api/v4/stats/chart/income");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('Income in CHF - last 8 weeks', $json['title']);
$this->assertSame('bar', $json['type']);
$this->assertCount(8, $json['data']['labels']);
$this->assertSame(date('Y-W'), $json['data']['labels'][7]);
$this->assertSame([['values' => [0,0,0,0,0,0,0,0]]], $json['data']['datasets']);
// 'users' chart
$response = $this->actingAs($admin)->get("api/v4/stats/chart/users");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('Users - last 8 weeks', $json['title']);
$this->assertCount(8, $json['data']['labels']);
$this->assertSame(date('Y-W'), $json['data']['labels'][7]);
$this->assertCount(2, $json['data']['datasets']);
$this->assertSame('Created', $json['data']['datasets'][0]['name']);
$this->assertSame('Deleted', $json['data']['datasets'][1]['name']);
// 'users-all' chart
$response = $this->actingAs($admin)->get("api/v4/stats/chart/users-all");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('All Users - last year', $json['title']);
$this->assertCount(54, $json['data']['labels']);
$this->assertCount(1, $json['data']['datasets']);
// 'vouchers' chart
$discount = \App\Discount::withObjectTenantContext($user)->where('code', 'TEST')->first();
$wallet = $user->wallets->first();
$wallet->discount()->associate($discount);
$wallet->save();
$response = $this->actingAs($admin)->get("api/v4/stats/chart/vouchers");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('Vouchers', $json['title']);
$this->assertSame(['TEST'], $json['data']['labels']);
$this->assertSame([['values' => [1]]], $json['data']['datasets']);
}
/**
* Test income chart currency handling
*/
public function testChartIncomeCurrency(): void
{
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$john = $this->getTestUser('john@kolab.org');
$user = $this->getTestUser('test-stats@' . \config('app.domain'));
$wallet = $user->wallets()->first();
$wallet->currency = 'EUR';
$wallet->save();
$johns_wallet = $john->wallets()->first();
// Create some test payments
Payment::create([
'id' => 'test1',
'description' => '',
- 'status' => PaymentProvider::STATUS_PAID,
+ 'status' => Payment::STATUS_PAID,
'amount' => 1000,
'credit_amount' => 1000,
- 'type' => PaymentProvider::TYPE_ONEOFF,
+ 'type' => Payment::TYPE_ONEOFF,
'wallet_id' => $wallet->id,
'provider' => 'mollie',
'currency' => 'EUR',
'currency_amount' => 1000,
]);
Payment::create([
'id' => 'test2',
'description' => '',
- 'status' => PaymentProvider::STATUS_PAID,
+ 'status' => Payment::STATUS_PAID,
'amount' => 2000,
'credit_amount' => 2000,
- 'type' => PaymentProvider::TYPE_RECURRING,
+ 'type' => Payment::TYPE_RECURRING,
'wallet_id' => $wallet->id,
'provider' => 'mollie',
'currency' => 'EUR',
'currency_amount' => 2000,
]);
Payment::create([
'id' => 'test3',
'description' => '',
- 'status' => PaymentProvider::STATUS_PAID,
+ 'status' => Payment::STATUS_PAID,
'amount' => 3000,
'credit_amount' => 3000,
- 'type' => PaymentProvider::TYPE_ONEOFF,
+ 'type' => Payment::TYPE_ONEOFF,
'wallet_id' => $johns_wallet->id,
'provider' => 'mollie',
'currency' => 'EUR',
'currency_amount' => 2800,
]);
Payment::create([
'id' => 'test4',
'description' => '',
- 'status' => PaymentProvider::STATUS_PAID,
+ 'status' => Payment::STATUS_PAID,
'amount' => 4000,
'credit_amount' => 4000,
- 'type' => PaymentProvider::TYPE_RECURRING,
+ 'type' => Payment::TYPE_RECURRING,
'wallet_id' => $johns_wallet->id,
'provider' => 'mollie',
'currency' => 'CHF',
'currency_amount' => 4000,
]);
Payment::create([
'id' => 'test5',
'description' => '',
- 'status' => PaymentProvider::STATUS_OPEN,
+ 'status' => Payment::STATUS_OPEN,
'amount' => 5000,
'credit_amount' => 5000,
- 'type' => PaymentProvider::TYPE_ONEOFF,
+ 'type' => Payment::TYPE_ONEOFF,
'wallet_id' => $johns_wallet->id,
'provider' => 'mollie',
'currency' => 'CHF',
'currency_amount' => 5000,
]);
Payment::create([
'id' => 'test6',
'description' => '',
- 'status' => PaymentProvider::STATUS_FAILED,
+ 'status' => Payment::STATUS_FAILED,
'amount' => 6000,
'credit_amount' => 6000,
- 'type' => PaymentProvider::TYPE_ONEOFF,
+ 'type' => Payment::TYPE_ONEOFF,
'wallet_id' => $johns_wallet->id,
'provider' => 'mollie',
'currency' => 'CHF',
'currency_amount' => 6000,
]);
// 'income' chart
$response = $this->actingAs($admin)->get("api/v4/stats/chart/income");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('Income in CHF - last 8 weeks', $json['title']);
$this->assertSame('bar', $json['type']);
$this->assertCount(8, $json['data']['labels']);
$this->assertSame(date('Y-W'), $json['data']['labels'][7]);
// 7000 CHF + 3000 EUR =
$expected = 7000 + intval(round(3000 * \App\Utils::exchangeRate('EUR', 'CHF')));
$this->assertCount(1, $json['data']['datasets']);
$this->assertSame($expected / 100, $json['data']['datasets'][0]['values'][7]);
}
/**
* Test payers chart
*/
public function testChartPayers(): void
{
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
DB::table('stats')->truncate();
$response = $this->actingAs($admin)->get("api/v4/stats/chart/payers");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('Payers - last year', $json['title']);
$this->assertSame('line', $json['type']);
$this->assertCount(54, $json['data']['labels']);
$this->assertSame(date('Y-W'), $json['data']['labels'][53]);
$this->assertCount(1, $json['data']['datasets']);
$this->assertCount(54, $json['data']['datasets'][0]['values']);
DB::table('stats')->insert([
'type' => StatsController::TYPE_PAYERS,
'value' => 5,
'created_at' => \now(),
]);
DB::table('stats')->insert([
'type' => StatsController::TYPE_PAYERS,
'value' => 7,
'created_at' => \now(),
]);
$response = $this->actingAs($admin)->get("api/v4/stats/chart/payers");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(6, $json['data']['datasets'][0]['values'][53]);
}
}
diff --git a/src/tests/Feature/Controller/PaymentsCoinbaseTest.php b/src/tests/Feature/Controller/PaymentsCoinbaseTest.php
index ecfdc572..6f46da1f 100644
--- a/src/tests/Feature/Controller/PaymentsCoinbaseTest.php
+++ b/src/tests/Feature/Controller/PaymentsCoinbaseTest.php
@@ -1,447 +1,446 @@
<?php
namespace Tests\Feature\Controller;
use App\Http\Controllers\API\V4\PaymentsController;
use App\Payment;
-use App\Providers\PaymentProvider;
use App\Transaction;
use App\Wallet;
use App\WalletSetting;
use App\Utils;
use GuzzleHttp\Psr7\Response;
use Illuminate\Support\Facades\Bus;
use Tests\TestCase;
use Tests\BrowserAddonTrait;
use Tests\CoinbaseMocksTrait;
class PaymentsCoinbaseTest extends TestCase
{
use CoinbaseMocksTrait;
use BrowserAddonTrait;
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
\config(['services.payment_provider' => '']);
Utils::setTestExchangeRates(['EUR' => '0.90503424978382']);
$john = $this->getTestUser('john@kolab.org');
$wallet = $john->wallets()->first();
Payment::where('wallet_id', $wallet->id)->delete();
Wallet::where('id', $wallet->id)->update(['balance' => 0]);
WalletSetting::where('wallet_id', $wallet->id)->delete();
$types = [
Transaction::WALLET_CREDIT,
Transaction::WALLET_REFUND,
Transaction::WALLET_CHARGEBACK,
];
Transaction::where('object_id', $wallet->id)->whereIn('type', $types)->delete();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$john = $this->getTestUser('john@kolab.org');
$wallet = $john->wallets()->first();
Payment::where('wallet_id', $wallet->id)->delete();
Wallet::where('id', $wallet->id)->update(['balance' => 0]);
WalletSetting::where('wallet_id', $wallet->id)->delete();
$types = [
Transaction::WALLET_CREDIT,
Transaction::WALLET_REFUND,
Transaction::WALLET_CHARGEBACK,
];
Transaction::where('object_id', $wallet->id)->whereIn('type', $types)->delete();
Utils::setTestExchangeRates([]);
parent::tearDown();
}
/**
* Test creating a payment and receiving a status via webhook
*
* @group coinbase
*/
public function testStoreAndWebhook(): void
{
Bus::fake();
// Unauth access not allowed
$response = $this->post("api/v4/payments", []);
$response->assertStatus(401);
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
// Invalid amount
$post = ['amount' => -1];
$response = $this->actingAs($user)->post("api/v4/payments", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
- $min = $wallet->money(PaymentProvider::MIN_AMOUNT);
+ $min = $wallet->money(Payment::MIN_AMOUNT);
$this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']);
// Invalid currency
$post = ['amount' => '12.34', 'currency' => 'FOO', 'methodId' => 'bitcoin'];
$response = $this->actingAs($user)->post("api/v4/payments", $post);
$response->assertStatus(500);
// Rate limit exceeded
$coinbase_response = [
'error' => [
'type' => 'rate_limit_exceeded',
'message' => 'Rate limit exceeded',
],
];
$responseStack = $this->mockCoinbase();
$responseStack->append(new Response(429, [], json_encode($coinbase_response)));
$post = ['amount' => '12.34', 'currency' => 'BTC', 'methodId' => 'bitcoin'];
$response = $this->actingAs($user)->post("api/v4/payments", $post);
$response->assertStatus(500);
// Rate limit exceeded
$coinbase_response = [
'error' => [
'type' => 'invalid_request',
'message' => 'Required parameter missing: name',
],
];
$responseStack = $this->mockCoinbase();
$responseStack->append(new Response(400, [], json_encode($coinbase_response)));
$post = ['amount' => '12.34', 'currency' => 'BTC', 'methodId' => 'bitcoin'];
$response = $this->actingAs($user)->post("api/v4/payments", $post);
$response->assertStatus(500);
// Successful payment
$coinbase_response = [
'reason' => 'Created',
'data' => [
'code' => 'test123',
'hosted_url' => 'https://commerce.coinbase.com',
'pricing' => [
'bitcoin' => [
'amount' => 0.0000005,
],
],
],
];
$responseStack = $this->mockCoinbase();
$responseStack->append(new Response(201, [], json_encode($coinbase_response)));
$post = ['amount' => '12.34', 'currency' => 'BTC', 'methodId' => 'bitcoin'];
$response = $this->actingAs($user)->post("api/v4/payments", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertMatchesRegularExpression('|^https://commerce.coinbase.com|', $json['newWindowUrl']);
$payments = Payment::where('wallet_id', $wallet->id)->get();
$this->assertCount(1, $payments);
$payment = $payments[0];
$this->assertSame(1234, $payment->amount);
$this->assertSame(5, $payment->currency_amount);
$this->assertSame('BTC', $payment->currency);
$this->assertSame($user->tenant->title . ' Payment', $payment->description);
$this->assertSame('open', $payment->status);
$this->assertEquals(0, $wallet->balance);
// Test the webhook
$post = [
'event' =>
[
'api_version' => '2018-03-22',
'data' => [
'code' => $payment->id,
],
'type' => 'charge:resolved',
],
];
$response = $this->webhookRequest($post);
$response->assertStatus(200);
- $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status);
+ $this->assertSame(Payment::STATUS_PAID, $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
$transaction = $wallet->transactions()
->where('type', Transaction::WALLET_CREDIT)->get()->last();
$this->assertSame(1234, $transaction->amount);
$this->assertSame(
"Payment transaction {$payment->id} using Coinbase",
$transaction->description
);
// Assert that email notification job wasn't dispatched,
// it is expected only for recurring payments
Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0);
// Verify "paid -> open -> paid" scenario, assert that balance didn't change
$post = [
'event' =>
[
'api_version' => '2018-03-22',
'data' => [
'code' => $payment->id,
],
'type' => 'charge:created',
],
];
$response = $this->webhookRequest($post);
$response->assertStatus(200);
- $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status);
+ $this->assertSame(Payment::STATUS_PAID, $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
$post = [
'event' =>
[
'api_version' => '2018-03-22',
'data' => [
'code' => $payment->id,
],
'type' => 'charge:resolved',
],
];
$response = $this->webhookRequest($post);
$response->assertStatus(200);
- $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status);
+ $this->assertSame(Payment::STATUS_PAID, $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
// Test for payment failure
Bus::fake();
$payment->refresh();
- $payment->status = PaymentProvider::STATUS_OPEN;
+ $payment->status = Payment::STATUS_OPEN;
$payment->save();
$post = [
'event' =>
[
'api_version' => '2018-03-22',
'data' => [
'code' => $payment->id,
],
'type' => 'charge:failed',
],
];
$response = $this->webhookRequest($post);
$response->assertStatus(200);
$this->assertSame('failed', $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
// Assert that email notification job wasn't dispatched,
// it is expected only for recurring payments
Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0);
}
/**
* Test creating a payment and receiving a status via webhook using a foreign currency
*
* @group coinbase
*/
public function testStoreAndWebhookForeignCurrency(): void
{
Bus::fake();
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
// Successful payment in BTC
$coinbase_response = [
'reason' => 'Created',
'data' => [
'code' => 'test123',
'hosted_url' => 'www.hosted.com',
'pricing' => [
'bitcoin' => [
'amount' => 0.0000005,
],
],
],
];
$responseStack = $this->mockCoinbase();
$responseStack->append(new Response(201, [], json_encode($coinbase_response)));
$post = ['amount' => '12.34', 'currency' => 'BTC', 'methodId' => 'bitcoin'];
$response = $this->actingAs($user)->post("api/v4/payments", $post);
$response->assertStatus(200);
$payment = $wallet->payments()
->where('currency', 'BTC')->get()->last();
$this->assertSame(1234, $payment->amount);
$this->assertSame(5, $payment->currency_amount);
$this->assertSame('BTC', $payment->currency);
$this->assertEquals(0, $wallet->balance);
$post = [
'event' =>
[
'api_version' => '2018-03-22',
'data' => [
'code' => $payment->id,
],
'type' => 'charge:resolved',
],
];
$response = $this->webhookRequest($post);
$response->assertStatus(200);
- $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status);
+ $this->assertSame(Payment::STATUS_PAID, $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
}
/**
* Generate Coinbase-Signature header for a webhook payload
*/
protected function webhookRequest($post)
{
$secret = \config('services.coinbase.webhook_secret');
$payload = json_encode($post);
$sig = \hash_hmac('sha256', $payload, $secret);
return $this->withHeaders(['x-cc-webhook-signature' => $sig])
->json('POST', "api/webhooks/payment/coinbase", $post);
}
/**
* Test listing a pending payment
*
* @group coinbase
*/
public function testListingPayments(): void
{
Bus::fake();
$user = $this->getTestUser('john@kolab.org');
//Empty response
$response = $this->actingAs($user)->get("api/v4/payments/pending");
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame(0, $json['count']);
$this->assertSame(1, $json['page']);
$this->assertSame(false, $json['hasMore']);
$this->assertCount(0, $json['list']);
$response = $this->actingAs($user)->get("api/v4/payments/has-pending");
$json = $response->json();
$this->assertSame(false, $json['hasPending']);
$wallet = $user->wallets()->first();
// Successful payment
$coinbase_response = [
'reason' => 'Created',
'data' => [
'code' => 'test123',
'hosted_url' => 'www.hosted.com',
'pricing' => [
'bitcoin' => [
'amount' => 0.0000005,
],
],
],
];
$responseStack = $this->mockCoinbase();
$responseStack->append(new Response(201, [], json_encode($coinbase_response)));
$post = ['amount' => '12.34', 'currency' => 'BTC', 'methodId' => 'bitcoin'];
$response = $this->actingAs($user)->post("api/v4/payments", $post);
$response->assertStatus(200);
//A response
$response = $this->actingAs($user)->get("api/v4/payments/pending");
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame(1, $json['count']);
$this->assertSame(1, $json['page']);
$this->assertSame(false, $json['hasMore']);
$this->assertCount(1, $json['list']);
- $this->assertSame(PaymentProvider::STATUS_OPEN, $json['list'][0]['status']);
+ $this->assertSame(Payment::STATUS_OPEN, $json['list'][0]['status']);
$this->assertSame('CHF', $json['list'][0]['currency']);
- $this->assertSame(PaymentProvider::TYPE_ONEOFF, $json['list'][0]['type']);
+ $this->assertSame(Payment::TYPE_ONEOFF, $json['list'][0]['type']);
$this->assertSame(1234, $json['list'][0]['amount']);
$response = $this->actingAs($user)->get("api/v4/payments/has-pending");
$json = $response->json();
$this->assertSame(true, $json['hasPending']);
// Set the payment to paid
$payments = Payment::where('wallet_id', $wallet->id)->get();
$this->assertCount(1, $payments);
$payment = $payments[0];
- $payment->status = PaymentProvider::STATUS_PAID;
+ $payment->status = Payment::STATUS_PAID;
$payment->save();
// They payment should be gone from the pending list now
$response = $this->actingAs($user)->get("api/v4/payments/pending");
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame(0, $json['count']);
$this->assertCount(0, $json['list']);
$response = $this->actingAs($user)->get("api/v4/payments/has-pending");
$json = $response->json();
$this->assertSame(false, $json['hasPending']);
}
/**
* Test listing payment methods
*
* @group coinbase
*/
public function testListingPaymentMethods(): void
{
Bus::fake();
$user = $this->getTestUser('john@kolab.org');
- $response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . PaymentProvider::TYPE_ONEOFF);
+ $response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . Payment::TYPE_ONEOFF);
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(4, $json);
$this->assertSame('bitcoin', $json[3]['id']);
$this->assertSame('BTC', $json[3]['currency']);
- $response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . PaymentProvider::TYPE_RECURRING);
+ $response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . Payment::TYPE_RECURRING);
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(1, $json);
}
}
diff --git a/src/tests/Feature/Controller/PaymentsMollieEuroTest.php b/src/tests/Feature/Controller/PaymentsMollieEuroTest.php
index e8eda36f..9405fa7f 100644
--- a/src/tests/Feature/Controller/PaymentsMollieEuroTest.php
+++ b/src/tests/Feature/Controller/PaymentsMollieEuroTest.php
@@ -1,937 +1,937 @@
<?php
namespace Tests\Feature\Controller;
use App\Http\Controllers\API\V4\PaymentsController;
use App\Payment;
use App\Providers\PaymentProvider;
use App\Transaction;
use App\Wallet;
use App\WalletSetting;
use GuzzleHttp\Psr7\Response;
use Illuminate\Support\Facades\Bus;
use Tests\TestCase;
use Tests\BrowserAddonTrait;
use Tests\MollieMocksTrait;
class PaymentsMollieEuroTest extends TestCase
{
use MollieMocksTrait;
use BrowserAddonTrait;
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
// All tests in this file use Mollie
\config(['services.payment_provider' => 'mollie']);
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('euro@' . \config('app.domain'));
parent::tearDown();
}
/**
* Test creating/updating/deleting an outo-payment mandate
*
* @group mollie
*/
public function testMandates(): void
{
// Unauth access not allowed
$response = $this->get("api/v4/payments/mandate");
$response->assertStatus(401);
$response = $this->post("api/v4/payments/mandate", []);
$response->assertStatus(401);
$response = $this->put("api/v4/payments/mandate", []);
$response->assertStatus(401);
$response = $this->delete("api/v4/payments/mandate");
$response->assertStatus(401);
$user = $this->getTestUser('euro@' . \config('app.domain'));
$wallet = $user->wallets()->first();
$wallet->currency = 'EUR';
$wallet->save();
// Test creating a mandate (invalid input)
$post = [];
$response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json['errors']);
$this->assertSame('The amount field is required.', $json['errors']['amount'][0]);
$this->assertSame('The balance field is required.', $json['errors']['balance'][0]);
// Test creating a mandate (invalid input)
$post = ['amount' => 100, 'balance' => 'a'];
$response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertSame('The balance must be a number.', $json['errors']['balance'][0]);
// Test creating a mandate (amount smaller than the minimum value)
$post = ['amount' => -100, 'balance' => 0];
$response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
- $min = $wallet->money(PaymentProvider::MIN_AMOUNT);
+ $min = $wallet->money(Payment::MIN_AMOUNT);
$this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']);
$this->assertMatchesRegularExpression("/[0-9.,]+ €\.$/", $json['errors']['amount']);
// Test creating a mandate (negative balance, amount too small)
Wallet::where('id', $wallet->id)->update(['balance' => -2000]);
- $post = ['amount' => PaymentProvider::MIN_AMOUNT / 100, 'balance' => 0];
+ $post = ['amount' => Payment::MIN_AMOUNT / 100, 'balance' => 0];
$response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertSame("The specified amount does not cover the balance on the account.", $json['errors']['amount']);
// Test creating a mandate (valid input)
$post = ['amount' => 20.10, 'balance' => 0, 'methodId' => PaymentProvider::METHOD_CREDITCARD];
$response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertMatchesRegularExpression('|^https://www.mollie.com|', $json['redirectUrl']);
// Assert the proper payment amount has been used
$payment = Payment::where('id', $json['id'])->first();
$this->assertSame(2010, $payment->amount);
$this->assertSame($wallet->id, $payment->wallet_id);
$this->assertSame($user->tenant->title . " Auto-Payment Setup", $payment->description);
- $this->assertSame(PaymentProvider::TYPE_MANDATE, $payment->type);
+ $this->assertSame(Payment::TYPE_MANDATE, $payment->type);
// Test fetching the mandate information
$response = $this->actingAs($user)->get("api/v4/payments/mandate");
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals(20.10, $json['amount']);
$this->assertEquals(0, $json['balance']);
$this->assertEquals('Credit Card', $json['method']);
$this->assertSame(true, $json['isPending']);
$this->assertSame(false, $json['isValid']);
$this->assertSame(false, $json['isDisabled']);
$mandate_id = $json['id'];
// We would have to invoke a browser to accept the "first payment" to make
// the mandate validated/completed. Instead, we'll mock the mandate object.
$mollie_response = [
'resource' => 'mandate',
'id' => $mandate_id,
'status' => 'valid',
'method' => 'creditcard',
'details' => [
'cardNumber' => '4242',
'cardLabel' => 'Visa',
],
'customerId' => 'cst_GMfxGPt7Gj',
'createdAt' => '2020-04-28T11:09:47+00:00',
];
$responseStack = $this->mockMollie();
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$wallet = $user->wallets()->first();
$wallet->setSetting('mandate_disabled', 1);
$response = $this->actingAs($user)->get("api/v4/payments/mandate");
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals(20.10, $json['amount']);
$this->assertEquals(0, $json['balance']);
$this->assertEquals('Visa (**** **** **** 4242)', $json['method']);
$this->assertSame(false, $json['isPending']);
$this->assertSame(true, $json['isValid']);
$this->assertSame(true, $json['isDisabled']);
Bus::fake();
$wallet->setSetting('mandate_disabled', null);
$wallet->balance = 1000;
$wallet->save();
// Test updating mandate details (invalid input)
$post = [];
$response = $this->actingAs($user)->put("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json['errors']);
$this->assertSame('The amount field is required.', $json['errors']['amount'][0]);
$this->assertSame('The balance field is required.', $json['errors']['balance'][0]);
$post = ['amount' => -100, 'balance' => 0];
$response = $this->actingAs($user)->put("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']);
$this->assertMatchesRegularExpression("/[0-9.,]+ €\.$/", $json['errors']['amount']);
// Test updating a mandate (valid input)
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$post = ['amount' => 30.10, 'balance' => 10];
$response = $this->actingAs($user)->put("api/v4/payments/mandate", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame('The auto-payment has been updated.', $json['message']);
$this->assertSame($mandate_id, $json['id']);
$this->assertFalse($json['isDisabled']);
$wallet->refresh();
$this->assertEquals(30.10, $wallet->getSetting('mandate_amount'));
$this->assertEquals(10, $wallet->getSetting('mandate_balance'));
Bus::assertDispatchedTimes(\App\Jobs\WalletCharge::class, 0);
// Test updating a disabled mandate (invalid input)
$wallet->setSetting('mandate_disabled', 1);
$wallet->balance = -2000;
$wallet->save();
$user->refresh(); // required so the controller sees the wallet update from above
$post = ['amount' => 15.10, 'balance' => 1];
$response = $this->actingAs($user)->put("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertSame('The specified amount does not cover the balance on the account.', $json['errors']['amount']);
// Test updating a disabled mandate (valid input)
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$post = ['amount' => 30, 'balance' => 1];
$response = $this->actingAs($user)->put("api/v4/payments/mandate", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame('The auto-payment has been updated.', $json['message']);
$this->assertSame($mandate_id, $json['id']);
$this->assertFalse($json['isDisabled']);
Bus::assertDispatchedTimes(\App\Jobs\WalletCharge::class, 1);
Bus::assertDispatched(\App\Jobs\WalletCharge::class, function ($job) use ($wallet) {
$job_wallet = $this->getObjectProperty($job, 'wallet');
return $job_wallet->id === $wallet->id;
});
$this->unmockMollie();
// Delete mandate
$response = $this->actingAs($user)->delete("api/v4/payments/mandate");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame('The auto-payment has been removed.', $json['message']);
// Confirm with Mollie the mandate does not exist
$customer_id = $wallet->getSetting('mollie_id');
$this->expectException(\Mollie\Api\Exceptions\ApiException::class);
$this->expectExceptionMessageMatches('/410: Gone/');
$mandate = mollie()->mandates()->getForId($customer_id, $mandate_id);
$this->assertNull($wallet->fresh()->getSetting('mollie_mandate_id'));
// Test Mollie's "410 Gone" response handling when fetching the mandate info
// It is expected to remove the mandate reference
$mollie_response = [
'status' => 410,
'title' => "Gone",
'detail' => "You are trying to access an object, which has previously been deleted",
'_links' => [
'documentation' => [
'href' => "https://docs.mollie.com/errors",
'type' => "text/html"
]
]
];
$responseStack = $this->mockMollie();
$responseStack->append(new Response(410, [], json_encode($mollie_response)));
$wallet->fresh()->setSetting('mollie_mandate_id', '123');
$response = $this->actingAs($user)->get("api/v4/payments/mandate");
$response->assertStatus(200);
$json = $response->json();
$this->assertFalse(array_key_exists('id', $json));
$this->assertFalse(array_key_exists('method', $json));
$this->assertNull($wallet->fresh()->getSetting('mollie_mandate_id'));
}
/**
* Test creating a payment and receiving a status via webhook
*
* @group mollie
*/
public function testStoreAndWebhook(): void
{
Bus::fake();
// Unauth access not allowed
$response = $this->post("api/v4/payments", []);
$response->assertStatus(401);
$user = $this->getTestUser('euro@' . \config('app.domain'));
$wallet = $user->wallets()->first();
$wallet->currency = 'EUR';
$wallet->save();
// Invalid amount
$post = ['amount' => -1];
$response = $this->actingAs($user)->post("api/v4/payments", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
- $min = $wallet->money(PaymentProvider::MIN_AMOUNT);
+ $min = $wallet->money(Payment::MIN_AMOUNT);
$this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']);
$this->assertMatchesRegularExpression("/[0-9.,]+ €\.$/", $json['errors']['amount']);
// Invalid currency
$post = ['amount' => '12.34', 'currency' => 'FOO', 'methodId' => 'creditcard'];
$response = $this->actingAs($user)->post("api/v4/payments", $post);
$response->assertStatus(500);
// Successful payment
$post = ['amount' => '12.34', 'currency' => 'EUR', 'methodId' => 'creditcard'];
$response = $this->actingAs($user)->post("api/v4/payments", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertMatchesRegularExpression('|^https://www.mollie.com|', $json['redirectUrl']);
$payments = Payment::where('wallet_id', $wallet->id)->get();
$this->assertCount(1, $payments);
$payment = $payments[0];
$this->assertSame(1234, $payment->amount);
$this->assertSame(1234, $payment->currency_amount);
$this->assertSame('EUR', $payment->currency);
$this->assertSame($user->tenant->title . ' Payment', $payment->description);
$this->assertSame('open', $payment->status);
$this->assertEquals(0, $wallet->balance);
// Test the webhook
// Note: Webhook end-point does not require authentication
$mollie_response = [
"resource" => "payment",
"id" => $payment->id,
"status" => "paid",
// Status is not enough, paidAt is used to distinguish the state
"paidAt" => date('c'),
"mode" => "test",
];
// We'll trigger the webhook with payment id and use mocking for
// a request to the Mollie payments API. We cannot force Mollie
// to make the payment status change.
$responseStack = $this->mockMollie();
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$post = ['id' => $payment->id];
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
- $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status);
+ $this->assertSame(Payment::STATUS_PAID, $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
$transaction = $wallet->transactions()
->where('type', Transaction::WALLET_CREDIT)->get()->last();
$this->assertSame(1234, $transaction->amount);
$this->assertSame(
"Payment transaction {$payment->id} using Mollie",
$transaction->description
);
// Assert that email notification job wasn't dispatched,
// it is expected only for recurring payments
Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0);
// Verify "paid -> open -> paid" scenario, assert that balance didn't change
$mollie_response['status'] = 'open';
unset($mollie_response['paidAt']);
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
- $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status);
+ $this->assertSame(Payment::STATUS_PAID, $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
$mollie_response['status'] = 'paid';
$mollie_response['paidAt'] = date('c');
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
- $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status);
+ $this->assertSame(Payment::STATUS_PAID, $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
// Test for payment failure
Bus::fake();
$payment->refresh();
- $payment->status = PaymentProvider::STATUS_OPEN;
+ $payment->status = Payment::STATUS_OPEN;
$payment->save();
$mollie_response = [
"resource" => "payment",
"id" => $payment->id,
"status" => "failed",
"mode" => "test",
];
// We'll trigger the webhook with payment id and use mocking for
// a request to the Mollie payments API. We cannot force Mollie
// to make the payment status change.
$responseStack = $this->mockMollie();
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$this->assertSame('failed', $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
// Assert that email notification job wasn't dispatched,
// it is expected only for recurring payments
Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0);
}
/**
* Test automatic payment charges
*
* @group mollie
*/
public function testTopUp(): void
{
Bus::fake();
$user = $this->getTestUser('euro@' . \config('app.domain'));
$wallet = $user->wallets()->first();
$wallet->currency = 'EUR';
$wallet->save();
// Create a valid mandate first (balance=0, so there's no extra payment yet)
$this->createMandate($wallet, ['amount' => 20.10, 'balance' => 0, 'methodId' => 'creditcard']);
$wallet->setSetting('mandate_balance', 10);
// Expect a recurring payment as we have a valid mandate at this point
// and the balance is below the threshold
$result = PaymentsController::topUpWallet($wallet);
$this->assertTrue($result);
// Check that the payments table contains a new record with proper amount.
// There should be two records, one for the mandate payment and another for
// the top-up payment
$payments = $wallet->payments()->orderBy('amount')->get();
$this->assertCount(2, $payments);
$this->assertSame(0, $payments[0]->amount);
$this->assertSame(0, $payments[0]->currency_amount);
$this->assertSame(2010, $payments[1]->amount);
$this->assertSame(2010, $payments[1]->currency_amount);
$payment = $payments[1];
// In mollie we don't have to wait for a webhook, the response to
// PaymentIntent already sets the status to 'paid', so we can test
// immediately the balance update
// Assert that email notification job has been dispatched
- $this->assertSame(PaymentProvider::STATUS_PAID, $payment->status);
+ $this->assertSame(Payment::STATUS_PAID, $payment->status);
$this->assertEquals(2010, $wallet->fresh()->balance);
$transaction = $wallet->transactions()
->where('type', Transaction::WALLET_CREDIT)->get()->last();
$this->assertSame(2010, $transaction->amount);
$this->assertSame(
"Auto-payment transaction {$payment->id} using Mastercard (**** **** **** 9399)",
$transaction->description
);
Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1);
Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) {
$job_payment = $this->getObjectProperty($job, 'payment');
return $job_payment->id === $payment->id;
});
// Expect no payment if the mandate is disabled
$wallet->setSetting('mandate_disabled', 1);
$result = PaymentsController::topUpWallet($wallet);
$this->assertFalse($result);
$this->assertCount(2, $wallet->payments()->get());
// Expect no payment if balance is ok
$wallet->setSetting('mandate_disabled', null);
$wallet->balance = 1000;
$wallet->save();
$result = PaymentsController::topUpWallet($wallet);
$this->assertFalse($result);
$this->assertCount(2, $wallet->payments()->get());
// Expect no payment if the top-up amount is not enough
$wallet->setSetting('mandate_disabled', null);
$wallet->balance = -2050;
$wallet->save();
$result = PaymentsController::topUpWallet($wallet);
$this->assertFalse($result);
$this->assertCount(2, $wallet->payments()->get());
Bus::assertDispatchedTimes(\App\Jobs\PaymentMandateDisabledEmail::class, 1);
Bus::assertDispatched(\App\Jobs\PaymentMandateDisabledEmail::class, function ($job) use ($wallet) {
$job_wallet = $this->getObjectProperty($job, 'wallet');
return $job_wallet->id === $wallet->id;
});
// Expect no payment if there's no mandate
$wallet->setSetting('mollie_mandate_id', null);
$wallet->balance = 0;
$wallet->save();
$result = PaymentsController::topUpWallet($wallet);
$this->assertFalse($result);
$this->assertCount(2, $wallet->payments()->get());
Bus::assertDispatchedTimes(\App\Jobs\PaymentMandateDisabledEmail::class, 1);
// Test webhook for recurring payments
$wallet->transactions()->delete();
$responseStack = $this->mockMollie();
Bus::fake();
$payment->refresh();
- $payment->status = PaymentProvider::STATUS_OPEN;
+ $payment->status = Payment::STATUS_OPEN;
$payment->save();
$mollie_response = [
"resource" => "payment",
"id" => $payment->id,
"status" => "paid",
// Status is not enough, paidAt is used to distinguish the state
"paidAt" => date('c'),
"mode" => "test",
];
// We'll trigger the webhook with payment id and use mocking for
// a request to the Mollie payments API. We cannot force Mollie
// to make the payment status change.
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$post = ['id' => $payment->id];
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
- $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status);
+ $this->assertSame(Payment::STATUS_PAID, $payment->fresh()->status);
$this->assertEquals(2010, $wallet->fresh()->balance);
$transaction = $wallet->transactions()
->where('type', Transaction::WALLET_CREDIT)->get()->last();
$this->assertSame(2010, $transaction->amount);
$this->assertSame(
"Auto-payment transaction {$payment->id} using Mollie",
$transaction->description
);
// Assert that email notification job has been dispatched
Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1);
Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) {
$job_payment = $this->getObjectProperty($job, 'payment');
return $job_payment->id === $payment->id;
});
Bus::fake();
// Test for payment failure
$payment->refresh();
- $payment->status = PaymentProvider::STATUS_OPEN;
+ $payment->status = Payment::STATUS_OPEN;
$payment->save();
$wallet->setSetting('mollie_mandate_id', 'xxx');
$wallet->setSetting('mandate_disabled', null);
$mollie_response = [
"resource" => "payment",
"id" => $payment->id,
"status" => "failed",
"mode" => "test",
];
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$wallet->refresh();
- $this->assertSame(PaymentProvider::STATUS_FAILED, $payment->fresh()->status);
+ $this->assertSame(Payment::STATUS_FAILED, $payment->fresh()->status);
$this->assertEquals(2010, $wallet->balance);
$this->assertTrue(!empty($wallet->getSetting('mandate_disabled')));
// Assert that email notification job has been dispatched
Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1);
Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) {
$job_payment = $this->getObjectProperty($job, 'payment');
return $job_payment->id === $payment->id;
});
$this->unmockMollie();
}
/**
* Test refund/chargeback handling by the webhook
*
* @group mollie
*/
public function testRefundAndChargeback(): void
{
Bus::fake();
$user = $this->getTestUser('euro@' . \config('app.domain'));
$wallet = $user->wallets()->first();
$wallet->currency = 'EUR';
$wallet->save();
$wallet->transactions()->delete();
$mollie = PaymentProvider::factory('mollie');
// Create a paid payment
$payment = Payment::create([
'id' => 'tr_123456',
- 'status' => PaymentProvider::STATUS_PAID,
+ 'status' => Payment::STATUS_PAID,
'amount' => 123,
'credit_amount' => 123,
'currency_amount' => 123,
'currency' => 'EUR',
- 'type' => PaymentProvider::TYPE_ONEOFF,
+ 'type' => Payment::TYPE_ONEOFF,
'wallet_id' => $wallet->id,
'provider' => 'mollie',
'description' => 'test',
]);
// Test handling a refund by the webhook
$mollie_response1 = [
"resource" => "payment",
"id" => $payment->id,
"status" => "paid",
// Status is not enough, paidAt is used to distinguish the state
"paidAt" => date('c'),
"mode" => "test",
"_links" => [
"refunds" => [
"href" => "https://api.mollie.com/v2/payments/{$payment->id}/refunds",
"type" => "application/hal+json"
]
]
];
$mollie_response2 = [
"count" => 1,
"_links" => [],
"_embedded" => [
"refunds" => [
[
"resource" => "refund",
"id" => "re_123456",
"status" => \Mollie\Api\Types\RefundStatus::STATUS_REFUNDED,
"paymentId" => $payment->id,
"description" => "refund desc",
"amount" => [
"currency" => "EUR",
"value" => "1.01",
],
]
]
]
];
// We'll trigger the webhook with payment id and use mocking for
// requests to the Mollie payments API.
$responseStack = $this->mockMollie();
$responseStack->append(new Response(200, [], json_encode($mollie_response1)));
$responseStack->append(new Response(200, [], json_encode($mollie_response2)));
$post = ['id' => $payment->id];
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$wallet->refresh();
$this->assertEquals(-101, $wallet->balance);
$transactions = $wallet->transactions()->where('type', Transaction::WALLET_REFUND)->get();
$this->assertCount(1, $transactions);
$this->assertSame(-101, $transactions[0]->amount);
$this->assertSame(Transaction::WALLET_REFUND, $transactions[0]->type);
$this->assertSame("refund desc", $transactions[0]->description);
$payments = $wallet->payments()->where('id', 're_123456')->get();
$this->assertCount(1, $payments);
$this->assertSame(-101, $payments[0]->amount);
$this->assertSame(-101, $payments[0]->currency_amount);
- $this->assertSame(PaymentProvider::STATUS_PAID, $payments[0]->status);
- $this->assertSame(PaymentProvider::TYPE_REFUND, $payments[0]->type);
+ $this->assertSame(Payment::STATUS_PAID, $payments[0]->status);
+ $this->assertSame(Payment::TYPE_REFUND, $payments[0]->type);
$this->assertSame("mollie", $payments[0]->provider);
$this->assertSame("refund desc", $payments[0]->description);
// Test handling a chargeback by the webhook
$mollie_response1["_links"] = [
"chargebacks" => [
"href" => "https://api.mollie.com/v2/payments/{$payment->id}/chargebacks",
"type" => "application/hal+json"
]
];
$mollie_response2 = [
"count" => 1,
"_links" => [],
"_embedded" => [
"chargebacks" => [
[
"resource" => "chargeback",
"id" => "chb_123456",
"paymentId" => $payment->id,
"amount" => [
"currency" => "EUR",
"value" => "0.15",
],
]
]
]
];
// We'll trigger the webhook with payment id and use mocking for
// requests to the Mollie payments API.
$responseStack = $this->mockMollie();
$responseStack->append(new Response(200, [], json_encode($mollie_response1)));
$responseStack->append(new Response(200, [], json_encode($mollie_response2)));
$post = ['id' => $payment->id];
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$wallet->refresh();
$this->assertEquals(-116, $wallet->balance);
$transactions = $wallet->transactions()->where('type', Transaction::WALLET_CHARGEBACK)->get();
$this->assertCount(1, $transactions);
$this->assertSame(-15, $transactions[0]->amount);
$this->assertSame(Transaction::WALLET_CHARGEBACK, $transactions[0]->type);
$this->assertSame('', $transactions[0]->description);
$payments = $wallet->payments()->where('id', 'chb_123456')->get();
$this->assertCount(1, $payments);
$this->assertSame(-15, $payments[0]->amount);
- $this->assertSame(PaymentProvider::STATUS_PAID, $payments[0]->status);
- $this->assertSame(PaymentProvider::TYPE_CHARGEBACK, $payments[0]->type);
+ $this->assertSame(Payment::STATUS_PAID, $payments[0]->status);
+ $this->assertSame(Payment::TYPE_CHARGEBACK, $payments[0]->type);
$this->assertSame("mollie", $payments[0]->provider);
$this->assertSame('', $payments[0]->description);
Bus::assertNotDispatched(\App\Jobs\PaymentEmail::class);
$this->unmockMollie();
}
/**
* Create Mollie's auto-payment mandate using our API and Chrome browser
*/
protected function createMandate(Wallet $wallet, array $params)
{
// Use the API to create a first payment with a mandate
$response = $this->actingAs($wallet->owner)->post("api/v4/payments/mandate", $params);
$response->assertStatus(200);
$json = $response->json();
// There's no easy way to confirm a created mandate.
// The only way seems to be to fire up Chrome on checkout page
// and do actions with use of Dusk browser.
$this->startBrowser()->visit($json['redirectUrl']);
$molliePage = new \Tests\Browser\Pages\PaymentMollie();
$molliePage->assert($this->browser);
$molliePage->submitPayment($this->browser, 'paid');
$this->stopBrowser();
}
/**
* Test listing a pending payment
*
* @group mollie
*/
public function testListingPayments(): void
{
Bus::fake();
$user = $this->getTestUser('euro@' . \config('app.domain'));
$wallet = $user->wallets()->first();
$wallet->currency = 'EUR';
$wallet->save();
//Empty response
$response = $this->actingAs($user)->get("api/v4/payments/pending");
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame(0, $json['count']);
$this->assertSame(1, $json['page']);
$this->assertSame(false, $json['hasMore']);
$this->assertCount(0, $json['list']);
$response = $this->actingAs($user)->get("api/v4/payments/has-pending");
$json = $response->json();
$this->assertSame(false, $json['hasPending']);
// Successful payment
$post = ['amount' => '12.34', 'currency' => 'EUR', 'methodId' => 'creditcard'];
$response = $this->actingAs($user)->post("api/v4/payments", $post);
$response->assertStatus(200);
//A response
$response = $this->actingAs($user)->get("api/v4/payments/pending");
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame(1, $json['count']);
$this->assertSame(1, $json['page']);
$this->assertSame(false, $json['hasMore']);
$this->assertCount(1, $json['list']);
- $this->assertSame(PaymentProvider::STATUS_OPEN, $json['list'][0]['status']);
+ $this->assertSame(Payment::STATUS_OPEN, $json['list'][0]['status']);
$this->assertSame('EUR', $json['list'][0]['currency']);
- $this->assertSame(PaymentProvider::TYPE_ONEOFF, $json['list'][0]['type']);
+ $this->assertSame(Payment::TYPE_ONEOFF, $json['list'][0]['type']);
$this->assertSame(1234, $json['list'][0]['amount']);
$response = $this->actingAs($user)->get("api/v4/payments/has-pending");
$json = $response->json();
$this->assertSame(true, $json['hasPending']);
// Set the payment to paid
$payments = Payment::where('wallet_id', $wallet->id)->get();
$this->assertCount(1, $payments);
$payment = $payments[0];
- $payment->status = PaymentProvider::STATUS_PAID;
+ $payment->status = Payment::STATUS_PAID;
$payment->save();
// They payment should be gone from the pending list now
$response = $this->actingAs($user)->get("api/v4/payments/pending");
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame(0, $json['count']);
$this->assertCount(0, $json['list']);
$response = $this->actingAs($user)->get("api/v4/payments/has-pending");
$json = $response->json();
$this->assertSame(false, $json['hasPending']);
}
/**
* Test listing payment methods
*
* @group mollie
*/
public function testListingPaymentMethods(): void
{
Bus::fake();
$user = $this->getTestUser('euro@' . \config('app.domain'));
$wallet = $user->wallets()->first();
$wallet->currency = 'EUR';
$wallet->save();
- $response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . PaymentProvider::TYPE_ONEOFF);
+ $response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . Payment::TYPE_ONEOFF);
$response->assertStatus(200);
$json = $response->json();
$hasCoinbase = !empty(\config('services.coinbase.key'));
$this->assertCount(3 + intval($hasCoinbase), $json);
$this->assertSame('creditcard', $json[0]['id']);
$this->assertSame('paypal', $json[1]['id']);
$this->assertSame('banktransfer', $json[2]['id']);
$this->assertSame('EUR', $json[0]['currency']);
$this->assertSame('EUR', $json[1]['currency']);
$this->assertSame('EUR', $json[2]['currency']);
$this->assertSame(1, $json[0]['exchangeRate']);
$this->assertSame(1, $json[1]['exchangeRate']);
$this->assertSame(1, $json[2]['exchangeRate']);
if ($hasCoinbase) {
$this->assertSame('bitcoin', $json[3]['id']);
$this->assertSame('BTC', $json[3]['currency']);
}
- $response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . PaymentProvider::TYPE_RECURRING);
+ $response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . Payment::TYPE_RECURRING);
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(1, $json);
$this->assertSame('creditcard', $json[0]['id']);
$this->assertSame('EUR', $json[0]['currency']);
}
}
diff --git a/src/tests/Feature/Controller/PaymentsMollieTest.php b/src/tests/Feature/Controller/PaymentsMollieTest.php
index cf3d06ef..36d1030e 100644
--- a/src/tests/Feature/Controller/PaymentsMollieTest.php
+++ b/src/tests/Feature/Controller/PaymentsMollieTest.php
@@ -1,1152 +1,1152 @@
<?php
namespace Tests\Feature\Controller;
use App\Http\Controllers\API\V4\PaymentsController;
use App\Payment;
use App\Providers\PaymentProvider;
use App\Transaction;
use App\Wallet;
use App\WalletSetting;
use App\VatRate;
use App\Utils;
use GuzzleHttp\Psr7\Response;
use Illuminate\Support\Facades\Bus;
use Tests\TestCase;
use Tests\BrowserAddonTrait;
use Tests\MollieMocksTrait;
class PaymentsMollieTest extends TestCase
{
use MollieMocksTrait;
use BrowserAddonTrait;
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
// All tests in this file use Mollie
\config(['services.payment_provider' => 'mollie']);
\config(['app.vat.mode' => 0]);
Utils::setTestExchangeRates(['EUR' => '0.90503424978382']);
$this->deleteTestUser('payment-test@' . \config('app.domain'));
$john = $this->getTestUser('john@kolab.org');
$wallet = $john->wallets()->first();
Payment::query()->delete();
VatRate::query()->delete();
Wallet::where('id', $wallet->id)->update(['balance' => 0]);
WalletSetting::where('wallet_id', $wallet->id)->delete();
$types = [
Transaction::WALLET_CREDIT,
Transaction::WALLET_REFUND,
Transaction::WALLET_CHARGEBACK,
];
Transaction::where('object_id', $wallet->id)->whereIn('type', $types)->delete();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('payment-test@' . \config('app.domain'));
$john = $this->getTestUser('john@kolab.org');
$wallet = $john->wallets()->first();
Payment::query()->delete();
VatRate::query()->delete();
Wallet::where('id', $wallet->id)->update(['balance' => 0]);
WalletSetting::where('wallet_id', $wallet->id)->delete();
$types = [
Transaction::WALLET_CREDIT,
Transaction::WALLET_REFUND,
Transaction::WALLET_CHARGEBACK,
];
Transaction::where('object_id', $wallet->id)->whereIn('type', $types)->delete();
Utils::setTestExchangeRates([]);
parent::tearDown();
}
/**
* Test creating/updating/deleting an outo-payment mandate
*
* @group mollie
*/
public function testMandates(): void
{
// Unauth access not allowed
$response = $this->get("api/v4/payments/mandate");
$response->assertStatus(401);
$response = $this->post("api/v4/payments/mandate", []);
$response->assertStatus(401);
$response = $this->put("api/v4/payments/mandate", []);
$response->assertStatus(401);
$response = $this->delete("api/v4/payments/mandate");
$response->assertStatus(401);
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
// Test creating a mandate (invalid input)
$post = [];
$response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json['errors']);
$this->assertSame('The amount field is required.', $json['errors']['amount'][0]);
$this->assertSame('The balance field is required.', $json['errors']['balance'][0]);
// Test creating a mandate (invalid input)
$post = ['amount' => 100, 'balance' => 'a'];
$response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertSame('The balance must be a number.', $json['errors']['balance'][0]);
// Test creating a mandate (amount smaller than the minimum value)
$post = ['amount' => -100, 'balance' => 0];
$response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
- $min = $wallet->money(PaymentProvider::MIN_AMOUNT);
+ $min = $wallet->money(Payment::MIN_AMOUNT);
$this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']);
// Test creating a mandate (negative balance, amount too small)
Wallet::where('id', $wallet->id)->update(['balance' => -2000]);
- $post = ['amount' => PaymentProvider::MIN_AMOUNT / 100, 'balance' => 0];
+ $post = ['amount' => Payment::MIN_AMOUNT / 100, 'balance' => 0];
$response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertSame("The specified amount does not cover the balance on the account.", $json['errors']['amount']);
// Test creating a mandate (valid input)
$post = ['amount' => 20.10, 'balance' => 0, 'methodId' => PaymentProvider::METHOD_CREDITCARD];
$response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertMatchesRegularExpression('|^https://www.mollie.com|', $json['redirectUrl']);
// Assert the proper payment amount has been used
$payment = Payment::where('id', $json['id'])->first();
$this->assertSame(2010, $payment->amount);
$this->assertSame($wallet->id, $payment->wallet_id);
$this->assertSame($user->tenant->title . " Auto-Payment Setup", $payment->description);
- $this->assertSame(PaymentProvider::TYPE_MANDATE, $payment->type);
+ $this->assertSame(Payment::TYPE_MANDATE, $payment->type);
// Test fetching the mandate information
$response = $this->actingAs($user)->get("api/v4/payments/mandate");
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals(20.10, $json['amount']);
$this->assertEquals(0, $json['balance']);
$this->assertEquals('Credit Card', $json['method']);
$this->assertSame(true, $json['isPending']);
$this->assertSame(false, $json['isValid']);
$this->assertSame(false, $json['isDisabled']);
$mandate_id = $json['id'];
// We would have to invoke a browser to accept the "first payment" to make
// the mandate validated/completed. Instead, we'll mock the mandate object.
$mollie_response = [
'resource' => 'mandate',
'id' => $mandate_id,
'status' => 'valid',
'method' => 'creditcard',
'details' => [
'cardNumber' => '4242',
'cardLabel' => 'Visa',
],
'customerId' => 'cst_GMfxGPt7Gj',
'createdAt' => '2020-04-28T11:09:47+00:00',
];
$responseStack = $this->mockMollie();
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$wallet = $user->wallets()->first();
$wallet->setSetting('mandate_disabled', 1);
$response = $this->actingAs($user)->get("api/v4/payments/mandate");
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals(20.10, $json['amount']);
$this->assertEquals(0, $json['balance']);
$this->assertEquals('Visa (**** **** **** 4242)', $json['method']);
$this->assertSame(false, $json['isPending']);
$this->assertSame(true, $json['isValid']);
$this->assertSame(true, $json['isDisabled']);
Bus::fake();
$wallet->setSetting('mandate_disabled', null);
$wallet->balance = 1000;
$wallet->save();
// Test updating mandate details (invalid input)
$post = [];
$response = $this->actingAs($user)->put("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json['errors']);
$this->assertSame('The amount field is required.', $json['errors']['amount'][0]);
$this->assertSame('The balance field is required.', $json['errors']['balance'][0]);
$post = ['amount' => -100, 'balance' => 0];
$response = $this->actingAs($user)->put("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']);
// Test updating a mandate (valid input)
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$post = ['amount' => 30.10, 'balance' => 10];
$response = $this->actingAs($user)->put("api/v4/payments/mandate", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame('The auto-payment has been updated.', $json['message']);
$this->assertSame($mandate_id, $json['id']);
$this->assertFalse($json['isDisabled']);
$wallet->refresh();
$this->assertEquals(30.10, $wallet->getSetting('mandate_amount'));
$this->assertEquals(10, $wallet->getSetting('mandate_balance'));
Bus::assertDispatchedTimes(\App\Jobs\WalletCharge::class, 0);
// Test updating a disabled mandate (invalid input)
$wallet->setSetting('mandate_disabled', 1);
$wallet->balance = -2000;
$wallet->save();
$user->refresh(); // required so the controller sees the wallet update from above
$post = ['amount' => 15.10, 'balance' => 1];
$response = $this->actingAs($user)->put("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertSame('The specified amount does not cover the balance on the account.', $json['errors']['amount']);
// Test updating a disabled mandate (valid input)
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$post = ['amount' => 30, 'balance' => 1];
$response = $this->actingAs($user)->put("api/v4/payments/mandate", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame('The auto-payment has been updated.', $json['message']);
$this->assertSame($mandate_id, $json['id']);
$this->assertFalse($json['isDisabled']);
Bus::assertDispatchedTimes(\App\Jobs\WalletCharge::class, 1);
Bus::assertDispatched(\App\Jobs\WalletCharge::class, function ($job) use ($wallet) {
$job_wallet = $this->getObjectProperty($job, 'wallet');
return $job_wallet->id === $wallet->id;
});
$this->unmockMollie();
// Delete mandate
$response = $this->actingAs($user)->delete("api/v4/payments/mandate");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame('The auto-payment has been removed.', $json['message']);
// Confirm with Mollie the mandate does not exist
$customer_id = $wallet->getSetting('mollie_id');
$this->expectException(\Mollie\Api\Exceptions\ApiException::class);
$this->expectExceptionMessageMatches('/410: Gone/');
$mandate = mollie()->mandates()->getForId($customer_id, $mandate_id);
$this->assertNull($wallet->fresh()->getSetting('mollie_mandate_id'));
// Test Mollie's "410 Gone" response handling when fetching the mandate info
// It is expected to remove the mandate reference
$mollie_response = [
'status' => 410,
'title' => "Gone",
'detail' => "You are trying to access an object, which has previously been deleted",
'_links' => [
'documentation' => [
'href' => "https://docs.mollie.com/errors",
'type' => "text/html"
]
]
];
$responseStack = $this->mockMollie();
$responseStack->append(new Response(410, [], json_encode($mollie_response)));
$wallet->fresh()->setSetting('mollie_mandate_id', '123');
$response = $this->actingAs($user)->get("api/v4/payments/mandate");
$response->assertStatus(200);
$json = $response->json();
$this->assertFalse(array_key_exists('id', $json));
$this->assertFalse(array_key_exists('method', $json));
$this->assertNull($wallet->fresh()->getSetting('mollie_mandate_id'));
}
/**
* Test creating a payment and receiving a status via webhook
*
* @group mollie
*/
public function testStoreAndWebhook(): void
{
Bus::fake();
// Unauth access not allowed
$response = $this->post("api/v4/payments", []);
$response->assertStatus(401);
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
// Invalid amount
$post = ['amount' => -1];
$response = $this->actingAs($user)->post("api/v4/payments", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
- $min = $wallet->money(PaymentProvider::MIN_AMOUNT);
+ $min = $wallet->money(Payment::MIN_AMOUNT);
$this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']);
// Invalid currency
$post = ['amount' => '12.34', 'currency' => 'FOO', 'methodId' => 'creditcard'];
$response = $this->actingAs($user)->post("api/v4/payments", $post);
$response->assertStatus(500);
// Successful payment
$post = ['amount' => '12.34', 'currency' => 'CHF', 'methodId' => 'creditcard'];
$response = $this->actingAs($user)->post("api/v4/payments", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertMatchesRegularExpression('|^https://www.mollie.com|', $json['redirectUrl']);
$payments = Payment::where('wallet_id', $wallet->id)->get();
$this->assertCount(1, $payments);
$payment = $payments[0];
$this->assertSame(1234, $payment->amount);
$this->assertSame(1234, $payment->currency_amount);
$this->assertSame('CHF', $payment->currency);
$this->assertSame($user->tenant->title . ' Payment', $payment->description);
$this->assertSame('open', $payment->status);
$this->assertEquals(0, $wallet->balance);
// Test the webhook
// Note: Webhook end-point does not require authentication
$mollie_response = [
"resource" => "payment",
"id" => $payment->id,
"status" => "paid",
// Status is not enough, paidAt is used to distinguish the state
"paidAt" => date('c'),
"mode" => "test",
];
// We'll trigger the webhook with payment id and use mocking for
// a request to the Mollie payments API. We cannot force Mollie
// to make the payment status change.
$responseStack = $this->mockMollie();
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$post = ['id' => $payment->id];
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
- $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status);
+ $this->assertSame(Payment::STATUS_PAID, $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
$transaction = $wallet->transactions()
->where('type', Transaction::WALLET_CREDIT)->get()->last();
$this->assertSame(1234, $transaction->amount);
$this->assertSame(
"Payment transaction {$payment->id} using Mollie",
$transaction->description
);
// Assert that email notification job wasn't dispatched,
// it is expected only for recurring payments
Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0);
// Verify "paid -> open -> paid" scenario, assert that balance didn't change
$mollie_response['status'] = 'open';
unset($mollie_response['paidAt']);
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
- $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status);
+ $this->assertSame(Payment::STATUS_PAID, $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
$mollie_response['status'] = 'paid';
$mollie_response['paidAt'] = date('c');
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
- $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status);
+ $this->assertSame(Payment::STATUS_PAID, $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
// Test for payment failure
Bus::fake();
$payment->refresh();
- $payment->status = PaymentProvider::STATUS_OPEN;
+ $payment->status = Payment::STATUS_OPEN;
$payment->save();
$mollie_response = [
"resource" => "payment",
"id" => $payment->id,
"status" => "failed",
"mode" => "test",
];
// We'll trigger the webhook with payment id and use mocking for
// a request to the Mollie payments API. We cannot force Mollie
// to make the payment status change.
$responseStack = $this->mockMollie();
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$this->assertSame('failed', $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
// Assert that email notification job wasn't dispatched,
// it is expected only for recurring payments
Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0);
}
/**
* Test creating a payment and receiving a status via webhook using a foreign currency
*
* @group mollie
*/
public function testStoreAndWebhookForeignCurrency(): void
{
Bus::fake();
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
// Successful payment in EUR
$post = ['amount' => '12.34', 'currency' => 'EUR', 'methodId' => 'banktransfer'];
$response = $this->actingAs($user)->post("api/v4/payments", $post);
$response->assertStatus(200);
$payment = $wallet->payments()
->where('currency', 'EUR')->get()->last();
$this->assertSame(1234, $payment->amount);
$this->assertSame(1117, $payment->currency_amount);
$this->assertSame('EUR', $payment->currency);
$this->assertEquals(0, $wallet->balance);
$mollie_response = [
"resource" => "payment",
"id" => $payment->id,
"status" => "paid",
// Status is not enough, paidAt is used to distinguish the state
"paidAt" => date('c'),
"mode" => "test",
];
$responseStack = $this->mockMollie();
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$post = ['id' => $payment->id];
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
- $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status);
+ $this->assertSame(Payment::STATUS_PAID, $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
}
/**
* Test automatic payment charges
*
* @group mollie
*/
public function testTopUp(): void
{
Bus::fake();
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
// Create a valid mandate first (balance=0, so there's no extra payment yet)
$this->createMandate($wallet, ['amount' => 20.10, 'balance' => 0]);
$wallet->setSetting('mandate_balance', 10);
// Expect a recurring payment as we have a valid mandate at this point
// and the balance is below the threshold
$this->assertTrue(PaymentsController::topUpWallet($wallet));
// Check that the payments table contains a new record with proper amount.
// There should be two records, one for the mandate payment and another for
// the top-up payment
$payments = $wallet->payments()->orderBy('amount')->get();
$this->assertCount(2, $payments);
$this->assertSame(0, $payments[0]->amount);
$this->assertSame(0, $payments[0]->currency_amount);
$this->assertSame(2010, $payments[1]->amount);
$this->assertSame(2010, $payments[1]->currency_amount);
$payment = $payments[1];
// In mollie we don't have to wait for a webhook, the response to
// PaymentIntent already sets the status to 'paid', so we can test
// immediately the balance update
// Assert that email notification job has been dispatched
- $this->assertSame(PaymentProvider::STATUS_PAID, $payment->status);
+ $this->assertSame(Payment::STATUS_PAID, $payment->status);
$this->assertEquals(2010, $wallet->fresh()->balance);
$transaction = $wallet->transactions()
->where('type', Transaction::WALLET_CREDIT)->get()->last();
$this->assertSame(2010, $transaction->amount);
$this->assertSame(
"Auto-payment transaction {$payment->id} using Mastercard (**** **** **** 9399)",
$transaction->description
);
Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1);
Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) {
$job_payment = $this->getObjectProperty($job, 'payment');
return $job_payment->id === $payment->id;
});
// Expect no payment if the mandate is disabled
$wallet->setSetting('mandate_disabled', 1);
$result = PaymentsController::topUpWallet($wallet);
$this->assertFalse($result);
$this->assertCount(2, $wallet->payments()->get());
// Expect no payment if balance is ok
$wallet->setSetting('mandate_disabled', null);
$wallet->balance = 1000;
$wallet->save();
$result = PaymentsController::topUpWallet($wallet);
$this->assertFalse($result);
$this->assertCount(2, $wallet->payments()->get());
// Expect no payment if the top-up amount is not enough
$wallet->setSetting('mandate_disabled', null);
$wallet->balance = -2050;
$wallet->save();
$result = PaymentsController::topUpWallet($wallet);
$this->assertFalse($result);
$this->assertCount(2, $wallet->payments()->get());
Bus::assertDispatchedTimes(\App\Jobs\PaymentMandateDisabledEmail::class, 1);
Bus::assertDispatched(\App\Jobs\PaymentMandateDisabledEmail::class, function ($job) use ($wallet) {
$job_wallet = $this->getObjectProperty($job, 'wallet');
return $job_wallet->id === $wallet->id;
});
// Expect no payment if there's no mandate
$wallet->setSetting('mollie_mandate_id', null);
$wallet->balance = 0;
$wallet->save();
$result = PaymentsController::topUpWallet($wallet);
$this->assertFalse($result);
$this->assertCount(2, $wallet->payments()->get());
Bus::assertDispatchedTimes(\App\Jobs\PaymentMandateDisabledEmail::class, 1);
// Test webhook for recurring payments
$wallet->transactions()->delete();
$responseStack = $this->mockMollie();
Bus::fake();
$payment->refresh();
- $payment->status = PaymentProvider::STATUS_OPEN;
+ $payment->status = Payment::STATUS_OPEN;
$payment->save();
$mollie_response = [
"resource" => "payment",
"id" => $payment->id,
"status" => "paid",
// Status is not enough, paidAt is used to distinguish the state
"paidAt" => date('c'),
"mode" => "test",
];
// We'll trigger the webhook with payment id and use mocking for
// a request to the Mollie payments API. We cannot force Mollie
// to make the payment status change.
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$post = ['id' => $payment->id];
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
- $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status);
+ $this->assertSame(Payment::STATUS_PAID, $payment->fresh()->status);
$this->assertEquals(2010, $wallet->fresh()->balance);
$transaction = $wallet->transactions()
->where('type', Transaction::WALLET_CREDIT)->get()->last();
$this->assertSame(2010, $transaction->amount);
$this->assertSame(
"Auto-payment transaction {$payment->id} using Mollie",
$transaction->description
);
// Assert that email notification job has been dispatched
Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1);
Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) {
$job_payment = $this->getObjectProperty($job, 'payment');
return $job_payment->id === $payment->id;
});
Bus::fake();
// Test for payment failure
$payment->refresh();
- $payment->status = PaymentProvider::STATUS_OPEN;
+ $payment->status = Payment::STATUS_OPEN;
$payment->save();
$wallet->setSetting('mollie_mandate_id', 'xxx');
$wallet->setSetting('mandate_disabled', null);
$mollie_response = [
"resource" => "payment",
"id" => $payment->id,
"status" => "failed",
"mode" => "test",
];
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$wallet->refresh();
- $this->assertSame(PaymentProvider::STATUS_FAILED, $payment->fresh()->status);
+ $this->assertSame(Payment::STATUS_FAILED, $payment->fresh()->status);
$this->assertEquals(2010, $wallet->balance);
$this->assertTrue(!empty($wallet->getSetting('mandate_disabled')));
// Assert that email notification job has been dispatched
Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1);
Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) {
$job_payment = $this->getObjectProperty($job, 'payment');
return $job_payment->id === $payment->id;
});
$this->unmockMollie();
}
/**
* Test payment/top-up with VAT_MODE=1
*
* @group mollie
*/
public function testPaymentsWithVatModeOne(): void
{
\config(['app.vat.mode' => 1]);
$user = $this->getTestUser('payment-test@' . \config('app.domain'));
$user->setSetting('country', 'US');
$wallet = $user->wallets()->first();
$vatRate = VatRate::create([
'country' => 'US',
'rate' => 5.0,
'start' => now()->subDay(),
]);
// Payment
$post = ['amount' => '10', 'currency' => 'CHF', 'methodId' => 'creditcard'];
$response = $this->actingAs($user)->post("api/v4/payments", $post);
$response->assertStatus(200);
// Check that the payments table contains a new record with proper amount(s)
$payment = $wallet->payments()->first();
$this->assertSame(1000 + intval(round(1000 * $vatRate->rate / 100)), $payment->amount);
$this->assertSame(1000, $payment->credit_amount);
$this->assertSame($payment->amount, $payment->currency_amount);
$this->assertSame('CHF', $payment->currency);
$this->assertSame($vatRate->id, $payment->vat_rate_id);
$this->assertSame('open', $payment->status);
$wallet->payments()->delete();
$wallet->balance = -1000;
$wallet->save();
// Top-up (mandate creation)
// Create a valid mandate first (expect an extra payment)
$this->createMandate($wallet, ['amount' => 20.10, 'balance' => 0]);
// Check that the payments table contains a new record with proper amount(s)
$payment = $wallet->payments()->first();
$this->assertSame(2010 + intval(round(2010 * $vatRate->rate / 100)), $payment->amount);
$this->assertSame(2010, $payment->credit_amount);
$this->assertSame($payment->amount, $payment->currency_amount);
$this->assertSame($vatRate->id, $payment->vat_rate_id);
$wallet->payments()->delete();
$wallet->balance = -1000;
$wallet->save();
// Top-up (recurring payment)
// Expect a recurring payment as we have a valid mandate at this point
// and the balance is below the threshold
$this->assertTrue(PaymentsController::topUpWallet($wallet));
// Check that the payments table contains a new record with proper amount(s)
$payment = $wallet->payments()->first();
$this->assertSame(2010 + intval(round(2010 * $vatRate->rate / 100)), $payment->amount);
$this->assertSame(2010, $payment->credit_amount);
$this->assertSame($payment->amount, $payment->currency_amount);
$this->assertSame($vatRate->id, $payment->vat_rate_id);
}
/**
* Test refund/chargeback handling by the webhook
*
* @group mollie
*/
public function testRefundAndChargeback(): void
{
Bus::fake();
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
$wallet->transactions()->delete();
$mollie = PaymentProvider::factory('mollie');
// Create a paid payment
$payment = Payment::create([
'id' => 'tr_123456',
- 'status' => PaymentProvider::STATUS_PAID,
+ 'status' => Payment::STATUS_PAID,
'amount' => 123,
'credit_amount' => 123,
'currency_amount' => 123,
'currency' => 'CHF',
- 'type' => PaymentProvider::TYPE_ONEOFF,
+ 'type' => Payment::TYPE_ONEOFF,
'wallet_id' => $wallet->id,
'provider' => 'mollie',
'description' => 'test',
]);
// Test handling a refund by the webhook
$mollie_response1 = [
"resource" => "payment",
"id" => $payment->id,
"status" => "paid",
// Status is not enough, paidAt is used to distinguish the state
"paidAt" => date('c'),
"mode" => "test",
"_links" => [
"refunds" => [
"href" => "https://api.mollie.com/v2/payments/{$payment->id}/refunds",
"type" => "application/hal+json"
]
]
];
$mollie_response2 = [
"count" => 1,
"_links" => [],
"_embedded" => [
"refunds" => [
[
"resource" => "refund",
"id" => "re_123456",
"status" => \Mollie\Api\Types\RefundStatus::STATUS_REFUNDED,
"paymentId" => $payment->id,
"description" => "refund desc",
"amount" => [
"currency" => "CHF",
"value" => "1.01",
],
]
]
]
];
// We'll trigger the webhook with payment id and use mocking for
// requests to the Mollie payments API.
$responseStack = $this->mockMollie();
$responseStack->append(new Response(200, [], json_encode($mollie_response1)));
$responseStack->append(new Response(200, [], json_encode($mollie_response2)));
$post = ['id' => $payment->id];
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$wallet->refresh();
$this->assertEquals(-101, $wallet->balance);
$transactions = $wallet->transactions()->where('type', Transaction::WALLET_REFUND)->get();
$this->assertCount(1, $transactions);
$this->assertSame(-101, $transactions[0]->amount);
$this->assertSame(Transaction::WALLET_REFUND, $transactions[0]->type);
$this->assertSame("refund desc", $transactions[0]->description);
$payments = $wallet->payments()->where('id', 're_123456')->get();
$this->assertCount(1, $payments);
$this->assertSame(-101, $payments[0]->amount);
$this->assertSame(-101, $payments[0]->currency_amount);
- $this->assertSame(PaymentProvider::STATUS_PAID, $payments[0]->status);
- $this->assertSame(PaymentProvider::TYPE_REFUND, $payments[0]->type);
+ $this->assertSame(Payment::STATUS_PAID, $payments[0]->status);
+ $this->assertSame(Payment::TYPE_REFUND, $payments[0]->type);
$this->assertSame("mollie", $payments[0]->provider);
$this->assertSame("refund desc", $payments[0]->description);
// Test handling a chargeback by the webhook
$mollie_response1["_links"] = [
"chargebacks" => [
"href" => "https://api.mollie.com/v2/payments/{$payment->id}/chargebacks",
"type" => "application/hal+json"
]
];
$mollie_response2 = [
"count" => 1,
"_links" => [],
"_embedded" => [
"chargebacks" => [
[
"resource" => "chargeback",
"id" => "chb_123456",
"paymentId" => $payment->id,
"amount" => [
"currency" => "CHF",
"value" => "0.15",
],
]
]
]
];
// We'll trigger the webhook with payment id and use mocking for
// requests to the Mollie payments API.
$responseStack = $this->mockMollie();
$responseStack->append(new Response(200, [], json_encode($mollie_response1)));
$responseStack->append(new Response(200, [], json_encode($mollie_response2)));
$post = ['id' => $payment->id];
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$wallet->refresh();
$this->assertEquals(-116, $wallet->balance);
$transactions = $wallet->transactions()->where('type', Transaction::WALLET_CHARGEBACK)->get();
$this->assertCount(1, $transactions);
$this->assertSame(-15, $transactions[0]->amount);
$this->assertSame(Transaction::WALLET_CHARGEBACK, $transactions[0]->type);
$this->assertSame('', $transactions[0]->description);
$payments = $wallet->payments()->where('id', 'chb_123456')->get();
$this->assertCount(1, $payments);
$this->assertSame(-15, $payments[0]->amount);
- $this->assertSame(PaymentProvider::STATUS_PAID, $payments[0]->status);
- $this->assertSame(PaymentProvider::TYPE_CHARGEBACK, $payments[0]->type);
+ $this->assertSame(Payment::STATUS_PAID, $payments[0]->status);
+ $this->assertSame(Payment::TYPE_CHARGEBACK, $payments[0]->type);
$this->assertSame("mollie", $payments[0]->provider);
$this->assertSame('', $payments[0]->description);
Bus::assertNotDispatched(\App\Jobs\PaymentEmail::class);
$this->unmockMollie();
}
/**
* Test refund/chargeback handling by the webhook in a foreign currency
*
* @group mollie
*/
public function testRefundAndChargebackForeignCurrency(): void
{
Bus::fake();
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
$wallet->transactions()->delete();
$mollie = PaymentProvider::factory('mollie');
// Create a paid payment
$payment = Payment::create([
'id' => 'tr_123456',
- 'status' => PaymentProvider::STATUS_PAID,
+ 'status' => Payment::STATUS_PAID,
'amount' => 1234,
'credit_amount' => 1234,
'currency_amount' => 1117,
'currency' => 'EUR',
- 'type' => PaymentProvider::TYPE_ONEOFF,
+ 'type' => Payment::TYPE_ONEOFF,
'wallet_id' => $wallet->id,
'provider' => 'mollie',
'description' => 'test',
]);
// Test handling a refund by the webhook
$mollie_response1 = [
"resource" => "payment",
"id" => $payment->id,
"status" => "paid",
// Status is not enough, paidAt is used to distinguish the state
"paidAt" => date('c'),
"mode" => "test",
"_links" => [
"refunds" => [
"href" => "https://api.mollie.com/v2/payments/{$payment->id}/refunds",
"type" => "application/hal+json"
]
]
];
$mollie_response2 = [
"count" => 1,
"_links" => [],
"_embedded" => [
"refunds" => [
[
"resource" => "refund",
"id" => "re_123456",
"status" => \Mollie\Api\Types\RefundStatus::STATUS_REFUNDED,
"paymentId" => $payment->id,
"description" => "refund desc",
"amount" => [
"currency" => "EUR",
"value" => "1.01",
],
]
]
]
];
// We'll trigger the webhook with payment id and use mocking for
// requests to the Mollie payments API.
$responseStack = $this->mockMollie();
$responseStack->append(new Response(200, [], json_encode($mollie_response1)));
$responseStack->append(new Response(200, [], json_encode($mollie_response2)));
$post = ['id' => $payment->id];
$response = $this->post("api/webhooks/payment/mollie", $post);
$response->assertStatus(200);
$wallet->refresh();
$this->assertTrue($wallet->balance <= -100);
$this->assertTrue($wallet->balance >= -114);
$payments = $wallet->payments()->where('id', 're_123456')->get();
$this->assertCount(1, $payments);
$this->assertTrue($payments[0]->amount <= -100);
$this->assertTrue($payments[0]->amount >= -114);
$this->assertSame(-101, $payments[0]->currency_amount);
$this->assertSame('EUR', $payments[0]->currency);
$this->unmockMollie();
}
/**
* Create Mollie's auto-payment mandate using our API and Chrome browser
*/
protected function createMandate(Wallet $wallet, array $params)
{
// Use the API to create a first payment with a mandate
$response = $this->actingAs($wallet->owner)->post("api/v4/payments/mandate", $params);
$response->assertStatus(200);
$json = $response->json();
// There's no easy way to confirm a created mandate.
// The only way seems to be to fire up Chrome on checkout page
// and do actions with use of Dusk browser.
$this->startBrowser()->visit($json['redirectUrl']);
$molliePage = new \Tests\Browser\Pages\PaymentMollie();
$molliePage->assert($this->browser);
$molliePage->submitPayment($this->browser, 'paid');
$this->stopBrowser();
}
/**
* Test listing a pending payment
*
* @group mollie
*/
public function testListingPayments(): void
{
Bus::fake();
$user = $this->getTestUser('john@kolab.org');
//Empty response
$response = $this->actingAs($user)->get("api/v4/payments/pending");
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame(0, $json['count']);
$this->assertSame(1, $json['page']);
$this->assertSame(false, $json['hasMore']);
$this->assertCount(0, $json['list']);
$response = $this->actingAs($user)->get("api/v4/payments/has-pending");
$json = $response->json();
$this->assertSame(false, $json['hasPending']);
$wallet = $user->wallets()->first();
// Successful payment
$post = ['amount' => '12.34', 'currency' => 'CHF', 'methodId' => 'creditcard'];
$response = $this->actingAs($user)->post("api/v4/payments", $post);
$response->assertStatus(200);
//A response
$response = $this->actingAs($user)->get("api/v4/payments/pending");
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame(1, $json['count']);
$this->assertSame(1, $json['page']);
$this->assertSame(false, $json['hasMore']);
$this->assertCount(1, $json['list']);
- $this->assertSame(PaymentProvider::STATUS_OPEN, $json['list'][0]['status']);
+ $this->assertSame(Payment::STATUS_OPEN, $json['list'][0]['status']);
$this->assertSame('CHF', $json['list'][0]['currency']);
- $this->assertSame(PaymentProvider::TYPE_ONEOFF, $json['list'][0]['type']);
+ $this->assertSame(Payment::TYPE_ONEOFF, $json['list'][0]['type']);
$this->assertSame(1234, $json['list'][0]['amount']);
$response = $this->actingAs($user)->get("api/v4/payments/has-pending");
$json = $response->json();
$this->assertSame(true, $json['hasPending']);
// Set the payment to paid
$payments = Payment::where('wallet_id', $wallet->id)->get();
$this->assertCount(1, $payments);
$payment = $payments[0];
- $payment->status = PaymentProvider::STATUS_PAID;
+ $payment->status = Payment::STATUS_PAID;
$payment->save();
// They payment should be gone from the pending list now
$response = $this->actingAs($user)->get("api/v4/payments/pending");
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame(0, $json['count']);
$this->assertCount(0, $json['list']);
$response = $this->actingAs($user)->get("api/v4/payments/has-pending");
$json = $response->json();
$this->assertSame(false, $json['hasPending']);
}
/**
* Test listing payment methods
*
* @group mollie
*/
public function testListingPaymentMethods(): void
{
Bus::fake();
$user = $this->getTestUser('john@kolab.org');
- $response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . PaymentProvider::TYPE_ONEOFF);
+ $response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . Payment::TYPE_ONEOFF);
$response->assertStatus(200);
$json = $response->json();
$hasCoinbase = !empty(\config('services.coinbase.key'));
$this->assertCount(3 + intval($hasCoinbase), $json);
$this->assertSame('creditcard', $json[0]['id']);
$this->assertSame('paypal', $json[1]['id']);
$this->assertSame('banktransfer', $json[2]['id']);
$this->assertSame('CHF', $json[0]['currency']);
$this->assertSame('CHF', $json[1]['currency']);
$this->assertSame('EUR', $json[2]['currency']);
if ($hasCoinbase) {
$this->assertSame('bitcoin', $json[3]['id']);
$this->assertSame('BTC', $json[3]['currency']);
}
- $response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . PaymentProvider::TYPE_RECURRING);
+ $response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . Payment::TYPE_RECURRING);
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(1, $json);
$this->assertSame('creditcard', $json[0]['id']);
$this->assertSame('CHF', $json[0]['currency']);
}
}
diff --git a/src/tests/Feature/Controller/PaymentsStripeTest.php b/src/tests/Feature/Controller/PaymentsStripeTest.php
index 05028341..3d461ae9 100644
--- a/src/tests/Feature/Controller/PaymentsStripeTest.php
+++ b/src/tests/Feature/Controller/PaymentsStripeTest.php
@@ -1,859 +1,859 @@
<?php
namespace Tests\Feature\Controller;
use App\Http\Controllers\API\V4\PaymentsController;
use App\Payment;
use App\Providers\PaymentProvider;
use App\Transaction;
use App\Wallet;
use App\WalletSetting;
use App\VatRate;
use GuzzleHttp\Psr7\Response;
use Illuminate\Support\Facades\Bus;
use Tests\TestCase;
use Tests\StripeMocksTrait;
class PaymentsStripeTest extends TestCase
{
use StripeMocksTrait;
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
// All tests in this file use Stripe
\config(['services.payment_provider' => 'stripe']);
\config(['app.vat.mode' => 0]);
$this->deleteTestUser('payment-test@' . \config('app.domain'));
$john = $this->getTestUser('john@kolab.org');
$wallet = $john->wallets()->first();
Wallet::where('id', $wallet->id)->update(['balance' => 0]);
WalletSetting::where('wallet_id', $wallet->id)->delete();
Transaction::where('object_id', $wallet->id)
->where('type', Transaction::WALLET_CREDIT)->delete();
Payment::query()->delete();
VatRate::query()->delete();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('payment-test@' . \config('app.domain'));
$john = $this->getTestUser('john@kolab.org');
$wallet = $john->wallets()->first();
Wallet::where('id', $wallet->id)->update(['balance' => 0]);
WalletSetting::where('wallet_id', $wallet->id)->delete();
Transaction::where('object_id', $wallet->id)
->where('type', Transaction::WALLET_CREDIT)->delete();
Payment::query()->delete();
VatRate::query()->delete();
parent::tearDown();
}
/**
* Test creating/updating/deleting an outo-payment mandate
*
* @group stripe
*/
public function testMandates(): void
{
Bus::fake();
// Unauth access not allowed
$response = $this->get("api/v4/payments/mandate");
$response->assertStatus(401);
$response = $this->post("api/v4/payments/mandate", []);
$response->assertStatus(401);
$response = $this->put("api/v4/payments/mandate", []);
$response->assertStatus(401);
$response = $this->delete("api/v4/payments/mandate");
$response->assertStatus(401);
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
// Test creating a mandate (invalid input)
$post = [];
$response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json['errors']);
$this->assertSame('The amount field is required.', $json['errors']['amount'][0]);
$this->assertSame('The balance field is required.', $json['errors']['balance'][0]);
// Test creating a mandate (invalid input)
$post = ['amount' => 100, 'balance' => 'a'];
$response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertSame('The balance must be a number.', $json['errors']['balance'][0]);
// Test creating a mandate (invalid input)
$post = ['amount' => -100, 'balance' => 0];
$response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
- $min = $wallet->money(PaymentProvider::MIN_AMOUNT);
+ $min = $wallet->money(Payment::MIN_AMOUNT);
$this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']);
// Test creating a mandate (negative balance, amount too small)
Wallet::where('id', $wallet->id)->update(['balance' => -2000]);
- $post = ['amount' => PaymentProvider::MIN_AMOUNT / 100, 'balance' => 0];
+ $post = ['amount' => Payment::MIN_AMOUNT / 100, 'balance' => 0];
$response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertSame("The specified amount does not cover the balance on the account.", $json['errors']['amount']);
// Test creating a mandate (valid input)
$post = ['amount' => 20.10, 'balance' => 0, 'methodId' => PaymentProvider::METHOD_CREDITCARD];
$response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertMatchesRegularExpression('|^cs_test_|', $json['id']);
// Assert the proper payment amount has been used
// Stripe in 'setup' mode does not allow to set the amount
$payment = Payment::where('wallet_id', $wallet->id)->first();
$this->assertSame(0, $payment->amount);
$this->assertSame($user->tenant->title . " Auto-Payment Setup", $payment->description);
- $this->assertSame(PaymentProvider::TYPE_MANDATE, $payment->type);
+ $this->assertSame(Payment::TYPE_MANDATE, $payment->type);
// Test fetching the mandate information
$response = $this->actingAs($user)->get("api/v4/payments/mandate");
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals(20.10, $json['amount']);
$this->assertEquals(0, $json['balance']);
$this->assertSame(false, $json['isDisabled']);
// We would have to invoke a browser to accept the "first payment" to make
// the mandate validated/completed. Instead, we'll mock the mandate object.
$setupIntent = '{
"id": "AAA",
"object": "setup_intent",
"created": 123456789,
"payment_method": "pm_YYY",
"status": "succeeded",
"usage": "off_session",
"customer": null
}';
$paymentMethod = '{
"id": "pm_YYY",
"object": "payment_method",
"card": {
"brand": "visa",
"country": "US",
"last4": "4242"
},
"created": 123456789,
"type": "card"
}';
$client = $this->mockStripe();
$client->addResponse($setupIntent);
$client->addResponse($paymentMethod);
// As we do not use checkout page, we do not receive a webworker request
// I.e. we have to fake the mandate id
$wallet = $user->wallets()->first();
$wallet->setSetting('stripe_mandate_id', 'AAA');
$wallet->setSetting('mandate_disabled', 1);
$response = $this->actingAs($user)->get("api/v4/payments/mandate");
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals(20.10, $json['amount']);
$this->assertEquals(0, $json['balance']);
$this->assertEquals('Visa (**** **** **** 4242)', $json['method']);
$this->assertSame(false, $json['isPending']);
$this->assertSame(true, $json['isValid']);
$this->assertSame(true, $json['isDisabled']);
// Test updating mandate details (invalid input)
$wallet->setSetting('mandate_disabled', null);
$wallet->balance = 1000;
$wallet->save();
$user->refresh();
$post = [];
$response = $this->actingAs($user)->put("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(2, $json['errors']);
$this->assertSame('The amount field is required.', $json['errors']['amount'][0]);
$this->assertSame('The balance field is required.', $json['errors']['balance'][0]);
$post = ['amount' => -100, 'balance' => 0];
$response = $this->actingAs($user)->put("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']);
// Test updating a mandate (valid input)
$client->addResponse($setupIntent);
$client->addResponse($paymentMethod);
$post = ['amount' => 30.10, 'balance' => 10];
$response = $this->actingAs($user)->put("api/v4/payments/mandate", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame('The auto-payment has been updated.', $json['message']);
$this->assertEquals(30.10, $wallet->getSetting('mandate_amount'));
$this->assertEquals(10, $wallet->getSetting('mandate_balance'));
$this->assertSame('AAA', $json['id']);
$this->assertFalse($json['isDisabled']);
// Test updating a disabled mandate (invalid input)
$wallet->setSetting('mandate_disabled', 1);
$wallet->balance = -2000;
$wallet->save();
$user->refresh(); // required so the controller sees the wallet update from above
$post = ['amount' => 15.10, 'balance' => 1];
$response = $this->actingAs($user)->put("api/v4/payments/mandate", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
$this->assertSame('The specified amount does not cover the balance on the account.', $json['errors']['amount']);
// Test updating a disabled mandate (valid input)
$client->addResponse($setupIntent);
$client->addResponse($paymentMethod);
$post = ['amount' => 30, 'balance' => 1];
$response = $this->actingAs($user)->put("api/v4/payments/mandate", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame('The auto-payment has been updated.', $json['message']);
$this->assertSame('AAA', $json['id']);
$this->assertFalse($json['isDisabled']);
Bus::assertDispatchedTimes(\App\Jobs\WalletCharge::class, 1);
Bus::assertDispatched(\App\Jobs\WalletCharge::class, function ($job) use ($wallet) {
$job_wallet = $this->getObjectProperty($job, 'wallet');
return $job_wallet->id === $wallet->id;
});
$this->unmockStripe();
// TODO: Delete mandate
}
/**
* Test creating a payment and receiving a status via webhook
*
* @group stripe
*/
public function testStoreAndWebhook(): void
{
Bus::fake();
// Unauth access not allowed
$response = $this->post("api/v4/payments", []);
$response->assertStatus(401);
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
$post = ['amount' => -1];
$response = $this->actingAs($user)->post("api/v4/payments", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertCount(1, $json['errors']);
- $min = $wallet->money(PaymentProvider::MIN_AMOUNT);
+ $min = $wallet->money(Payment::MIN_AMOUNT);
$this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']);
// Invalid currency
$post = ['amount' => '12.34', 'currency' => 'FOO', 'methodId' => 'creditcard'];
$response = $this->actingAs($user)->post("api/v4/payments", $post);
$response->assertStatus(500);
// Successful payment
$post = ['amount' => '12.34', 'currency' => 'CHF', 'methodId' => 'creditcard'];
$response = $this->actingAs($user)->post("api/v4/payments", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertMatchesRegularExpression('|^cs_test_|', $json['id']);
$payments = Payment::where('wallet_id', $wallet->id)->get();
$this->assertCount(1, $payments);
$payment = $payments[0];
$this->assertSame(1234, $payment->amount);
$this->assertSame($user->tenant->title . ' Payment', $payment->description);
$this->assertSame('open', $payment->status);
$this->assertEquals(0, $wallet->balance);
// Test the webhook
$post = [
'id' => "evt_1GlZ814fj3SIEU8wtxMZ4Nsa",
'object' => "event",
'api_version' => "2020-03-02",
'created' => 1590147209,
'data' => [
'object' => [
'id' => $payment->id,
'object' => "payment_intent",
'amount' => 1234,
'amount_capturable' => 0,
'amount_received' => 1234,
'capture_method' => "automatic",
'client_secret' => "pi_1GlZ7w4fj3SIEU8w1RlBpN4l_secret_UYRNDTUUU7nkYHpOLZMb3uf48",
'confirmation_method' => "automatic",
'created' => 1590147204,
'currency' => "chf",
'customer' => "cus_HKDZ53OsKdlM83",
'last_payment_error' => null,
'livemode' => false,
'metadata' => [],
'receipt_email' => "payment-test@kolabnow.com",
'status' => "succeeded"
]
],
'type' => "payment_intent.succeeded"
];
// Test payment succeeded event
$response = $this->webhookRequest($post);
$response->assertStatus(200);
- $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status);
+ $this->assertSame(Payment::STATUS_PAID, $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
$transaction = $wallet->transactions()
->where('type', Transaction::WALLET_CREDIT)->get()->last();
$this->assertSame(1234, $transaction->amount);
$this->assertSame(
"Payment transaction {$payment->id} using Stripe",
$transaction->description
);
// Assert that email notification job wasn't dispatched,
// it is expected only for recurring payments
Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0);
// Test that balance didn't change if the same event is posted
$response = $this->webhookRequest($post);
$response->assertStatus(200);
- $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status);
+ $this->assertSame(Payment::STATUS_PAID, $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
// Test for payment failure ('failed' status)
$payment->refresh();
- $payment->status = PaymentProvider::STATUS_OPEN;
+ $payment->status = Payment::STATUS_OPEN;
$payment->save();
$post['type'] = "payment_intent.payment_failed";
$post['data']['object']['status'] = 'failed';
$response = $this->webhookRequest($post);
$response->assertStatus(200);
- $this->assertSame(PaymentProvider::STATUS_FAILED, $payment->fresh()->status);
+ $this->assertSame(Payment::STATUS_FAILED, $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
// Assert that email notification job wasn't dispatched,
// it is expected only for recurring payments
Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0);
// Test for payment failure ('canceled' status)
$payment->refresh();
- $payment->status = PaymentProvider::STATUS_OPEN;
+ $payment->status = Payment::STATUS_OPEN;
$payment->save();
$post['type'] = "payment_intent.canceled";
$post['data']['object']['status'] = 'canceled';
$response = $this->webhookRequest($post);
$response->assertStatus(200);
- $this->assertSame(PaymentProvider::STATUS_CANCELED, $payment->fresh()->status);
+ $this->assertSame(Payment::STATUS_CANCELED, $payment->fresh()->status);
$this->assertEquals(1234, $wallet->fresh()->balance);
// Assert that email notification job wasn't dispatched,
// it is expected only for recurring payments
Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0);
}
/**
* Test receiving webhook request for setup intent
*
* @group stripe
*/
public function testCreateMandateAndWebhook(): void
{
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
Wallet::where('id', $wallet->id)->update(['balance' => -1000]);
// Test creating a mandate (valid input)
$post = ['amount' => 20.10, 'balance' => 0];
$response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
$response->assertStatus(200);
$payment = $wallet->payments()->first();
- $this->assertSame(PaymentProvider::STATUS_OPEN, $payment->status);
- $this->assertSame(PaymentProvider::TYPE_MANDATE, $payment->type);
+ $this->assertSame(Payment::STATUS_OPEN, $payment->status);
+ $this->assertSame(Payment::TYPE_MANDATE, $payment->type);
$this->assertSame(0, $payment->amount);
$post = [
'id' => "evt_1GlZ814fj3SIEU8wtxMZ4Nsa",
'object' => "event",
'api_version' => "2020-03-02",
'created' => 1590147209,
'data' => [
'object' => [
'id' => $payment->id,
'object' => "setup_intent",
'client_secret' => "pi_1GlZ7w4fj3SIEU8w1RlBpN4l_secret_UYRNDTUUU7nkYHpOLZMb3uf48",
'created' => 1590147204,
'customer' => "cus_HKDZ53OsKdlM83",
'last_setup_error' => null,
'metadata' => [],
'status' => "succeeded"
]
],
'type' => "setup_intent.succeeded"
];
Bus::fake();
// Test payment succeeded event
$response = $this->webhookRequest($post);
$response->assertStatus(200);
$payment->refresh();
- $this->assertSame(PaymentProvider::STATUS_PAID, $payment->status);
+ $this->assertSame(Payment::STATUS_PAID, $payment->status);
$this->assertSame($payment->id, $wallet->fresh()->getSetting('stripe_mandate_id'));
// Expect a WalletCharge job if the balance is negative
Bus::assertDispatchedTimes(\App\Jobs\WalletCharge::class, 1);
Bus::assertDispatched(\App\Jobs\WalletCharge::class, function ($job) use ($wallet) {
$job_wallet = TestCase::getObjectProperty($job, 'wallet');
return $job_wallet->id === $wallet->id;
});
// TODO: test other setup_intent.* events
}
/**
* Test automatic payment charges
*
* @group stripe
*/
public function testTopUpAndWebhook(): void
{
Bus::fake();
$user = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
// Stripe API does not allow us to create a mandate easily
// That's why we we'll mock API responses
// Create a fake mandate
$wallet->setSettings([
'mandate_amount' => 20.10,
'mandate_balance' => 10,
'stripe_mandate_id' => 'AAA',
]);
$setupIntent = json_encode([
"id" => "AAA",
"object" => "setup_intent",
"created" => 123456789,
"payment_method" => "pm_YYY",
"status" => "succeeded",
"usage" => "off_session",
"customer" => null
]);
$paymentMethod = json_encode([
"id" => "pm_YYY",
"object" => "payment_method",
"card" => [
"brand" => "visa",
"country" => "US",
"last4" => "4242"
],
"created" => 123456789,
"type" => "card"
]);
$paymentIntent = json_encode([
"id" => "pi_XX",
"object" => "payment_intent",
"created" => 123456789,
"amount" => 2010,
"currency" => "chf",
"description" => $user->tenant->title . " Recurring Payment"
]);
$client = $this->mockStripe();
$client->addResponse($setupIntent);
$client->addResponse($paymentMethod);
$client->addResponse($setupIntent);
$client->addResponse($paymentIntent);
$client->addResponse($setupIntent);
$client->addResponse($paymentMethod);
// Expect a recurring payment as we have a valid mandate at this point
$result = PaymentsController::topUpWallet($wallet);
$this->assertTrue($result);
// Check that the payments table contains a new record with proper amount
// There should be two records, one for the first payment and another for
// the recurring payment
$this->assertCount(1, $wallet->payments()->get());
$payment = $wallet->payments()->first();
$this->assertSame(2010, $payment->amount);
$this->assertSame($user->tenant->title . " Recurring Payment", $payment->description);
$this->assertSame("pi_XX", $payment->id);
// Expect no payment if the mandate is disabled
$wallet->setSetting('mandate_disabled', 1);
$result = PaymentsController::topUpWallet($wallet);
$this->assertFalse($result);
$this->assertCount(1, $wallet->payments()->get());
// Expect no payment if balance is ok
$wallet->setSetting('mandate_disabled', null);
$wallet->balance = 1000;
$wallet->save();
$result = PaymentsController::topUpWallet($wallet);
$this->assertFalse($result);
$this->assertCount(1, $wallet->payments()->get());
// Expect no payment if the top-up amount is not enough
$wallet->setSetting('mandate_disabled', null);
$wallet->balance = -2050;
$wallet->save();
$result = PaymentsController::topUpWallet($wallet);
$this->assertFalse($result);
$this->assertCount(1, $wallet->payments()->get());
Bus::assertDispatchedTimes(\App\Jobs\PaymentMandateDisabledEmail::class, 1);
Bus::assertDispatched(\App\Jobs\PaymentMandateDisabledEmail::class, function ($job) use ($wallet) {
$job_wallet = $this->getObjectProperty($job, 'wallet');
return $job_wallet->id === $wallet->id;
});
// Expect no payment if there's no mandate
$wallet->setSetting('mollie_mandate_id', null);
$wallet->balance = 0;
$wallet->save();
$result = PaymentsController::topUpWallet($wallet);
$this->assertFalse($result);
$this->assertCount(1, $wallet->payments()->get());
Bus::assertDispatchedTimes(\App\Jobs\PaymentMandateDisabledEmail::class, 1);
$this->unmockStripe();
// Test webhook
$post = [
'id' => "evt_1GlZ814fj3SIEU8wtxMZ4Nsa",
'object' => "event",
'api_version' => "2020-03-02",
'created' => 1590147209,
'data' => [
'object' => [
'id' => $payment->id,
'object' => "payment_intent",
'amount' => 2010,
'capture_method' => "automatic",
'created' => 1590147204,
'currency' => "chf",
'customer' => "cus_HKDZ53OsKdlM83",
'last_payment_error' => null,
'metadata' => [],
'receipt_email' => "payment-test@kolabnow.com",
'status' => "succeeded"
]
],
'type' => "payment_intent.succeeded"
];
// Test payment succeeded event
$response = $this->webhookRequest($post);
$response->assertStatus(200);
- $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status);
+ $this->assertSame(Payment::STATUS_PAID, $payment->fresh()->status);
$this->assertEquals(2010, $wallet->fresh()->balance);
$transaction = $wallet->transactions()
->where('type', Transaction::WALLET_CREDIT)->get()->last();
$this->assertSame(2010, $transaction->amount);
$this->assertSame(
"Auto-payment transaction {$payment->id} using Stripe",
$transaction->description
);
// Assert that email notification job has been dispatched
Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1);
Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) {
$job_payment = $this->getObjectProperty($job, 'payment');
return $job_payment->id === $payment->id;
});
Bus::fake();
// Test for payment failure ('failed' status)
$payment->refresh();
- $payment->status = PaymentProvider::STATUS_OPEN;
+ $payment->status = Payment::STATUS_OPEN;
$payment->save();
$wallet->setSetting('mandate_disabled', null);
$post['type'] = "payment_intent.payment_failed";
$post['data']['object']['status'] = 'failed';
$response = $this->webhookRequest($post);
$response->assertStatus(200);
$wallet->refresh();
- $this->assertSame(PaymentProvider::STATUS_FAILED, $payment->fresh()->status);
+ $this->assertSame(Payment::STATUS_FAILED, $payment->fresh()->status);
$this->assertEquals(2010, $wallet->balance);
$this->assertTrue(!empty($wallet->getSetting('mandate_disabled')));
// Assert that email notification job has been dispatched
Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 1);
Bus::assertDispatched(\App\Jobs\PaymentEmail::class, function ($job) use ($payment) {
$job_payment = $this->getObjectProperty($job, 'payment');
return $job_payment->id === $payment->id;
});
Bus::fake();
// Test for payment failure ('canceled' status)
$payment->refresh();
- $payment->status = PaymentProvider::STATUS_OPEN;
+ $payment->status = Payment::STATUS_OPEN;
$payment->save();
$post['type'] = "payment_intent.canceled";
$post['data']['object']['status'] = 'canceled';
$response = $this->webhookRequest($post);
$response->assertStatus(200);
- $this->assertSame(PaymentProvider::STATUS_CANCELED, $payment->fresh()->status);
+ $this->assertSame(Payment::STATUS_CANCELED, $payment->fresh()->status);
$this->assertEquals(2010, $wallet->fresh()->balance);
// Assert that email notification job wasn't dispatched,
// it is expected only for recurring payments
Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0);
}
/**
* Test payment/top-up with VAT_MODE=1
*
* @group stripe
*/
public function testPaymentsWithVatModeOne(): void
{
\config(['app.vat.mode' => 1]);
$user = $this->getTestUser('payment-test@' . \config('app.domain'));
$user->setSetting('country', 'US');
$wallet = $user->wallets()->first();
$vatRate = VatRate::create([
'country' => 'US',
'rate' => 5.0,
'start' => now()->subDay(),
]);
// Payment
$post = ['amount' => '10', 'currency' => 'CHF', 'methodId' => 'creditcard'];
$response = $this->actingAs($user)->post("api/v4/payments", $post);
$response->assertStatus(200);
// Check that the payments table contains a new record with proper amount(s)
$payment = $wallet->payments()->first();
$this->assertSame(1000 + intval(round(1000 * $vatRate->rate / 100)), $payment->amount);
$this->assertSame(1000, $payment->credit_amount);
$this->assertSame($payment->amount, $payment->currency_amount);
$this->assertSame('CHF', $payment->currency);
$this->assertSame($vatRate->id, $payment->vat_rate_id);
$this->assertSame('open', $payment->status);
$wallet->payments()->delete();
$wallet->balance = -1000;
$wallet->save();
// Top-up (mandate creation)
// Create a valid mandate first (expect an extra payment)
$post = ['amount' => 20.10, 'balance' => 0, 'methodId' => PaymentProvider::METHOD_CREDITCARD];
$response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
$response->assertStatus(200);
// Check that the payments table contains a new record with proper amount(s)
// Stripe mandates always use amount=0
$payment = $wallet->payments()->first();
$this->assertSame(0, $payment->amount);
$this->assertSame(0, $payment->credit_amount);
$this->assertSame(0, $payment->currency_amount);
$this->assertSame(null, $payment->vat_rate_id);
$wallet->payments()->delete();
$wallet->balance = -1000;
$wallet->save();
// Top-up (recurring payment)
// Expect a recurring payment as we have a valid mandate at this point
// and the balance is below the threshold
$wallet->setSettings(['stripe_mandate_id' => 'AAA']);
$setupIntent = json_encode([
"id" => "AAA",
"object" => "setup_intent",
"created" => 123456789,
"payment_method" => "pm_YYY",
"status" => "succeeded",
"usage" => "off_session",
"customer" => null
]);
$paymentMethod = json_encode([
"id" => "pm_YYY",
"object" => "payment_method",
"card" => [
"brand" => "visa",
"country" => "US",
"last4" => "4242"
],
"created" => 123456789,
"type" => "card"
]);
$paymentIntent = json_encode([
"id" => "pi_XX",
"object" => "payment_intent",
"created" => 123456789,
"amount" => 2010 + intval(round(2010 * $vatRate->rate / 100)),
"currency" => "chf",
"description" => "Recurring Payment"
]);
$client = $this->mockStripe();
$client->addResponse($setupIntent);
$client->addResponse($paymentMethod);
$client->addResponse($setupIntent);
$client->addResponse($paymentIntent);
$result = PaymentsController::topUpWallet($wallet);
$this->assertTrue($result);
// Check that the payments table contains a new record with proper amount(s)
$payment = $wallet->payments()->first();
$this->assertSame(2010 + intval(round(2010 * $vatRate->rate / 100)), $payment->amount);
$this->assertSame(2010, $payment->credit_amount);
$this->assertSame($payment->amount, $payment->currency_amount);
$this->assertSame($vatRate->id, $payment->vat_rate_id);
}
/**
* Test listing payment methods
*
* @group stripe
*/
public function testListingPaymentMethods(): void
{
Bus::fake();
$user = $this->getTestUser('john@kolab.org');
- $response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . PaymentProvider::TYPE_ONEOFF);
+ $response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . Payment::TYPE_ONEOFF);
$response->assertStatus(200);
$json = $response->json();
$hasCoinbase = !empty(\config('services.coinbase.key'));
$this->assertCount(2 + intval($hasCoinbase), $json);
$this->assertSame('creditcard', $json[0]['id']);
$this->assertSame('paypal', $json[1]['id']);
$this->assertSame('bitcoin', $json[2]['id']);
- $response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . PaymentProvider::TYPE_RECURRING);
+ $response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . Payment::TYPE_RECURRING);
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(1, $json);
$this->assertSame('creditcard', $json[0]['id']);
}
/**
* Generate Stripe-Signature header for a webhook payload
*/
protected function webhookRequest($post)
{
$secret = \config('services.stripe.webhook_secret');
$ts = time();
$payload = "$ts." . json_encode($post);
$sig = sprintf('t=%d,v1=%s', $ts, \hash_hmac('sha256', $payload, $secret));
return $this->withHeaders(['Stripe-Signature' => $sig])
->json('POST', "api/webhooks/payment/stripe", $post);
}
}
diff --git a/src/tests/Feature/Controller/Reseller/PaymentsMollieTest.php b/src/tests/Feature/Controller/Reseller/PaymentsMollieTest.php
index 8ffdb005..889895c5 100644
--- a/src/tests/Feature/Controller/Reseller/PaymentsMollieTest.php
+++ b/src/tests/Feature/Controller/Reseller/PaymentsMollieTest.php
@@ -1,261 +1,260 @@
<?php
namespace Tests\Feature\Controller\Reseller;
use App\Http\Controllers\API\V4\Reseller\PaymentsController;
use App\Payment;
-use App\Providers\PaymentProvider;
use App\Transaction;
use App\Wallet;
use App\WalletSetting;
use GuzzleHttp\Psr7\Response;
use Illuminate\Support\Facades\Bus;
use Tests\TestCase;
use Tests\BrowserAddonTrait;
use Tests\MollieMocksTrait;
class PaymentsMollieTest extends TestCase
{
use MollieMocksTrait;
use BrowserAddonTrait;
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
// All tests in this file use Mollie
\config(['services.payment_provider' => 'mollie']);
$reseller = $this->getTestUser('reseller@' . \config('app.domain'));
$wallet = $reseller->wallets()->first();
Payment::where('wallet_id', $wallet->id)->delete();
Wallet::where('id', $wallet->id)->update(['balance' => 0]);
WalletSetting::where('wallet_id', $wallet->id)->delete();
Transaction::where('object_id', $wallet->id)->delete();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$reseller = $this->getTestUser('reseller@' . \config('app.domain'));
$wallet = $reseller->wallets()->first();
Payment::where('wallet_id', $wallet->id)->delete();
Wallet::where('id', $wallet->id)->update(['balance' => 0]);
WalletSetting::where('wallet_id', $wallet->id)->delete();
Transaction::where('object_id', $wallet->id)->delete();
parent::tearDown();
}
/**
* Test creating/updating/deleting an outo-payment mandate
*
* @group mollie
*/
public function testMandates(): void
{
// Unauth access not allowed
$response = $this->get("api/v4/payments/mandate");
$response->assertStatus(401);
$response = $this->post("api/v4/payments/mandate", []);
$response->assertStatus(401);
$response = $this->put("api/v4/payments/mandate", []);
$response->assertStatus(401);
$response = $this->delete("api/v4/payments/mandate");
$response->assertStatus(401);
$reseller = $this->getTestUser('reseller@' . \config('app.domain'));
$wallet = $reseller->wallets()->first();
$wallet->balance = -10;
$wallet->save();
// Test creating a mandate (valid input)
$post = ['amount' => 20.10, 'balance' => 0];
$response = $this->actingAs($reseller)->post("api/v4/payments/mandate", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertMatchesRegularExpression('|^https://www.mollie.com|', $json['redirectUrl']);
// Assert the proper payment amount has been used
$payment = Payment::where('id', $json['id'])->first();
$this->assertSame(2010, $payment->amount);
$this->assertSame($wallet->id, $payment->wallet_id);
$this->assertSame($reseller->tenant->title . " Auto-Payment Setup", $payment->description);
- $this->assertSame(PaymentProvider::TYPE_MANDATE, $payment->type);
+ $this->assertSame(Payment::TYPE_MANDATE, $payment->type);
// Test fetching the mandate information
$response = $this->actingAs($reseller)->get("api/v4/payments/mandate");
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals(20.10, $json['amount']);
$this->assertEquals(0, $json['balance']);
$this->assertEquals('Credit Card', $json['method']);
$this->assertSame(true, $json['isPending']);
$this->assertSame(false, $json['isValid']);
$this->assertSame(false, $json['isDisabled']);
$mandate_id = $json['id'];
// We would have to invoke a browser to accept the "first payment" to make
// the mandate validated/completed. Instead, we'll mock the mandate object.
$mollie_response = [
'resource' => 'mandate',
'id' => $mandate_id,
'status' => 'valid',
'method' => 'creditcard',
'details' => [
'cardNumber' => '4242',
'cardLabel' => 'Visa',
],
'customerId' => 'cst_GMfxGPt7Gj',
'createdAt' => '2020-04-28T11:09:47+00:00',
];
$responseStack = $this->mockMollie();
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$wallet = $reseller->wallets()->first();
$wallet->setSetting('mandate_disabled', 1);
$response = $this->actingAs($reseller)->get("api/v4/payments/mandate");
$response->assertStatus(200);
$json = $response->json();
$this->assertEquals(20.10, $json['amount']);
$this->assertEquals(0, $json['balance']);
$this->assertEquals('Visa (**** **** **** 4242)', $json['method']);
$this->assertSame(false, $json['isPending']);
$this->assertSame(true, $json['isValid']);
$this->assertSame(true, $json['isDisabled']);
Bus::fake();
$wallet->setSetting('mandate_disabled', null);
$wallet->balance = 1000;
$wallet->save();
// Test updating a mandate (valid input)
$responseStack->append(new Response(200, [], json_encode($mollie_response)));
$post = ['amount' => 30.10, 'balance' => 10];
$response = $this->actingAs($reseller)->put("api/v4/payments/mandate", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame('The auto-payment has been updated.', $json['message']);
$this->assertSame($mandate_id, $json['id']);
$this->assertFalse($json['isDisabled']);
$wallet->refresh();
$this->assertEquals(30.10, $wallet->getSetting('mandate_amount'));
$this->assertEquals(10, $wallet->getSetting('mandate_balance'));
Bus::assertDispatchedTimes(\App\Jobs\WalletCharge::class, 0);
$this->unmockMollie();
// Delete mandate
$response = $this->actingAs($reseller)->delete("api/v4/payments/mandate");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame('The auto-payment has been removed.', $json['message']);
}
/**
* Test creating a payment
*
* @group mollie
*/
public function testStore(): void
{
Bus::fake();
// Unauth access not allowed
$response = $this->post("api/v4/payments", []);
$response->assertStatus(401);
$reseller = $this->getTestUser('reseller@' . \config('app.domain'));
// Successful payment
$post = ['amount' => '12.34', 'currency' => 'CHF', 'methodId' => 'creditcard'];
$response = $this->actingAs($reseller)->post("api/v4/payments", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertMatchesRegularExpression('|^https://www.mollie.com|', $json['redirectUrl']);
}
/**
* Test listing a pending payment
*
* @group mollie
*/
public function testListingPayments(): void
{
Bus::fake();
$reseller = $this->getTestUser('reseller@' . \config('app.domain'));
// Empty response
$response = $this->actingAs($reseller)->get("api/v4/payments/pending");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame(0, $json['count']);
$this->assertSame(1, $json['page']);
$this->assertSame(false, $json['hasMore']);
$this->assertCount(0, $json['list']);
$response = $this->actingAs($reseller)->get("api/v4/payments/has-pending");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(false, $json['hasPending']);
}
/**
* Test listing payment methods
*
* @group mollie
*/
public function testListingPaymentMethods(): void
{
Bus::fake();
$reseller = $this->getTestUser('reseller@' . \config('app.domain'));
- $response = $this->actingAs($reseller)->get('api/v4/payments/methods?type=' . PaymentProvider::TYPE_ONEOFF);
+ $response = $this->actingAs($reseller)->get('api/v4/payments/methods?type=' . Payment::TYPE_ONEOFF);
$response->assertStatus(200);
$json = $response->json();
$hasCoinbase = !empty(\config('services.coinbase.key'));
$this->assertCount(3 + intval($hasCoinbase), $json);
$this->assertSame('creditcard', $json[0]['id']);
$this->assertSame('paypal', $json[1]['id']);
$this->assertSame('banktransfer', $json[2]['id']);
$this->assertSame('bitcoin', $json[3]['id']);
}
}
diff --git a/src/tests/Feature/Controller/WalletsTest.php b/src/tests/Feature/Controller/WalletsTest.php
index a84323b5..6b5c255d 100644
--- a/src/tests/Feature/Controller/WalletsTest.php
+++ b/src/tests/Feature/Controller/WalletsTest.php
@@ -1,357 +1,356 @@
<?php
namespace Tests\Feature\Controller;
use App\Http\Controllers\API\V4\WalletsController;
use App\Payment;
-use App\Providers\PaymentProvider;
use App\Transaction;
use Carbon\Carbon;
use Tests\TestCase;
class WalletsTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('wallets-controller@kolabnow.com');
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('wallets-controller@kolabnow.com');
parent::tearDown();
}
/**
* Test for getWalletNotice() method
*/
public function testGetWalletNotice(): void
{
$user = $this->getTestUser('wallets-controller@kolabnow.com');
$plan = \App\Plan::withObjectTenantContext($user)->where('title', 'individual')->first();
$user->assignPlan($plan);
$wallet = $user->wallets()->first();
$controller = new WalletsController();
$method = new \ReflectionMethod($controller, 'getWalletNotice');
$method->setAccessible(true);
// User/entitlements created today, balance=0
$notice = $method->invoke($controller, $wallet);
$this->assertSame('You are in your free trial period.', $notice);
$wallet->owner->created_at = Carbon::now()->subWeeks(3);
$wallet->owner->save();
$notice = $method->invoke($controller, $wallet);
$this->assertSame('Your free trial is about to end, top up to continue.', $notice);
// User/entitlements created today, balance=-10 CHF
$wallet->balance = -1000;
$notice = $method->invoke($controller, $wallet);
$this->assertSame('You are out of credit, top up your balance now.', $notice);
// User/entitlements created slightly more than a month ago, balance=9,99 CHF (monthly)
$this->backdateEntitlements($wallet->entitlements, Carbon::now()->subMonthsWithoutOverflow(1)->subDays(1));
$wallet->refresh();
// test "1 month"
$wallet->balance = 990;
$notice = $method->invoke($controller, $wallet);
$this->assertMatchesRegularExpression('/\((1 month|4 weeks)\)/', $notice);
// test "2 months"
$wallet->balance = 990 * 2.6;
$notice = $method->invoke($controller, $wallet);
$this->assertMatchesRegularExpression('/\(1 month 4 weeks\)/', $notice);
// Change locale to make sure the text is localized by Carbon
\app()->setLocale('de');
// test "almost 2 years"
$wallet->balance = 990 * 23.5;
$notice = $method->invoke($controller, $wallet);
$this->assertMatchesRegularExpression('/\(1 Jahr 10 Monate\)/', $notice);
// Old entitlements, 100% discount
$this->backdateEntitlements($wallet->entitlements, Carbon::now()->subDays(40));
$discount = \App\Discount::withObjectTenantContext($user)->where('discount', 100)->first();
$wallet->discount()->associate($discount);
$notice = $method->invoke($controller, $wallet->refresh());
$this->assertSame(null, $notice);
}
/**
* Test fetching pdf receipt
*/
public function testReceiptDownload(): void
{
$user = $this->getTestUser('wallets-controller@kolabnow.com');
$john = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
// Unauth access not allowed
$response = $this->get("api/v4/wallets/{$wallet->id}/receipts/2020-05");
$response->assertStatus(401);
$response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/receipts/2020-05");
$response->assertStatus(403);
// Invalid receipt id (current month)
$receiptId = date('Y-m');
$response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts/{$receiptId}");
$response->assertStatus(404);
// Invalid receipt id
$receiptId = '1000-03';
$response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts/{$receiptId}");
$response->assertStatus(404);
// Valid receipt id
$year = intval(date('Y')) - 1;
$receiptId = "$year-12";
$filename = \config('app.name') . " Receipt for $year-12";
$response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts/{$receiptId}");
$response->assertStatus(200);
$response->assertHeader('content-type', 'application/pdf');
$response->assertHeader('content-disposition', 'attachment; filename="' . $filename . '"');
$response->assertHeader('content-length');
$length = $response->headers->get('content-length');
$content = $response->content();
$this->assertStringStartsWith("%PDF-1.", $content);
$this->assertEquals(strlen($content), $length);
}
/**
* Test fetching list of receipts
*/
public function testReceipts(): void
{
$user = $this->getTestUser('wallets-controller@kolabnow.com');
$john = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
$wallet->payments()->delete();
// Unauth access not allowed
$response = $this->get("api/v4/wallets/{$wallet->id}/receipts");
$response->assertStatus(401);
$response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/receipts");
$response->assertStatus(403);
// Empty list expected
$response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(5, $json);
$this->assertSame('success', $json['status']);
$this->assertSame([], $json['list']);
$this->assertSame(1, $json['page']);
$this->assertSame(0, $json['count']);
$this->assertSame(false, $json['hasMore']);
// Insert a payment to the database
$date = Carbon::create(intval(date('Y')) - 1, 4, 30);
$payment = Payment::create([
'id' => 'AAA1',
- 'status' => PaymentProvider::STATUS_PAID,
- 'type' => PaymentProvider::TYPE_ONEOFF,
+ 'status' => Payment::STATUS_PAID,
+ 'type' => Payment::TYPE_ONEOFF,
'description' => 'Paid in April',
'wallet_id' => $wallet->id,
'provider' => 'stripe',
'amount' => 1111,
'credit_amount' => 1111,
'currency' => 'CHF',
'currency_amount' => 1111,
]);
$payment->updated_at = $date;
$payment->save();
$response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(5, $json);
$this->assertSame('success', $json['status']);
$this->assertSame([$date->format('Y-m')], $json['list']);
$this->assertSame(1, $json['page']);
$this->assertSame(1, $json['count']);
$this->assertSame(false, $json['hasMore']);
}
/**
* Test fetching a wallet (GET /api/v4/wallets/:id)
*/
public function testShow(): void
{
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$wallet = $john->wallets()->first();
$wallet->balance = -100;
$wallet->save();
// Accessing a wallet of someone else
$response = $this->actingAs($jack)->get("api/v4/wallets/{$wallet->id}");
$response->assertStatus(403);
// Accessing non-existing wallet
$response = $this->actingAs($jack)->get("api/v4/wallets/aaa");
$response->assertStatus(404);
// Wallet owner
$response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame($wallet->id, $json['id']);
$this->assertSame('CHF', $json['currency']);
$this->assertSame($wallet->balance, $json['balance']);
$this->assertTrue(empty($json['description']));
$this->assertTrue(!empty($json['notice']));
}
/**
* Test fetching wallet transactions
*/
public function testTransactions(): void
{
$package_kolab = \App\Package::where('title', 'kolab')->first();
$user = $this->getTestUser('wallets-controller@kolabnow.com');
$user->assignPackage($package_kolab);
$john = $this->getTestUser('john@kolab.org');
$wallet = $user->wallets()->first();
// Unauth access not allowed
$response = $this->get("api/v4/wallets/{$wallet->id}/transactions");
$response->assertStatus(401);
$response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/transactions");
$response->assertStatus(403);
// Expect empty list
$response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(5, $json);
$this->assertSame('success', $json['status']);
$this->assertSame([], $json['list']);
$this->assertSame(1, $json['page']);
$this->assertSame(0, $json['count']);
$this->assertSame(false, $json['hasMore']);
// Create some sample transactions
$transactions = $this->createTestTransactions($wallet);
$transactions = array_reverse($transactions);
$pages = array_chunk($transactions, 10 /* page size*/);
// Get the first page
$response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(5, $json);
$this->assertSame('success', $json['status']);
$this->assertSame(1, $json['page']);
$this->assertSame(10, $json['count']);
$this->assertSame(true, $json['hasMore']);
$this->assertCount(10, $json['list']);
foreach ($pages[0] as $idx => $transaction) {
$this->assertSame($transaction->id, $json['list'][$idx]['id']);
$this->assertSame($transaction->type, $json['list'][$idx]['type']);
$this->assertSame(\config('app.currency'), $json['list'][$idx]['currency']);
$this->assertSame($transaction->shortDescription(), $json['list'][$idx]['description']);
$this->assertFalse($json['list'][$idx]['hasDetails']);
$this->assertFalse(array_key_exists('user', $json['list'][$idx]));
}
$search = null;
// Get the second page
$response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?page=2");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(5, $json);
$this->assertSame('success', $json['status']);
$this->assertSame(2, $json['page']);
$this->assertSame(2, $json['count']);
$this->assertSame(false, $json['hasMore']);
$this->assertCount(2, $json['list']);
foreach ($pages[1] as $idx => $transaction) {
$this->assertSame($transaction->id, $json['list'][$idx]['id']);
$this->assertSame($transaction->type, $json['list'][$idx]['type']);
$this->assertSame($transaction->shortDescription(), $json['list'][$idx]['description']);
$this->assertSame(
$transaction->type == Transaction::WALLET_DEBIT,
$json['list'][$idx]['hasDetails']
);
$this->assertFalse(array_key_exists('user', $json['list'][$idx]));
if ($transaction->type == Transaction::WALLET_DEBIT) {
$search = $transaction->id;
}
}
// Get a non-existing page
$response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?page=3");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(5, $json);
$this->assertSame('success', $json['status']);
$this->assertSame(3, $json['page']);
$this->assertSame(0, $json['count']);
$this->assertSame(false, $json['hasMore']);
$this->assertCount(0, $json['list']);
// Sub-transaction searching
$response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?transaction=123");
$response->assertStatus(404);
$response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?transaction={$search}");
$response->assertStatus(200);
$json = $response->json();
$this->assertCount(5, $json);
$this->assertSame('success', $json['status']);
$this->assertSame(1, $json['page']);
$this->assertSame(2, $json['count']);
$this->assertSame(false, $json['hasMore']);
$this->assertCount(2, $json['list']);
$this->assertSame(Transaction::ENTITLEMENT_BILLED, $json['list'][0]['type']);
$this->assertSame(Transaction::ENTITLEMENT_BILLED, $json['list'][1]['type']);
// Test that John gets 404 if he tries to access
// someone else's transaction ID on his wallet's endpoint
$wallet = $john->wallets()->first();
$response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/transactions?transaction={$search}");
$response->assertStatus(404);
}
}
diff --git a/src/tests/Feature/Documents/ReceiptTest.php b/src/tests/Feature/Documents/ReceiptTest.php
index 6a762c62..9bae9d9d 100644
--- a/src/tests/Feature/Documents/ReceiptTest.php
+++ b/src/tests/Feature/Documents/ReceiptTest.php
@@ -1,410 +1,409 @@
<?php
namespace Tests\Feature\Documents;
use App\Documents\Receipt;
use App\Payment;
-use App\Providers\PaymentProvider;
use App\User;
use App\Wallet;
use App\VatRate;
use Carbon\Carbon;
use Illuminate\Support\Facades\Bus;
use Tests\TestCase;
class ReceiptTest extends TestCase
{
public function setUp(): void
{
parent::setUp();
Payment::query()->delete();
VatRate::query()->delete();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('receipt-test@kolabnow.com');
Payment::query()->delete();
VatRate::query()->delete();
parent::tearDown();
}
/**
* Test receipt HTML output (without VAT)
*/
public function testHtmlOutput(): void
{
$appName = \config('app.name');
$wallet = $this->getTestData();
$receipt = new Receipt($wallet, 2020, 5);
$html = $receipt->htmlOutput();
$this->assertStringStartsWith('<!DOCTYPE html>', $html);
$dom = new \DOMDocument('1.0', 'UTF-8');
$dom->loadHTML($html);
// Title
$title = $dom->getElementById('title');
$this->assertSame("Receipt for May 2020", $title->textContent);
// Company name/address
$header = $dom->getElementById('header');
$companyOutput = $this->getNodeContent($header->getElementsByTagName('td')[0]);
$companyExpected = \config('app.company.name') . "\n" . \config('app.company.address');
$this->assertSame($companyExpected, $companyOutput);
// The main table content
$content = $dom->getElementById('content');
$records = $content->getElementsByTagName('tr');
$this->assertCount(7, $records);
$headerCells = $records[0]->getElementsByTagName('th');
$this->assertCount(3, $headerCells);
$this->assertSame('Date', $this->getNodeContent($headerCells[0]));
$this->assertSame('Description', $this->getNodeContent($headerCells[1]));
$this->assertSame('Amount', $this->getNodeContent($headerCells[2]));
$cells = $records[1]->getElementsByTagName('td');
$this->assertCount(3, $cells);
$this->assertSame('2020-05-01', $this->getNodeContent($cells[0]));
$this->assertSame("$appName Services", $this->getNodeContent($cells[1]));
$this->assertSame('12,34 CHF', $this->getNodeContent($cells[2]));
$cells = $records[2]->getElementsByTagName('td');
$this->assertCount(3, $cells);
$this->assertSame('2020-05-10', $this->getNodeContent($cells[0]));
$this->assertSame("$appName Services", $this->getNodeContent($cells[1]));
$this->assertSame('0,01 CHF', $this->getNodeContent($cells[2]));
$cells = $records[3]->getElementsByTagName('td');
$this->assertCount(3, $cells);
$this->assertSame('2020-05-21', $this->getNodeContent($cells[0]));
$this->assertSame("$appName Services", $this->getNodeContent($cells[1]));
$this->assertSame('1,00 CHF', $this->getNodeContent($cells[2]));
$cells = $records[4]->getElementsByTagName('td');
$this->assertCount(3, $cells);
$this->assertSame('2020-05-30', $this->getNodeContent($cells[0]));
$this->assertSame("Refund", $this->getNodeContent($cells[1]));
$this->assertSame('-1,00 CHF', $this->getNodeContent($cells[2]));
$cells = $records[5]->getElementsByTagName('td');
$this->assertCount(3, $cells);
$this->assertSame('2020-05-31', $this->getNodeContent($cells[0]));
$this->assertSame("Chargeback", $this->getNodeContent($cells[1]));
$this->assertSame('-0,10 CHF', $this->getNodeContent($cells[2]));
$summaryCells = $records[6]->getElementsByTagName('td');
$this->assertCount(2, $summaryCells);
$this->assertSame('Total', $this->getNodeContent($summaryCells[0]));
$this->assertSame('12,25 CHF', $this->getNodeContent($summaryCells[1]));
// Customer data
$customer = $dom->getElementById('customer');
$customerCells = $customer->getElementsByTagName('td');
$customerOutput = $this->getNodeContent($customerCells[0]);
$customerExpected = "Firstname Lastname\nTest Unicode Straße 150\n10115 Berlin";
$this->assertSame($customerExpected, $this->getNodeContent($customerCells[0]));
$customerIdents = $this->getNodeContent($customerCells[1]);
//$this->assertTrue(strpos($customerIdents, "Account ID {$wallet->id}") !== false);
$this->assertTrue(strpos($customerIdents, "Customer No. {$wallet->owner->id}") !== false);
// Company details in the footer
$footer = $dom->getElementById('footer');
$footerOutput = $footer->textContent;
$this->assertStringStartsWith(\config('app.company.details'), $footerOutput);
$this->assertTrue(strpos($footerOutput, \config('app.company.email')) !== false);
}
/**
* Test receipt HTML output (with VAT)
*/
public function testHtmlOutputVat(): void
{
$appName = \config('app.name');
$wallet = $this->getTestData('CH');
$receipt = new Receipt($wallet, 2020, 5);
$html = $receipt->htmlOutput();
$this->assertStringStartsWith('<!DOCTYPE html>', $html);
$dom = new \DOMDocument('1.0', 'UTF-8');
$dom->loadHTML($html);
// The main table content
$content = $dom->getElementById('content');
$records = $content->getElementsByTagName('tr');
$this->assertCount(9, $records);
$cells = $records[1]->getElementsByTagName('td');
$this->assertCount(3, $cells);
$this->assertSame('2020-05-01', $this->getNodeContent($cells[0]));
$this->assertSame("$appName Services", $this->getNodeContent($cells[1]));
$this->assertSame('11,39 CHF', $this->getNodeContent($cells[2]));
$cells = $records[2]->getElementsByTagName('td');
$this->assertCount(3, $cells);
$this->assertSame('2020-05-10', $this->getNodeContent($cells[0]));
$this->assertSame("$appName Services", $this->getNodeContent($cells[1]));
$this->assertSame('0,01 CHF', $this->getNodeContent($cells[2]));
$cells = $records[3]->getElementsByTagName('td');
$this->assertCount(3, $cells);
$this->assertSame('2020-05-21', $this->getNodeContent($cells[0]));
$this->assertSame("$appName Services", $this->getNodeContent($cells[1]));
$this->assertSame('0,92 CHF', $this->getNodeContent($cells[2]));
$cells = $records[4]->getElementsByTagName('td');
$this->assertCount(3, $cells);
$this->assertSame('2020-05-30', $this->getNodeContent($cells[0]));
$this->assertSame("Refund", $this->getNodeContent($cells[1]));
$this->assertSame('-0,92 CHF', $this->getNodeContent($cells[2]));
$cells = $records[5]->getElementsByTagName('td');
$this->assertCount(3, $cells);
$this->assertSame('2020-05-31', $this->getNodeContent($cells[0]));
$this->assertSame("Chargeback", $this->getNodeContent($cells[1]));
$this->assertSame('-0,09 CHF', $this->getNodeContent($cells[2]));
$subtotalCells = $records[6]->getElementsByTagName('td');
$this->assertCount(2, $subtotalCells);
$this->assertSame('Subtotal', $this->getNodeContent($subtotalCells[0]));
$this->assertSame('11,31 CHF', $this->getNodeContent($subtotalCells[1]));
$vatCells = $records[7]->getElementsByTagName('td');
$this->assertCount(2, $vatCells);
$this->assertSame('VAT (7.7%)', $this->getNodeContent($vatCells[0]));
$this->assertSame('0,94 CHF', $this->getNodeContent($vatCells[1]));
$totalCells = $records[8]->getElementsByTagName('td');
$this->assertCount(2, $totalCells);
$this->assertSame('Total', $this->getNodeContent($totalCells[0]));
$this->assertSame('12,25 CHF', $this->getNodeContent($totalCells[1]));
}
/**
* Test receipt PDF output
*/
public function testPdfOutput(): void
{
$wallet = $this->getTestData();
$receipt = new Receipt($wallet, 2020, 5);
$pdf = $receipt->PdfOutput();
$this->assertStringStartsWith("%PDF-1.", $pdf);
$this->assertTrue(strlen($pdf) > 2000);
// TODO: Test the content somehow
}
/**
* Prepare data for a test
*
* @param string $country User country code
*
* @return \App\Wallet
*/
protected function getTestData(string $country = null): Wallet
{
Bus::fake();
$user = $this->getTestUser('receipt-test@kolabnow.com');
$user->setSettings([
'first_name' => 'Firstname',
'last_name' => 'Lastname',
'billing_address' => "Test Unicode Straße 150\n10115 Berlin",
'country' => $country
]);
$wallet = $user->wallets()->first();
$vat = null;
if ($country) {
$vat = VatRate::create([
'country' => $country,
'rate' => 7.7,
'start' => now(),
])->id;
}
// Create two payments out of the 2020-05 period
// and three in it, plus one in the period but unpaid,
// and one with amount 0, and an extra refund and chanrgeback
$payment = Payment::create([
'id' => 'AAA1',
- 'status' => PaymentProvider::STATUS_PAID,
- 'type' => PaymentProvider::TYPE_ONEOFF,
+ 'status' => Payment::STATUS_PAID,
+ 'type' => Payment::TYPE_ONEOFF,
'description' => 'Paid in April',
'wallet_id' => $wallet->id,
'provider' => 'stripe',
'amount' => 1111,
'credit_amount' => 1111,
'vat_rate_id' => $vat,
'currency' => 'CHF',
'currency_amount' => 1111,
]);
$payment->updated_at = Carbon::create(2020, 4, 30, 12, 0, 0);
$payment->save();
$payment = Payment::create([
'id' => 'AAA2',
- 'status' => PaymentProvider::STATUS_PAID,
- 'type' => PaymentProvider::TYPE_ONEOFF,
+ 'status' => Payment::STATUS_PAID,
+ 'type' => Payment::TYPE_ONEOFF,
'description' => 'Paid in June',
'wallet_id' => $wallet->id,
'provider' => 'stripe',
'amount' => 2222,
'credit_amount' => 2222,
'vat_rate_id' => $vat,
'currency' => 'CHF',
'currency_amount' => 2222,
]);
$payment->updated_at = Carbon::create(2020, 6, 1, 0, 0, 0);
$payment->save();
$payment = Payment::create([
'id' => 'AAA3',
- 'status' => PaymentProvider::STATUS_PAID,
- 'type' => PaymentProvider::TYPE_ONEOFF,
+ 'status' => Payment::STATUS_PAID,
+ 'type' => Payment::TYPE_ONEOFF,
'description' => 'Auto-Payment Setup',
'wallet_id' => $wallet->id,
'provider' => 'stripe',
'amount' => 0,
'credit_amount' => 0,
'vat_rate_id' => $vat,
'currency' => 'CHF',
'currency_amount' => 0,
]);
$payment->updated_at = Carbon::create(2020, 5, 1, 0, 0, 0);
$payment->save();
$payment = Payment::create([
'id' => 'AAA4',
- 'status' => PaymentProvider::STATUS_OPEN,
- 'type' => PaymentProvider::TYPE_ONEOFF,
+ 'status' => Payment::STATUS_OPEN,
+ 'type' => Payment::TYPE_ONEOFF,
'description' => 'Payment not yet paid',
'wallet_id' => $wallet->id,
'provider' => 'stripe',
'amount' => 990,
'credit_amount' => 990,
'vat_rate_id' => $vat,
'currency' => 'CHF',
'currency_amount' => 990,
]);
$payment->updated_at = Carbon::create(2020, 5, 1, 0, 0, 0);
$payment->save();
// ... so we expect the five three on the receipt
$payment = Payment::create([
'id' => 'AAA5',
- 'status' => PaymentProvider::STATUS_PAID,
- 'type' => PaymentProvider::TYPE_ONEOFF,
+ 'status' => Payment::STATUS_PAID,
+ 'type' => Payment::TYPE_ONEOFF,
'description' => 'Payment OK',
'wallet_id' => $wallet->id,
'provider' => 'stripe',
'amount' => 1234,
'credit_amount' => 1234,
'vat_rate_id' => $vat,
'currency' => 'CHF',
'currency_amount' => 1234,
]);
$payment->updated_at = Carbon::create(2020, 5, 1, 0, 0, 0);
$payment->save();
$payment = Payment::create([
'id' => 'AAA6',
- 'status' => PaymentProvider::STATUS_PAID,
- 'type' => PaymentProvider::TYPE_ONEOFF,
+ 'status' => Payment::STATUS_PAID,
+ 'type' => Payment::TYPE_ONEOFF,
'description' => 'Payment OK',
'wallet_id' => $wallet->id,
'provider' => 'stripe',
'amount' => 1,
'credit_amount' => 1,
'vat_rate_id' => $vat,
'currency' => 'CHF',
'currency_amount' => 1,
]);
$payment->updated_at = Carbon::create(2020, 5, 10, 0, 0, 0);
$payment->save();
$payment = Payment::create([
'id' => 'AAA7',
- 'status' => PaymentProvider::STATUS_PAID,
- 'type' => PaymentProvider::TYPE_RECURRING,
+ 'status' => Payment::STATUS_PAID,
+ 'type' => Payment::TYPE_RECURRING,
'description' => 'Payment OK',
'wallet_id' => $wallet->id,
'provider' => 'stripe',
'amount' => 100,
'credit_amount' => 100,
'vat_rate_id' => $vat,
'currency' => 'CHF',
'currency_amount' => 100,
]);
$payment->updated_at = Carbon::create(2020, 5, 21, 23, 59, 0);
$payment->save();
$payment = Payment::create([
'id' => 'ref1',
- 'status' => PaymentProvider::STATUS_PAID,
- 'type' => PaymentProvider::TYPE_REFUND,
+ 'status' => Payment::STATUS_PAID,
+ 'type' => Payment::TYPE_REFUND,
'description' => 'refund desc',
'wallet_id' => $wallet->id,
'provider' => 'stripe',
'amount' => -100,
'credit_amount' => -100,
'vat_rate_id' => $vat,
'currency' => 'CHF',
'currency_amount' => -100,
]);
$payment->updated_at = Carbon::create(2020, 5, 30, 23, 59, 0);
$payment->save();
$payment = Payment::create([
'id' => 'chback1',
- 'status' => PaymentProvider::STATUS_PAID,
- 'type' => PaymentProvider::TYPE_CHARGEBACK,
+ 'status' => Payment::STATUS_PAID,
+ 'type' => Payment::TYPE_CHARGEBACK,
'description' => '',
'wallet_id' => $wallet->id,
'provider' => 'stripe',
'amount' => -10,
'credit_amount' => -10,
'vat_rate_id' => $vat,
'currency' => 'CHF',
'currency_amount' => -10,
]);
$payment->updated_at = Carbon::create(2020, 5, 31, 23, 59, 0);
$payment->save();
// Make sure some config is set so we can test it's put into the receipt
if (empty(\config('app.company.name'))) {
\config(['app.company.name' => 'Company Co.']);
}
if (empty(\config('app.company.email'))) {
\config(['app.company.email' => 'email@domina.tld']);
}
if (empty(\config('app.company.details'))) {
\config(['app.company.details' => 'VAT No. 123456789']);
}
if (empty(\config('app.company.address'))) {
\config(['app.company.address' => "Test Street 12\n12345 Some Place"]);
}
return $wallet;
}
/**
* Extract text from a HTML element replacing <br> with \n
*
* @param \DOMElement $node The HTML element
*
* @return string The content
*/
protected function getNodeContent(\DOMElement $node)
{
$content = [];
foreach ($node->childNodes as $child) {
if ($child->nodeName == 'br') {
$content[] = "\n";
} else {
$content[] = $child->textContent;
}
}
return trim(implode($content));
}
}
diff --git a/src/tests/Feature/Jobs/PaymentEmailTest.php b/src/tests/Feature/Jobs/PaymentEmailTest.php
index a515d0d2..154a4d5e 100644
--- a/src/tests/Feature/Jobs/PaymentEmailTest.php
+++ b/src/tests/Feature/Jobs/PaymentEmailTest.php
@@ -1,125 +1,124 @@
<?php
namespace Tests\Feature\Jobs;
use App\Jobs\PaymentEmail;
use App\Mail\PaymentFailure;
use App\Mail\PaymentSuccess;
use App\Payment;
-use App\Providers\PaymentProvider;
use App\User;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
class PaymentEmailTest extends TestCase
{
/**
* {@inheritDoc}
*
* @return void
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('PaymentEmail@UserAccount.com');
}
/**
* {@inheritDoc}
*
* @return void
*/
public function tearDown(): void
{
$this->deleteTestUser('PaymentEmail@UserAccount.com');
parent::tearDown();
}
/**
* Test job handle
*
* @return void
*/
public function testHandle()
{
$status = User::STATUS_ACTIVE | User::STATUS_LDAP_READY | User::STATUS_IMAP_READY;
$user = $this->getTestUser('PaymentEmail@UserAccount.com', ['status' => $status]);
$user->setSetting('external_email', 'ext@email.tld');
$wallet = $user->wallets()->first();
$payment = new Payment();
$payment->id = 'test-payment';
$payment->wallet_id = $wallet->id;
$payment->amount = 100;
$payment->credit_amount = 100;
$payment->currency_amount = 100;
$payment->currency = 'CHF';
- $payment->status = PaymentProvider::STATUS_PAID;
+ $payment->status = Payment::STATUS_PAID;
$payment->description = 'test';
$payment->provider = 'stripe';
- $payment->type = PaymentProvider::TYPE_ONEOFF;
+ $payment->type = Payment::TYPE_ONEOFF;
$payment->save();
Mail::fake();
// Assert that no jobs were pushed...
Mail::assertNothingSent();
$job = new PaymentEmail($payment);
$job->handle();
// Assert the email sending job was pushed once
Mail::assertSent(PaymentSuccess::class, 1);
// Assert the mail was sent to the user's email
Mail::assertSent(PaymentSuccess::class, function ($mail) {
return $mail->hasTo('ext@email.tld') && !$mail->hasCc('ext@email.tld');
});
- $payment->status = PaymentProvider::STATUS_FAILED;
+ $payment->status = Payment::STATUS_FAILED;
$payment->save();
$job = new PaymentEmail($payment);
$job->handle();
// Assert the email sending job was pushed once
Mail::assertSent(PaymentFailure::class, 1);
// Assert the mail was sent to the user's email
Mail::assertSent(PaymentFailure::class, function ($mail) {
return $mail->hasTo('ext@email.tld') && !$mail->hasCc('ext@email.tld');
});
- $payment->status = PaymentProvider::STATUS_EXPIRED;
+ $payment->status = Payment::STATUS_EXPIRED;
$payment->save();
$job = new PaymentEmail($payment);
$job->handle();
// Assert the email sending job was pushed twice
Mail::assertSent(PaymentFailure::class, 2);
// None of statuses below should trigger an email
Mail::fake();
$states = [
- PaymentProvider::STATUS_OPEN,
- PaymentProvider::STATUS_CANCELED,
- PaymentProvider::STATUS_PENDING,
- PaymentProvider::STATUS_AUTHORIZED,
+ Payment::STATUS_OPEN,
+ Payment::STATUS_CANCELED,
+ Payment::STATUS_PENDING,
+ Payment::STATUS_AUTHORIZED,
];
foreach ($states as $state) {
$payment->status = $state;
$payment->save();
$job = new PaymentEmail($payment);
$job->handle();
}
// Assert that no mailables were sent...
Mail::assertNothingSent();
}
}
diff --git a/src/tests/Feature/PaymentTest.php b/src/tests/Feature/PaymentTest.php
index 03deb507..4102bb10 100644
--- a/src/tests/Feature/PaymentTest.php
+++ b/src/tests/Feature/PaymentTest.php
@@ -1,169 +1,226 @@
<?php
namespace Tests\Feature;
use App\Payment;
-use App\Providers\PaymentProvider;
use App\Transaction;
use App\Wallet;
use App\VatRate;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class PaymentTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
$this->deleteTestUser('jane@kolabnow.com');
Payment::query()->delete();
VatRate::query()->delete();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('jane@kolabnow.com');
Payment::query()->delete();
VatRate::query()->delete();
parent::tearDown();
}
+ /**
+ * Test credit() method
+ */
+ public function testCredit(): void
+ {
+ Queue::fake();
+
+ $user = $this->getTestUser('jane@kolabnow.com');
+ $wallet = $user->wallets()->first();
+
+ $wallet->setSetting('mandate_disabled', 1);
+
+ $payment1 = Payment::createFromArray([
+ 'id' => 'test-payment1',
+ 'amount' => 10750,
+ 'currency' => $wallet->currency,
+ 'currency_amount' => 10750,
+ 'type' => Payment::TYPE_ONEOFF,
+ 'wallet_id' => $wallet->id,
+ 'status' => Payment::STATUS_PAID,
+ ]);
+
+ $payment2 = Payment::createFromArray([
+ 'id' => 'test-payment2',
+ 'amount' => 1075,
+ 'currency' => $wallet->currency,
+ 'currency_amount' => 1075,
+ 'type' => Payment::TYPE_RECURRING,
+ 'wallet_id' => $wallet->id,
+ 'status' => Payment::STATUS_PAID,
+ ]);
+
+ // Credit the 1st payment
+ $payment1->credit('Test1');
+ $wallet->refresh();
+ $transaction = $wallet->transactions()->first();
+
+ $this->assertSame($payment1->credit_amount, $wallet->balance);
+ $this->assertNull($wallet->getSetting('mandate_disabled'));
+ $this->assertSame($payment1->credit_amount, $transaction->amount);
+ $this->assertSame("Payment transaction {$payment1->id} using Test1", $transaction->description);
+
+ $wallet->transactions()->delete();
+ $wallet->setSetting('mandate_disabled', 1);
+ $wallet->balance = -5000;
+ $wallet->save();
+
+ // Credit the 2nd payment
+ $payment2->credit('Test2');
+ $wallet->refresh();
+ $transaction = $wallet->transactions()->first();
+
+ $this->assertSame($payment2->credit_amount - 5000, $wallet->balance);
+ $this->assertSame('1', $wallet->getSetting('mandate_disabled'));
+ $this->assertSame($payment2->credit_amount, $transaction->amount);
+ $this->assertSame("Auto-payment transaction {$payment2->id} using Test2", $transaction->description);
+ }
+
/**
* Test createFromArray() and refund() methods
*/
public function testCreateAndRefund(): void
{
Queue::fake();
$user = $this->getTestUser('jane@kolabnow.com');
$wallet = $user->wallets()->first();
$vatRate = VatRate::create([
'start' => now()->subDay(),
'country' => 'US',
'rate' => 7.5,
]);
// Test required properties only
$payment1Array = [
'id' => 'test-payment2',
'amount' => 10750,
'currency' => 'USD',
'currency_amount' => 9000,
- 'type' => PaymentProvider::TYPE_ONEOFF,
+ 'type' => Payment::TYPE_ONEOFF,
'wallet_id' => $wallet->id,
];
$payment1 = Payment::createFromArray($payment1Array);
$this->assertSame($payment1Array['id'], $payment1->id);
$this->assertSame('', $payment1->provider);
$this->assertSame('', $payment1->description);
$this->assertSame(null, $payment1->vat_rate_id);
$this->assertSame($payment1Array['amount'], $payment1->amount);
$this->assertSame($payment1Array['amount'], $payment1->credit_amount);
$this->assertSame($payment1Array['currency_amount'], $payment1->currency_amount);
$this->assertSame($payment1Array['currency'], $payment1->currency);
$this->assertSame($payment1Array['type'], $payment1->type);
- $this->assertSame(PaymentProvider::STATUS_OPEN, $payment1->status);
+ $this->assertSame(Payment::STATUS_OPEN, $payment1->status);
$this->assertSame($payment1Array['wallet_id'], $payment1->wallet_id);
$this->assertCount(1, Payment::where('id', $payment1->id)->get());
// Test settable all properties
$payment2Array = [
'id' => 'test-payment',
'provider' => 'mollie',
'description' => 'payment description',
'vat_rate_id' => $vatRate->id,
'amount' => 10750,
'credit_amount' => 10000,
'currency' => $wallet->currency,
'currency_amount' => 10750,
- 'type' => PaymentProvider::TYPE_ONEOFF,
- 'status' => PaymentProvider::STATUS_OPEN,
+ 'type' => Payment::TYPE_ONEOFF,
+ 'status' => Payment::STATUS_OPEN,
'wallet_id' => $wallet->id,
];
$payment2 = Payment::createFromArray($payment2Array);
$this->assertSame($payment2Array['id'], $payment2->id);
$this->assertSame($payment2Array['provider'], $payment2->provider);
$this->assertSame($payment2Array['description'], $payment2->description);
$this->assertSame($payment2Array['vat_rate_id'], $payment2->vat_rate_id);
$this->assertSame($payment2Array['amount'], $payment2->amount);
$this->assertSame($payment2Array['credit_amount'], $payment2->credit_amount);
$this->assertSame($payment2Array['currency_amount'], $payment2->currency_amount);
$this->assertSame($payment2Array['currency'], $payment2->currency);
$this->assertSame($payment2Array['type'], $payment2->type);
$this->assertSame($payment2Array['status'], $payment2->status);
$this->assertSame($payment2Array['wallet_id'], $payment2->wallet_id);
$this->assertSame($vatRate->id, $payment2->vatRate->id);
$this->assertCount(1, Payment::where('id', $payment2->id)->get());
$refundArray = [
'id' => 'test-refund',
- 'type' => PaymentProvider::TYPE_CHARGEBACK,
+ 'type' => Payment::TYPE_CHARGEBACK,
'description' => 'test refund desc',
];
// Refund amount is required
$this->assertNull($payment2->refund($refundArray));
// All needed info
$refundArray['amount'] = 5000;
$refund = $payment2->refund($refundArray);
$this->assertSame($refundArray['id'], $refund->id);
$this->assertSame($refundArray['description'], $refund->description);
$this->assertSame(-5000, $refund->amount);
$this->assertSame(-4651, $refund->credit_amount);
$this->assertSame(-5000, $refund->currency_amount);
$this->assertSame($refundArray['type'], $refund->type);
- $this->assertSame(PaymentProvider::STATUS_PAID, $refund->status);
+ $this->assertSame(Payment::STATUS_PAID, $refund->status);
$this->assertSame($payment2->currency, $refund->currency);
$this->assertSame($payment2->provider, $refund->provider);
$this->assertSame($payment2->wallet_id, $refund->wallet_id);
$this->assertSame($payment2->vat_rate_id, $refund->vat_rate_id);
$wallet->refresh();
$this->assertSame(-4651, $wallet->balance);
$transaction = $wallet->transactions()->where('type', Transaction::WALLET_CHARGEBACK)->first();
$this->assertSame(-4651, $transaction->amount);
$this->assertSame($refundArray['description'], $transaction->description);
$wallet->balance = 0;
$wallet->save();
// Test non-wallet currency
$refundArray['id'] = 'test-refund-2';
$refundArray['amount'] = 9000;
- $refundArray['type'] = PaymentProvider::TYPE_REFUND;
+ $refundArray['type'] = Payment::TYPE_REFUND;
$refund = $payment1->refund($refundArray);
$this->assertSame($refundArray['id'], $refund->id);
$this->assertSame($refundArray['description'], $refund->description);
$this->assertSame(-10750, $refund->amount);
$this->assertSame(-10750, $refund->credit_amount);
$this->assertSame(-9000, $refund->currency_amount);
$this->assertSame($refundArray['type'], $refund->type);
- $this->assertSame(PaymentProvider::STATUS_PAID, $refund->status);
+ $this->assertSame(Payment::STATUS_PAID, $refund->status);
$this->assertSame($payment1->currency, $refund->currency);
$this->assertSame($payment1->provider, $refund->provider);
$this->assertSame($payment1->wallet_id, $refund->wallet_id);
$this->assertSame($payment1->vat_rate_id, $refund->vat_rate_id);
$wallet->refresh();
$this->assertSame(-10750, $wallet->balance);
$transaction = $wallet->transactions()->where('type', Transaction::WALLET_REFUND)->first();
$this->assertSame(-10750, $transaction->amount);
$this->assertSame($refundArray['description'], $transaction->description);
}
}
diff --git a/src/tests/Feature/Stories/RateLimitTest.php b/src/tests/Feature/Stories/RateLimitTest.php
index afcd29cb..d0d25baf 100644
--- a/src/tests/Feature/Stories/RateLimitTest.php
+++ b/src/tests/Feature/Stories/RateLimitTest.php
@@ -1,569 +1,570 @@
<?php
namespace Tests\Feature\Stories;
+use App\Payment;
use App\Policy\RateLimit;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* @group slow
* @group data
* @group ratelimit
*/
class RateLimitTest extends TestCase
{
public function setUp(): void
{
parent::setUp();
$this->setUpTest();
$this->useServicesUrl();
- \App\Payment::query()->delete();
+ Payment::query()->delete();
}
public function tearDown(): void
{
- \App\Payment::query()->delete();
+ Payment::query()->delete();
parent::tearDown();
}
/**
* Verify an individual can send an email unrestricted, so long as the account is active.
*/
public function testIndividualDunno()
{
$request = [
'sender' => $this->publicDomainUser->email,
'recipients' => [ 'someone@test.domain' ]
];
$response = $this->post('api/webhooks/policy/ratelimit', $request);
$response->assertStatus(200);
}
/**
* Verify a whitelisted individual account is in fact whitelisted
*/
public function testIndividualWhitelist()
{
\App\Policy\RateLimitWhitelist::create(
[
'whitelistable_id' => $this->publicDomainUser->id,
'whitelistable_type' => \App\User::class
]
);
$request = [
'sender' => $this->publicDomainUser->email,
'recipients' => []
];
// first 9 requests
for ($i = 1; $i <= 9; $i++) {
$request['recipients'] = [sprintf("%04d@test.domain", $i)];
$response = $this->post('api/webhooks/policy/ratelimit', $request);
$response->assertStatus(200);
}
// normally, request #10 would get blocked
$request['recipients'] = ['0010@test.domain'];
$response = $this->post('api/webhooks/policy/ratelimit', $request);
$response->assertStatus(200);
// requests 11 through 26
for ($i = 11; $i <= 26; $i++) {
$request['recipients'] = [sprintf("%04d@test.domain", $i)];
$response = $this->post('api/webhooks/policy/ratelimit', $request);
$response->assertStatus(200);
}
}
/**
* Verify an individual trial user is automatically suspended.
*/
public function testIndividualAutoSuspendMessages()
{
$request = [
'sender' => $this->publicDomainUser->email,
'recipients' => []
];
// first 9 requests
for ($i = 1; $i <= 9; $i++) {
$request['recipients'] = [sprintf("%04d@test.domain", $i)];
$response = $this->post('api/webhooks/policy/ratelimit', $request);
$response->assertStatus(200);
}
// the next 16 requests for 25 total
for ($i = 10; $i <= 25; $i++) {
$request['recipients'] = [sprintf("%04d@test.domain", $i)];
$response = $this->post('api/webhooks/policy/ratelimit', $request);
$response->assertStatus(403);
}
$this->assertTrue($this->publicDomainUser->fresh()->isSuspended());
}
/**
* Verify a suspended individual can not send an email
*/
public function testIndividualSuspended()
{
$this->publicDomainUser->suspend();
$request = [
'sender' => $this->publicDomainUser->email,
'recipients' => ['someone@test.domain']
];
$response = $this->post('api/webhooks/policy/ratelimit', $request);
$response->assertStatus(403);
}
/**
* Verify an individual can run out of messages per hour
*/
public function testIndividualTrialMessages()
{
$request = [
'sender' => $this->publicDomainUser->email,
'recipients' => []
];
// first 9 requests
for ($i = 1; $i <= 9; $i++) {
$request['recipients'] = [sprintf("%04d@test.domain", $i)];
$response = $this->post('api/webhooks/policy/ratelimit', $request);
$response->assertStatus(200);
}
// the tenth request should be blocked
$request['recipients'] = ['0010@test.domain'];
$response = $this->post('api/webhooks/policy/ratelimit', $request);
$response->assertStatus(403);
}
/**
* Verify a paid for individual account does not simply run out of messages
*/
public function testIndividualPaidMessages()
{
$wallet = $this->publicDomainUser->wallets()->first();
// Ensure there are no payments for the wallet
- \App\Payment::where('wallet_id', $wallet->id)->delete();
+ Payment::where('wallet_id', $wallet->id)->delete();
$payment = [
'id' => \App\Utils::uuidInt(),
- 'status' => \App\Providers\PaymentProvider::STATUS_PAID,
- 'type' => \App\Providers\PaymentProvider::TYPE_ONEOFF,
+ 'status' => Payment::STATUS_PAID,
+ 'type' => Payment::TYPE_ONEOFF,
'description' => 'Paid in March',
'wallet_id' => $wallet->id,
'provider' => 'stripe',
'amount' => 1111,
'credit_amount' => 1111,
'currency_amount' => 1111,
'currency' => 'CHF',
];
- \App\Payment::create($payment);
+ Payment::create($payment);
$wallet->credit(1111);
$request = [
'sender' => $this->publicDomainUser->email,
'recipients' => ['someone@test.domain']
];
// first 9 requests
for ($i = 1; $i <= 9; $i++) {
$request['recipients'] = [sprintf("%04d@test.domain", $i)];
$response = $this->post('api/webhooks/policy/ratelimit', $request);
$response->assertStatus(200);
}
// the tenth request should be blocked
$request['recipients'] = ['0010@test.domain'];
$response = $this->post('api/webhooks/policy/ratelimit', $request);
$response->assertStatus(403);
// create a second payment
$payment['id'] = \App\Utils::uuidInt();
- \App\Payment::create($payment);
+ Payment::create($payment);
$wallet->credit(1111);
// the tenth request should now be allowed
$response = $this->post('api/webhooks/policy/ratelimit', $request);
$response->assertStatus(200);
}
/**
* Verify that an individual user in its trial can run out of recipients.
*/
public function testIndividualTrialRecipients()
{
$request = [
'sender' => $this->publicDomainUser->email,
'recipients' => []
];
// first 2 requests (34 recipients each)
for ($x = 1; $x <= 2; $x++) {
$request['recipients'] = [];
for ($y = 1; $y <= 34; $y++) {
$request['recipients'][] = sprintf("%04d@test.domain", $x * $y);
}
$response = $this->post('api/webhooks/policy/ratelimit', $request);
$response->assertStatus(200);
}
// on to the third request, resulting in 102 recipients total
$request['recipients'] = [];
for ($y = 1; $y <= 34; $y++) {
$request['recipients'][] = sprintf("%04d@test.domain", 3 * $y);
}
$response = $this->post('api/webhooks/policy/ratelimit', $request);
$response->assertStatus(403);
}
/**
* Verify that an individual user that has paid for its account doesn't run out of recipients.
*/
public function testIndividualPaidRecipients()
{
$wallet = $this->publicDomainUser->wallets()->first();
// Ensure there are no payments for the wallet
- \App\Payment::where('wallet_id', $wallet->id)->delete();
+ Payment::where('wallet_id', $wallet->id)->delete();
$payment = [
'id' => \App\Utils::uuidInt(),
- 'status' => \App\Providers\PaymentProvider::STATUS_PAID,
- 'type' => \App\Providers\PaymentProvider::TYPE_ONEOFF,
+ 'status' => Payment::STATUS_PAID,
+ 'type' => Payment::TYPE_ONEOFF,
'description' => 'Paid in March',
'wallet_id' => $wallet->id,
'provider' => 'stripe',
'amount' => 1111,
'credit_amount' => 1111,
'currency_amount' => 1111,
'currency' => 'CHF',
];
- \App\Payment::create($payment);
+ Payment::create($payment);
$wallet->credit(1111);
$request = [
'sender' => $this->publicDomainUser->email,
'recipients' => []
];
// first 2 requests (34 recipients each)
for ($x = 0; $x < 2; $x++) {
$request['recipients'] = [];
for ($y = 0; $y < 34; $y++) {
$request['recipients'][] = sprintf("%04d@test.domain", $x * $y);
}
$response = $this->post('api/webhooks/policy/ratelimit', $request);
$response->assertStatus(200);
}
// on to the third request, resulting in 102 recipients total
$request['recipients'] = [];
for ($y = 0; $y < 34; $y++) {
$request['recipients'][] = sprintf("%04d@test.domain", 2 * $y);
}
$response = $this->post('api/webhooks/policy/ratelimit', $request);
$response->assertStatus(403);
$payment['id'] = \App\Utils::uuidInt();
- \App\Payment::create($payment);
+ Payment::create($payment);
$wallet->credit(1111);
// the tenth request should now be allowed
$response = $this->post('api/webhooks/policy/ratelimit', $request);
$response->assertStatus(200);
}
/**
* Verify that a group owner can send email
*/
public function testGroupOwnerDunno()
{
$request = [
'sender' => $this->domainOwner->email,
'recipients' => [ 'someone@test.domain' ]
];
$response = $this->post('api/webhooks/policy/ratelimit', $request);
$response->assertStatus(200);
}
/**
* Verify that a domain owner can run out of messages
*/
public function testGroupTrialOwnerMessages()
{
$request = [
'sender' => $this->domainOwner->email,
'recipients' => []
];
// first 9 requests
for ($i = 0; $i < 9; $i++) {
$request['recipients'] = [sprintf("%04d@test.domain", $i)];
$response = $this->post('api/webhooks/policy/ratelimit', $request);
$response->assertStatus(200);
}
// the tenth request should be blocked
$request['recipients'] = ['0010@test.domain'];
$response = $this->post('api/webhooks/policy/ratelimit', $request);
$response->assertStatus(403);
$this->assertFalse($this->domainOwner->fresh()->isSuspended());
}
/**
* Verify that a domain owner can run out of recipients
*/
public function testGroupTrialOwnerRecipients()
{
$request = [
'sender' => $this->domainOwner->email,
'recipients' => []
];
// first 2 requests (34 recipients each)
for ($x = 0; $x < 2; $x++) {
$request['recipients'] = [];
for ($y = 0; $y < 34; $y++) {
$request['recipients'][] = sprintf("%04d@test.domain", $x * $y);
}
$response = $this->post('api/webhooks/policy/ratelimit', $request);
$response->assertStatus(200);
}
// on to the third request, resulting in 102 recipients total
$request['recipients'] = [];
for ($y = 0; $y < 34; $y++) {
$request['recipients'][] = sprintf("%04d@test.domain", 2 * $y);
}
$response = $this->post('api/webhooks/policy/ratelimit', $request);
$response->assertStatus(403);
$this->assertFalse($this->domainOwner->fresh()->isSuspended());
}
/**
* Verify that a paid for group account can send messages.
*/
public function testGroupPaidOwnerRecipients()
{
$wallet = $this->domainOwner->wallets()->first();
// Ensure there are no payments for the wallet
- \App\Payment::where('wallet_id', $wallet->id)->delete();
+ Payment::where('wallet_id', $wallet->id)->delete();
$payment = [
'id' => \App\Utils::uuidInt(),
- 'status' => \App\Providers\PaymentProvider::STATUS_PAID,
- 'type' => \App\Providers\PaymentProvider::TYPE_ONEOFF,
+ 'status' => Payment::STATUS_PAID,
+ 'type' => Payment::TYPE_ONEOFF,
'description' => 'Paid in March',
'wallet_id' => $wallet->id,
'provider' => 'stripe',
'amount' => 1111,
'credit_amount' => 1111,
'currency_amount' => 1111,
'currency' => 'CHF',
];
- \App\Payment::create($payment);
+ Payment::create($payment);
$wallet->credit(1111);
$request = [
'sender' => $this->domainOwner->email,
'recipients' => []
];
// first 2 requests (34 recipients each)
for ($x = 0; $x < 2; $x++) {
$request['recipients'] = [];
for ($y = 0; $y < 34; $y++) {
$request['recipients'][] = sprintf("%04d@test.domain", $x * $y);
}
$response = $this->post('api/webhooks/policy/ratelimit', $request);
$response->assertStatus(200);
}
// on to the third request, resulting in 102 recipients total
$request['recipients'] = [];
for ($y = 0; $y < 34; $y++) {
$request['recipients'][] = sprintf("%04d@test.domain", 2 * $y);
}
$response = $this->post('api/webhooks/policy/ratelimit', $request);
$response->assertStatus(403);
// create a second payment
$payment['id'] = \App\Utils::uuidInt();
- \App\Payment::create($payment);
+ Payment::create($payment);
$response = $this->post('api/webhooks/policy/ratelimit', $request);
$response->assertStatus(200);
}
/**
* Verify that a user for a domain owner can send email.
*/
public function testGroupUserDunno()
{
$request = [
'sender' => $this->domainUsers[0]->email,
'recipients' => [ 'someone@test.domain' ]
];
$response = $this->post('api/webhooks/policy/ratelimit', $request);
$response->assertStatus(200);
}
/**
* Verify that the users in a group account can be limited.
*/
public function testGroupTrialUserMessages()
{
$user = $this->domainUsers[0];
$request = [
'sender' => $user->email,
'recipients' => []
];
// the first eight requests should be accepted
for ($i = 0; $i < 8; $i++) {
$request['recipients'] = [sprintf("%04d@test.domain", $i)];
$response = $this->post('api/webhooks/policy/ratelimit', $request);
$response->assertStatus(200);
}
$request['sender'] = $this->domainUsers[1]->email;
// the ninth request from another group user should also be accepted
$request['recipients'] = ['0009@test.domain'];
$response = $this->post('api/webhooks/policy/ratelimit', $request);
$response->assertStatus(200);
// the tenth request from another group user should be rejected
$request['recipients'] = ['0010@test.domain'];
$response = $this->post('api/webhooks/policy/ratelimit', $request);
$response->assertStatus(403);
}
public function testGroupTrialUserRecipients()
{
$request = [
'sender' => $this->domainUsers[0]->email,
'recipients' => []
];
// first 2 requests (34 recipients each)
for ($x = 0; $x < 2; $x++) {
$request['recipients'] = [];
for ($y = 0; $y < 34; $y++) {
$request['recipients'][] = sprintf("%04d@test.domain", $x * $y);
}
$response = $this->post('api/webhooks/policy/ratelimit', $request);
$response->assertStatus(200);
}
// on to the third request, resulting in 102 recipients total
$request['recipients'] = [];
for ($y = 0; $y < 34; $y++) {
$request['recipients'][] = sprintf("%04d@test.domain", 2 * $y);
}
$response = $this->post('api/webhooks/policy/ratelimit', $request);
$response->assertStatus(403);
}
/**
* Verify a whitelisted group domain is in fact whitelisted
*/
public function testGroupDomainWhitelist()
{
\App\Policy\RateLimitWhitelist::create(
[
'whitelistable_id' => $this->domainHosted->id,
'whitelistable_type' => \App\Domain::class
]
);
$request = [
'sender' => $this->domainUsers[0]->email,
'recipients' => []
];
// first 9 requests
for ($i = 1; $i <= 9; $i++) {
$request['recipients'] = [sprintf("%04d@test.domain", $i)];
$response = $this->post('api/webhooks/policy/ratelimit', $request);
$response->assertStatus(200);
}
// normally, request #10 would get blocked
$request['recipients'] = ['0010@test.domain'];
$response = $this->post('api/webhooks/policy/ratelimit', $request);
$response->assertStatus(200);
// requests 11 through 26
for ($i = 11; $i <= 26; $i++) {
$request['recipients'] = [sprintf("%04d@test.domain", $i)];
$response = $this->post('api/webhooks/policy/ratelimit', $request);
$response->assertStatus(200);
}
}
}

File Metadata

Mime Type
text/x-diff
Expires
Sun, Jan 19, 1:09 AM (17 h, 23 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
120049
Default Alt Text
(429 KB)

Event Timeline