diff --git a/src/app/Console/Commands/Data/Import/VatRatesCommand.php b/src/app/Console/Commands/Data/Import/VatRatesCommand.php
new file mode 100644
index 00000000..6d0b03b5
--- /dev/null
+++ b/src/app/Console/Commands/Data/Import/VatRatesCommand.php
@@ -0,0 +1,85 @@
+argument('file');
+ $date = $this->argument('date');
+
+ if (!preg_match('/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/', $date)) {
+ $this->error("Invalid start date");
+ return 1;
+ }
+
+ if (!file_exists($file)) {
+ $this->error("Invalid file location");
+ return 1;
+ }
+
+ $rates = json_decode(file_get_contents($file), true);
+
+ if (!is_array($rates) || empty($rates)) {
+ $this->error("Invalid or empty input data format");
+ return 1;
+ }
+
+ $date .= ' 00:00:00';
+
+ foreach ($rates as $country => $rate) {
+ if (is_string($country) && strlen($country)) {
+ if (strlen($country) != 2) {
+ $this->info("Invalid country code: {$country}");
+ continue;
+ }
+
+ if (!is_numeric($rate) || $rate < 0 || $rate > 100) {
+ $this->info("Invalid VAT rate for {$country}: {$rate}");
+ continue;
+ }
+
+ $existing = VatRate::where('country', $country)
+ ->where('start', '<=', $date)
+ ->limit(1)
+ ->first();
+
+ if (!$existing || $existing->rate != $rate) {
+ VatRate::create([
+ 'start' => $date,
+ 'rate' => $rate,
+ 'country' => strtoupper($country),
+ ]);
+
+ $this->info("Added {$country}:{$rate}");
+ continue;
+ }
+ }
+
+ $this->info("Skipped {$country}:{$rate}");
+ }
+ }
+}
diff --git a/src/app/Console/Commands/Scalpel/VatRate/CreateCommand.php b/src/app/Console/Commands/Scalpel/VatRate/CreateCommand.php
new file mode 100644
index 00000000..fc239478
--- /dev/null
+++ b/src/app/Console/Commands/Scalpel/VatRate/CreateCommand.php
@@ -0,0 +1,14 @@
+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) {
- $country = 'CH';
$customer = [
'id' => $this->wallet->owner->id,
'wallet_id' => $this->wallet->id,
'customer' => 'Freddie Krüger
7252 Westminster Lane
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();
- $country = $this->wallet->owner->getSetting('country');
-
$items = $this->wallet->payments()
->where('status', PaymentProvider::STATUS_PAID)
->where('updated_at', '>=', $start)
->where('updated_at', '<', $end)
->where('amount', '<>', 0)
->orderBy('updated_at')
->get();
}
- $vatRate = \config('app.vat.rate');
- $vatCountries = explode(',', \config('app.vat.countries'));
- $vatCountries = array_map('strtoupper', array_map('trim', $vatCountries));
-
- if (!$country || !in_array(strtoupper($country), $vatCountries)) {
- $vatRate = 0;
- }
-
+ $vatRate = 0;
$totalVat = 0;
- $total = 0;
- $items = $items->map(function ($item) use (&$total, &$totalVat, $appName, $vatRate) {
+ $total = 0; // excluding VAT
+
+ $items = $items->map(function ($item) use (&$total, &$totalVat, &$vatRate, $appName) {
$amount = $item->amount;
- if ($vatRate > 0) {
- $amount = round($amount * ((100 - $vatRate) / 100));
- $totalVat += $item->amount - $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) {
$description = \trans('documents.receipt-refund');
} elseif ($type == PaymentProvider::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", '
', 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", '
', 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('%s', $contact, $contact);
}
if ($logo && strpos($logo, '/') === false) {
$logo = "/themes/$theme/images/$logo";
}
return [
'logo' => $logo ? "" : '',
'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 d018bd86..ff37611d 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 @@
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(amount) as amount, wallets.currency")
+ ->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])
->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/Admin/WalletsController.php b/src/app/Http/Controllers/API/V4/Admin/WalletsController.php
index 205531cd..423c2e20 100644
--- a/src/app/Http/Controllers/API/V4/Admin/WalletsController.php
+++ b/src/app/Http/Controllers/API/V4/Admin/WalletsController.php
@@ -1,158 +1,146 @@
checkTenant($wallet->owner)) {
return $this->errorResponse(404);
}
$result = $wallet->toArray();
$result['discount'] = 0;
$result['discount_description'] = '';
if ($wallet->discount) {
$result['discount'] = $wallet->discount->discount;
$result['discount_description'] = $wallet->discount->description;
}
$result['mandate'] = PaymentsController::walletMandate($wallet);
$provider = PaymentProvider::factory($wallet);
$result['provider'] = $provider->name();
$result['providerLink'] = $provider->customerLink($wallet);
$result['notice'] = $this->getWalletNotice($wallet); // for resellers
return response()->json($result);
}
/**
* Award/penalize a wallet.
*
* @param \Illuminate\Http\Request $request The API request.
* @param string $id Wallet identifier
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function oneOff(Request $request, $id)
{
$wallet = Wallet::find($id);
$user = $this->guard()->user();
if (empty($wallet) || !$this->checkTenant($wallet->owner)) {
return $this->errorResponse(404);
}
// Check required fields
$v = Validator::make(
$request->all(),
[
'amount' => 'required|numeric',
'description' => 'required|string|max:1024',
]
);
if ($v->fails()) {
return response()->json(['status' => 'error', 'errors' => $v->errors()], 422);
}
$amount = (int) ($request->amount * 100);
- $type = $amount > 0 ? Transaction::WALLET_AWARD : Transaction::WALLET_PENALTY;
+ $method = $amount > 0 ? 'award' : 'penalty';
DB::beginTransaction();
- $wallet->balance += $amount;
- $wallet->save();
-
- Transaction::create(
- [
- 'user_email' => \App\Utils::userEmailOrNull(),
- 'object_id' => $wallet->id,
- 'object_type' => Wallet::class,
- 'type' => $type,
- 'amount' => $amount,
- 'description' => $request->description
- ]
- );
+ $wallet->{$method}(abs($amount), $request->description);
if ($user->role == 'reseller') {
if ($user->tenant && ($tenant_wallet = $user->tenant->wallet())) {
$desc = ($amount > 0 ? 'Awarded' : 'Penalized') . " user {$wallet->owner->email}";
- $method = $amount > 0 ? 'debit' : 'credit';
- $tenant_wallet->{$method}(abs($amount), $desc);
+ $tenant_method = $amount > 0 ? 'debit' : 'credit';
+ $tenant_wallet->{$tenant_method}(abs($amount), $desc);
}
}
DB::commit();
$response = [
'status' => 'success',
- 'message' => \trans("app.wallet-{$type}-success"),
+ 'message' => \trans("app.wallet-{$method}-success"),
'balance' => $wallet->balance
];
return response()->json($response);
}
/**
* Update wallet data.
*
* @param \Illuminate\Http\Request $request The API request.
* @param string $id Wallet identifier
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function update(Request $request, $id)
{
$wallet = Wallet::find($id);
if (empty($wallet) || !$this->checkTenant($wallet->owner)) {
return $this->errorResponse(404);
}
if (array_key_exists('discount', $request->input())) {
if (empty($request->discount)) {
$wallet->discount()->dissociate();
$wallet->save();
} elseif ($discount = Discount::withObjectTenantContext($wallet->owner)->find($request->discount)) {
$wallet->discount()->associate($discount);
$wallet->save();
}
}
$response = $wallet->toArray();
if ($wallet->discount) {
$response['discount'] = $wallet->discount->discount;
$response['discount_description'] = $wallet->discount->description;
}
$response['status'] = 'success';
$response['message'] = \trans('app.wallet-update-success');
return response()->json($response);
}
}
diff --git a/src/app/Http/Controllers/API/V4/PaymentsController.php b/src/app/Http/Controllers/API/V4/PaymentsController.php
index d6ba19d3..a471e7a9 100644
--- a/src/app/Http/Controllers/API/V4/PaymentsController.php
+++ b/src/app/Http/Controllers/API/V4/PaymentsController.php
@@ -1,487 +1,519 @@
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 + $amount < 0
) {
return ['amount' => \trans('validation.minamountdebt')];
}
if ($amount < PaymentProvider::MIN_AMOUNT) {
$min = $wallet->money(PaymentProvider::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);
$errors = ['amount' => \trans('validation.minamount', ['amount' => $min])];
return response()->json(['status' => 'error', 'errors' => $errors], 422);
}
$currency = $request->currency;
$request = [
'type' => PaymentProvider::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,
'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['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)
->whereIn('status', [
PaymentProvider::STATUS_OPEN,
PaymentProvider::STATUS_PENDING,
PaymentProvider::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)
->whereIn('status', [
PaymentProvider::STATUS_OPEN,
PaymentProvider::STATUS_PENDING,
PaymentProvider::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/Observers/EntitlementObserver.php b/src/app/Observers/EntitlementObserver.php
index 2025bca3..c6981403 100644
--- a/src/app/Observers/EntitlementObserver.php
+++ b/src/app/Observers/EntitlementObserver.php
@@ -1,192 +1,111 @@
wallet_id);
if (!$wallet || !$wallet->owner) {
return false;
}
$sku = \App\Sku::find($entitlement->sku_id);
if (!$sku) {
return false;
}
$result = $sku->handler_class::preReq($entitlement, $wallet->owner);
if (!$result) {
return false;
}
return true;
}
/**
* Handle the entitlement "created" event.
*
* @param \App\Entitlement $entitlement The entitlement.
*
* @return void
*/
public function created(Entitlement $entitlement)
{
$entitlement->entitleable->updated_at = Carbon::now();
$entitlement->entitleable->save();
$entitlement->createTransaction(\App\Transaction::ENTITLEMENT_CREATED);
// Update the user IMAP mailbox quota
if ($entitlement->sku->title == 'storage') {
\App\Jobs\User\UpdateJob::dispatch($entitlement->entitleable_id);
}
}
/**
* Handle the entitlement "deleted" event.
*
* @param \App\Entitlement $entitlement The entitlement.
*
* @return void
*/
public function deleted(Entitlement $entitlement)
{
if (!$entitlement->entitleable->trashed()) {
$entitlement->entitleable->updated_at = Carbon::now();
$entitlement->entitleable->save();
$entitlement->createTransaction(\App\Transaction::ENTITLEMENT_DELETED);
}
// Remove all configured 2FA methods from Roundcube database
if ($entitlement->sku->title == '2fa') {
// FIXME: Should that be an async job?
$sf = new \App\Auth\SecondFactor($entitlement->entitleable);
$sf->removeFactors();
}
// Update the user IMAP mailbox quota
if ($entitlement->sku->title == 'storage') {
\App\Jobs\User\UpdateJob::dispatch($entitlement->entitleable_id);
}
}
/**
* Handle the entitlement "deleting" event.
*
* @param \App\Entitlement $entitlement The entitlement.
*
* @return void
*/
public function deleting(Entitlement $entitlement)
{
- if ($entitlement->trashed()) {
- return;
- }
-
- // Start calculating the costs for the consumption of this entitlement if the
- // existing consumption spans >= 14 days.
- //
- // Effect is that anything's free for the first 14 days
- if ($entitlement->created_at >= Carbon::now()->subDays(14)) {
- return;
- }
-
- $owner = $entitlement->wallet->owner;
-
- if ($owner->isDegraded()) {
- return;
- }
-
- $now = Carbon::now();
-
- // Determine if we're still within the trial period
- $trial = $entitlement->wallet->trialInfo();
- if (
- !empty($trial)
- && $entitlement->updated_at < $trial['end']
- && in_array($entitlement->sku_id, $trial['skus'])
- ) {
- if ($trial['end'] >= $now) {
- return;
- }
-
- $entitlement->updated_at = $trial['end'];
- }
-
- // get the discount rate applied to the wallet.
- $discount = $entitlement->wallet->getDiscountRate();
-
- // just in case this had not been billed yet, ever
- $diffInMonths = $entitlement->updated_at->diffInMonths($now);
- $cost = (int) ($entitlement->cost * $discount * $diffInMonths);
- $fee = (int) ($entitlement->fee * $diffInMonths);
-
- // this moves the hypothetical updated at forward to however many months past the original
- $updatedAt = $entitlement->updated_at->copy()->addMonthsWithoutOverflow($diffInMonths);
-
- // now we have the diff in days since the last "billed" period end.
- // This may be an entitlement paid up until February 28th, 2020, with today being March
- // 12th 2020. Calculating the costs for the entitlement is based on the daily price
-
- // the price per day is based on the number of days in the last month
- // or the current month if the period does not overlap with the previous month
- // FIXME: This really should be simplified to $daysInMonth=30
-
- $diffInDays = $updatedAt->diffInDays($now);
-
- if ($now->day >= $diffInDays) {
- $daysInMonth = $now->daysInMonth;
- } else {
- $daysInMonth = \App\Utils::daysInLastMonth();
- }
-
- $pricePerDay = $entitlement->cost / $daysInMonth;
- $feePerDay = $entitlement->fee / $daysInMonth;
-
- $cost += (int) (round($pricePerDay * $discount * $diffInDays, 0));
- $fee += (int) (round($feePerDay * $diffInDays, 0));
-
- $profit = $cost - $fee;
-
- if ($profit != 0 && $owner->tenant && ($wallet = $owner->tenant->wallet())) {
- $desc = "Charged user {$owner->email}";
- $method = $profit > 0 ? 'credit' : 'debit';
- $wallet->{$method}(abs($profit), $desc);
- }
-
- if ($cost == 0) {
- return;
- }
-
- // FIXME: Shouldn't we create per-entitlement transaction record?
-
- $entitlement->wallet->debit($cost);
+ $entitlement->wallet->chargeEntitlement($entitlement);
}
}
diff --git a/src/app/Package.php b/src/app/Package.php
index 30c7c0a9..df90dd74 100644
--- a/src/app/Package.php
+++ b/src/app/Package.php
@@ -1,106 +1,107 @@
The attributes that are mass assignable */
protected $fillable = [
'description',
'discount_rate',
'name',
'title',
];
/** @var array Translatable properties */
public $translatable = [
'name',
'description',
];
/**
* The costs of this package at its pre-defined, existing configuration.
*
* @return int The costs in cents.
*/
public function cost()
{
$costs = 0;
foreach ($this->skus as $sku) {
$units = $sku->pivot->qty - $sku->units_free;
if ($units < 0) {
\Log::debug("Package {$this->id} is misconfigured for more free units than qty.");
$units = 0;
}
$ppu = $sku->cost * ((100 - $this->discount_rate) / 100);
$costs += $units * $ppu;
}
return $costs;
}
/**
* Checks whether the package contains a domain SKU.
*/
public function isDomain(): bool
{
foreach ($this->skus as $sku) {
if ($sku->handler_class::entitleableClass() == Domain::class) {
return true;
}
}
return false;
}
/**
* SKUs of this package.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function skus()
{
return $this->belongsToMany(Sku::class, 'package_skus')
->using(PackageSku::class)
->withPivot(['qty']);
}
}
diff --git a/src/app/Payment.php b/src/app/Payment.php
index 6c24b019..d6cd0723 100644
--- a/src/app/Payment.php
+++ b/src/app/Payment.php
@@ -1,59 +1,156 @@
The attributes that should be cast */
protected $casts = [
- 'amount' => 'integer'
+ 'amount' => 'integer',
+ 'credit_amount' => 'integer',
+ 'currency_amount' => 'integer',
];
/** @var array 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 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->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;
+ }
+
+ /**
+ * 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';
+
+ $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;
+
+ // 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 57093a51..26d063af 100644
--- a/src/app/Providers/Payment/Coinbase.php
+++ b/src/app/Providers/Payment/Coinbase.php
@@ -1,410 +1,410 @@
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) {
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;
//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->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;
// 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) {
$credit = true;
}
$newStatus = self::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;
}
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];
if (in_array($payment->status, $pending_states)) {
$payment->status = $newStatus;
$payment->save();
}
if (!empty($credit)) {
self::creditPayment($payment);
}
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->amount, $description);
+ $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) {
$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 5ecd1b48..9961feb2 100644
--- a/src/app/Providers/Payment/Mollie.php
+++ b/src/app/Providers/Payment/Mollie.php
@@ -1,640 +1,640 @@
tag
*/
public function customerLink(Wallet $wallet): ?string
{
$customer_id = self::mollieCustomerId($wallet, false);
if (!$customer_id) {
return null;
}
return sprintf(
'%s',
$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;
$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) {
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) {
$credit = true;
$notify = $payment->type == self::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,
'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,
'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
&& $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) {
$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];
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) {
- $this->storeRefund($payment->wallet, $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->amount, $description);
+ $payment->wallet->credit($payment, $description);
// Unlock the disabled auto-payment mandate
if ($payment->wallet->balance >= 0) {
$payment->wallet->setSetting('mandate_disabled', null);
}
}
/**
* 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 460b2777..f13efb50 100644
--- a/src/app/Providers/Payment/Stripe.php
+++ b/src/app/Providers/Payment/Stripe.php
@@ -1,559 +1,560 @@
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(
'%s',
$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;
$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) {
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) {
return 404;
}
switch ($intent->status) {
case StripeAPI\PaymentIntent::STATUS_CANCELED:
$status = self::STATUS_CANCELED;
break;
case StripeAPI\PaymentIntent::STATUS_SUCCEEDED:
$status = self::STATUS_PAID;
break;
default:
$status = self::STATUS_FAILED;
}
DB::beginTransaction();
if ($status == self::STATUS_PAID) {
// Update the balance, if it wasn't already
if ($payment->status != self::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) {
$payment->status = $status;
$payment->save();
if ($status != self::STATUS_CANCELED && $payment->type == self::TYPE_RECURRING) {
// Disable the mandate
if ($status == self::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) {
return 404;
}
switch ($intent->status) {
case StripeAPI\SetupIntent::STATUS_CANCELED:
$status = self::STATUS_CANCELED;
break;
case StripeAPI\SetupIntent::STATUS_SUCCEEDED:
$status = self::STATUS_PAID;
break;
default:
$status = self::STATUS_FAILED;
}
if ($status == self::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) {
\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->amount, $description);
+ $payment->wallet->credit($payment, $description);
// Unlock the disabled auto-payment mandate
if ($payment->wallet->balance >= 0) {
$payment->wallet->setSetting('mandate_disabled', null);
}
}
/**
* 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:
$availableMethods = [
self::METHOD_CREDITCARD => [
'id' => self::METHOD_CREDITCARD,
'name' => "Credit Card",
'minimumAmount' => self::MIN_AMOUNT,
'currency' => $currency,
'exchangeRate' => 1.0
],
self::METHOD_PAYPAL => [
'id' => self::METHOD_PAYPAL,
'name' => "PayPal",
'minimumAmount' => self::MIN_AMOUNT,
'currency' => $currency,
'exchangeRate' => 1.0
]
];
break;
case self::TYPE_RECURRING:
$availableMethods = [
self::METHOD_CREDITCARD => [
'id' => self::METHOD_CREDITCARD,
'name' => "Credit Card",
'minimumAmount' => self::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 9665c5ef..d85fb049 100644
--- a/src/app/Providers/PaymentProvider.php
+++ b/src/app/Providers/PaymentProvider.php
@@ -1,398 +1,346 @@
['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
+ * - 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 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
+ * - 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
{
- $db_payment = new Payment();
- $db_payment->id = $payment['id'];
- $db_payment->description = $payment['description'] ?? '';
- $db_payment->status = $payment['status'] ?? self::STATUS_OPEN;
- $db_payment->amount = $payment['amount'] ?? 0;
- $db_payment->type = $payment['type'];
- $db_payment->wallet_id = $wallet_id;
- $db_payment->provider = $this->name();
- $db_payment->currency = $payment['currency'];
- $db_payment->currency_amount = $payment['currency_amount'];
- $db_payment->save();
-
- return $db_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)));
}
- /**
- * Deduct an amount of pecunia from the wallet.
- * Creates a payment and transaction records for the refund/chargeback operation.
- *
- * @param \App\Wallet $wallet A wallet object
- * @param array $refund A refund or chargeback data (id, type, amount, description)
- *
- * @return void
- */
- protected function storeRefund(Wallet $wallet, array $refund): void
- {
- if (empty($refund) || empty($refund['amount'])) {
- return;
- }
-
- // Preserve originally refunded amount
- $refund['currency_amount'] = $refund['amount'] * -1;
-
- // Convert amount to wallet currency
- // TODO We should possibly be using the same exchange rate as for the original payment?
- $amount = $this->exchange($refund['amount'], $refund['currency'], $wallet->currency);
-
- $wallet->balance -= $amount;
- $wallet->save();
-
- if ($refund['type'] == self::TYPE_CHARGEBACK) {
- $transaction_type = Transaction::WALLET_CHARGEBACK;
- } else {
- $transaction_type = Transaction::WALLET_REFUND;
- }
-
- Transaction::create([
- 'object_id' => $wallet->id,
- 'object_type' => Wallet::class,
- 'type' => $transaction_type,
- 'amount' => $amount * -1,
- 'description' => $refund['description'] ?? '',
- ]);
-
- $refund['status'] = self::STATUS_PAID;
- $refund['amount'] = -1 * $amount;
-
- // FIXME: Refunds/chargebacks are out of the reseller comissioning for now
-
- $this->storePayment($refund, $wallet->id);
- }
-
/**
* 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:
$methods = explode(',', \config('app.payment.methods_oneoff'));
break;
case PaymentProvider::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/app/User.php b/src/app/User.php
index 3d840c99..f13d03f4 100644
--- a/src/app/User.php
+++ b/src/app/User.php
@@ -1,850 +1,850 @@
The attributes that are mass assignable */
protected $fillable = [
'id',
'email',
'password',
'password_ldap',
'status',
];
/** @var array The attributes that should be hidden for arrays */
protected $hidden = [
'password',
'password_ldap',
'role'
];
/** @var array The attributes that can be null */
protected $nullable = [
'password',
'password_ldap'
];
/** @var array The attributes that should be cast */
protected $casts = [
'created_at' => 'datetime:Y-m-d H:i:s',
'deleted_at' => 'datetime:Y-m-d H:i:s',
'updated_at' => 'datetime:Y-m-d H:i:s',
];
/**
* Any wallets on which this user is a controller.
*
* This does not include wallets owned by the user.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function accounts()
{
return $this->belongsToMany(
Wallet::class, // The foreign object definition
'user_accounts', // The table name
'user_id', // The local foreign key
'wallet_id' // The remote foreign key
);
}
/**
* Assign a package to a user. The user should not have any existing entitlements.
*
* @param \App\Package $package The package to assign.
* @param \App\User|null $user Assign the package to another user.
*
* @return \App\User
*/
public function assignPackage($package, $user = null)
{
if (!$user) {
$user = $this;
}
return $user->assignPackageAndWallet($package, $this->wallets()->first());
}
/**
* Assign a package plan to a user.
*
* @param \App\Plan $plan The plan to assign
* @param \App\Domain $domain Optional domain object
*
* @return \App\User Self
*/
public function assignPlan($plan, $domain = null): User
{
$this->setSetting('plan_id', $plan->id);
foreach ($plan->packages as $package) {
if ($package->isDomain()) {
$domain->assignPackage($package, $this);
} else {
$this->assignPackage($package);
}
}
return $this;
}
/**
* Check if current user can delete another object.
*
* @param mixed $object A user|domain|wallet|group object
*
* @return bool True if he can, False otherwise
*/
public function canDelete($object): bool
{
if (!is_object($object) || !method_exists($object, 'wallet')) {
return false;
}
$wallet = $object->wallet();
// TODO: For now controller can delete/update the account owner,
// this may change in future, controllers are not 0-regression feature
return $wallet && ($wallet->user_id == $this->id || $this->accounts->contains($wallet));
}
/**
* Check if current user can read data of another object.
*
* @param mixed $object A user|domain|wallet|group object
*
* @return bool True if he can, False otherwise
*/
public function canRead($object): bool
{
if ($this->role == 'admin') {
return true;
}
if ($object instanceof User && $this->id == $object->id) {
return true;
}
if ($this->role == 'reseller') {
if ($object instanceof User && $object->role == 'admin') {
return false;
}
if ($object instanceof Wallet && !empty($object->owner)) {
$object = $object->owner;
}
return isset($object->tenant_id) && $object->tenant_id == $this->tenant_id;
}
if ($object instanceof Wallet) {
return $object->user_id == $this->id || $object->controllers->contains($this);
}
if (!method_exists($object, 'wallet')) {
return false;
}
$wallet = $object->wallet();
return $wallet && ($wallet->user_id == $this->id || $this->accounts->contains($wallet));
}
/**
* Check if current user can update data of another object.
*
* @param mixed $object A user|domain|wallet|group object
*
* @return bool True if he can, False otherwise
*/
public function canUpdate($object): bool
{
if ($object instanceof User && $this->id == $object->id) {
return true;
}
if ($this->role == 'admin') {
return true;
}
if ($this->role == 'reseller') {
if ($object instanceof User && $object->role == 'admin') {
return false;
}
if ($object instanceof Wallet && !empty($object->owner)) {
$object = $object->owner;
}
return isset($object->tenant_id) && $object->tenant_id == $this->tenant_id;
}
return $this->canDelete($object);
}
/**
* Degrade the user
*
* @return void
*/
public function degrade(): void
{
if ($this->isDegraded()) {
return;
}
$this->status |= User::STATUS_DEGRADED;
$this->save();
}
/**
* List the domains to which this user is entitled.
*
* @param bool $with_accounts Include domains assigned to wallets
* the current user controls but not owns.
* @param bool $with_public Include active public domains (for the user tenant).
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
public function domains($with_accounts = true, $with_public = true)
{
$domains = $this->entitleables(Domain::class, $with_accounts);
if ($with_public) {
$domains->orWhere(function ($query) {
if (!$this->tenant_id) {
$query->where('tenant_id', $this->tenant_id);
} else {
$query->withEnvTenantContext();
}
$query->where('domains.type', '&', Domain::TYPE_PUBLIC)
->where('domains.status', '&', Domain::STATUS_ACTIVE);
});
}
return $domains;
}
/**
* Return entitleable objects of a specified type controlled by the current user.
*
* @param string $class Object class
* @param bool $with_accounts Include objects assigned to wallets
* the current user controls, but not owns.
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
private function entitleables(string $class, bool $with_accounts = true)
{
$wallets = $this->wallets()->pluck('id')->all();
if ($with_accounts) {
$wallets = array_merge($wallets, $this->accounts()->pluck('wallet_id')->all());
}
$object = new $class();
$table = $object->getTable();
return $object->select("{$table}.*")
->whereExists(function ($query) use ($table, $wallets, $class) {
$query->select(DB::raw(1))
->from('entitlements')
->whereColumn('entitleable_id', "{$table}.id")
->whereIn('entitlements.wallet_id', $wallets)
->where('entitlements.entitleable_type', $class);
});
}
/**
* Helper to find user by email address, whether it is
* main email address, alias or an external email.
*
* If there's more than one alias NULL will be returned.
*
* @param string $email Email address
* @param bool $external Search also for an external email
*
* @return \App\User|null User model object if found
*/
public static function findByEmail(string $email, bool $external = false): ?User
{
if (strpos($email, '@') === false) {
return null;
}
$email = \strtolower($email);
$user = self::where('email', $email)->first();
if ($user) {
return $user;
}
$aliases = UserAlias::where('alias', $email)->get();
if (count($aliases) == 1) {
return $aliases->first()->user;
}
// TODO: External email
return null;
}
/**
* Storage items for this user.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function fsItems()
{
return $this->hasMany(Fs\Item::class);
}
/**
* Return groups controlled by the current user.
*
* @param bool $with_accounts Include groups assigned to wallets
* the current user controls but not owns.
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
public function groups($with_accounts = true)
{
return $this->entitleables(Group::class, $with_accounts);
}
/**
* Returns whether this user (or its wallet owner) is degraded.
*
* @param bool $owner Check also the wallet owner instead just the user himself
*
* @return bool
*/
public function isDegraded(bool $owner = false): bool
{
if ($this->status & self::STATUS_DEGRADED) {
return true;
}
if ($owner && ($wallet = $this->wallet())) {
return $wallet->owner && $wallet->owner->isDegraded();
}
return false;
}
/**
* Returns whether this user is restricted.
*
* @return bool
*/
public function isRestricted(): bool
{
return ($this->status & self::STATUS_RESTRICTED) > 0;
}
/**
* A shortcut to get the user name.
*
* @param bool $fallback Return " User" if there's no name
*
* @return string Full user name
*/
public function name(bool $fallback = false): string
{
$settings = $this->getSettings(['first_name', 'last_name']);
$name = trim($settings['first_name'] . ' ' . $settings['last_name']);
if (empty($name) && $fallback) {
return trim(\trans('app.siteuser', ['site' => Tenant::getConfig($this->tenant_id, 'app.name')]));
}
return $name;
}
/**
* Old passwords for this user.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function passwords()
{
return $this->hasMany(UserPassword::class);
}
/**
* Restrict this user.
*
* @return void
*/
public function restrict(): void
{
if ($this->isRestricted()) {
return;
}
$this->status |= User::STATUS_RESTRICTED;
$this->save();
}
/**
* Return resources controlled by the current user.
*
* @param bool $with_accounts Include resources assigned to wallets
* the current user controls but not owns.
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
public function resources($with_accounts = true)
{
return $this->entitleables(Resource::class, $with_accounts);
}
/**
* Return rooms controlled by the current user.
*
* @param bool $with_accounts Include rooms assigned to wallets
* the current user controls but not owns.
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
public function rooms($with_accounts = true)
{
return $this->entitleables(Meet\Room::class, $with_accounts);
}
/**
* Return shared folders controlled by the current user.
*
* @param bool $with_accounts Include folders assigned to wallets
* the current user controls but not owns.
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
public function sharedFolders($with_accounts = true)
{
return $this->entitleables(SharedFolder::class, $with_accounts);
}
public function senderPolicyFrameworkWhitelist($clientName)
{
$setting = $this->getSetting('spf_whitelist');
if (!$setting) {
return false;
}
$whitelist = json_decode($setting);
$matchFound = false;
foreach ($whitelist as $entry) {
if (substr($entry, 0, 1) == '/') {
$match = preg_match($entry, $clientName);
if ($match) {
$matchFound = true;
}
continue;
}
if (substr($entry, 0, 1) == '.') {
if (substr($clientName, (-1 * strlen($entry))) == $entry) {
$matchFound = true;
}
continue;
}
if ($entry == $clientName) {
$matchFound = true;
continue;
}
}
return $matchFound;
}
/**
* Un-degrade this user.
*
* @return void
*/
public function undegrade(): void
{
if (!$this->isDegraded()) {
return;
}
$this->status ^= User::STATUS_DEGRADED;
$this->save();
}
/**
* Un-restrict this user.
*
* @return void
*/
public function unrestrict(): void
{
if (!$this->isRestricted()) {
return;
}
$this->status ^= User::STATUS_RESTRICTED;
$this->save();
}
/**
* Return users controlled by the current user.
*
* @param bool $with_accounts Include users assigned to wallets
* the current user controls but not owns.
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
public function users($with_accounts = true)
{
return $this->entitleables(User::class, $with_accounts);
}
/**
* Verification codes for this user.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function verificationcodes()
{
return $this->hasMany(VerificationCode::class, 'user_id', 'id');
}
/**
* Wallets this user owns.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function wallets()
{
return $this->hasMany(Wallet::class);
}
/**
* User password mutator
*
* @param string $password The password in plain text.
*
* @return void
*/
public function setPasswordAttribute($password)
{
if (!empty($password)) {
$this->attributes['password'] = Hash::make($password);
$this->attributes['password_ldap'] = '{SSHA512}' . base64_encode(
pack('H*', hash('sha512', $password))
);
}
}
/**
* User LDAP password mutator
*
* @param string $password The password in plain text.
*
* @return void
*/
public function setPasswordLdapAttribute($password)
{
$this->setPasswordAttribute($password);
}
/**
* User status mutator
*
* @throws \Exception
*/
public function setStatusAttribute($status)
{
$new_status = 0;
$allowed_values = [
self::STATUS_NEW,
self::STATUS_ACTIVE,
self::STATUS_SUSPENDED,
self::STATUS_DELETED,
self::STATUS_LDAP_READY,
self::STATUS_IMAP_READY,
self::STATUS_DEGRADED,
self::STATUS_RESTRICTED,
];
foreach ($allowed_values as $value) {
if ($status & $value) {
$new_status |= $value;
$status ^= $value;
}
}
if ($status > 0) {
throw new \Exception("Invalid user status: {$status}");
}
$this->attributes['status'] = $new_status;
}
/**
* Validate the user credentials
*
* @param string $username The username.
* @param string $password The password in plain text.
* @param bool $updatePassword Store the password if currently empty
*
* @return bool true on success
*/
public function validateCredentials(string $username, string $password, bool $updatePassword = true): bool
{
$authenticated = false;
if ($this->email === \strtolower($username)) {
if (!empty($this->password)) {
if (Hash::check($password, $this->password)) {
$authenticated = true;
}
} elseif (!empty($this->password_ldap)) {
if (substr($this->password_ldap, 0, 6) == "{SSHA}") {
$salt = substr(base64_decode(substr($this->password_ldap, 6)), 20);
$hash = '{SSHA}' . base64_encode(
sha1($password . $salt, true) . $salt
);
if ($hash == $this->password_ldap) {
$authenticated = true;
}
} elseif (substr($this->password_ldap, 0, 9) == "{SSHA512}") {
$salt = substr(base64_decode(substr($this->password_ldap, 9)), 64);
$hash = '{SSHA512}' . base64_encode(
pack('H*', hash('sha512', $password . $salt)) . $salt
);
if ($hash == $this->password_ldap) {
$authenticated = true;
}
}
} else {
\Log::error("Incomplete credentials for {$this->email}");
}
}
if ($authenticated) {
// TODO: update last login time
if ($updatePassword && (empty($this->password) || empty($this->password_ldap))) {
$this->password = $password;
$this->save();
}
}
return $authenticated;
}
/**
* Validate request location regarding geo-lockin
*
* @param string $ip IP address to check, usually request()->ip()
*
* @return bool
*/
public function validateLocation($ip): bool
{
$countryCodes = json_decode($this->getSetting('limit_geo', "[]"));
if (empty($countryCodes)) {
return true;
}
return in_array(\App\Utils::countryForIP($ip), $countryCodes);
}
/**
* Check if multi factor verification is enabled
*
* @return bool
*/
public function mfaEnabled(): bool
{
return \App\CompanionApp::where('user_id', $this->id)
->where('mfa_enabled', true)
->exists();
}
/**
* Retrieve and authenticate a user
*
* @param string $username The username
* @param string $password The password in plain text
* @param ?string $clientIP The IP address of the client
*
* @return array ['user', 'reason', 'errorMessage']
*/
public static function findAndAuthenticate($username, $password, $clientIP = null, $verifyMFA = true): array
{
$error = null;
if (!$clientIP) {
$clientIP = request()->ip();
}
$user = User::where('email', $username)->first();
if (!$user) {
$error = AuthAttempt::REASON_NOTFOUND;
}
// Check user password
if (!$error && !$user->validateCredentials($username, $password)) {
$error = AuthAttempt::REASON_PASSWORD;
}
if ($verifyMFA) {
// Check user (request) location
if (!$error && !$user->validateLocation($clientIP)) {
$error = AuthAttempt::REASON_GEOLOCATION;
}
// Check 2FA
if (!$error) {
try {
(new \App\Auth\SecondFactor($user))->validate(request()->secondfactor);
} catch (\Exception $e) {
$error = AuthAttempt::REASON_2FA_GENERIC;
$message = $e->getMessage();
}
}
// Check 2FA - Companion App
if (!$error && $user->mfaEnabled()) {
- $attempt = \App\AuthAttempt::recordAuthAttempt($user, $clientIP);
+ $attempt = AuthAttempt::recordAuthAttempt($user, $clientIP);
if (!$attempt->waitFor2FA()) {
$error = AuthAttempt::REASON_2FA;
}
}
}
if ($error) {
if ($user && empty($attempt)) {
- $attempt = \App\AuthAttempt::recordAuthAttempt($user, $clientIP);
+ $attempt = AuthAttempt::recordAuthAttempt($user, $clientIP);
if (!$attempt->isAccepted()) {
$attempt->deny($error);
$attempt->save();
$attempt->notify();
}
}
if ($user) {
\Log::info("Authentication failed for {$user->email}");
}
return ['reason' => $error, 'errorMessage' => $message ?? \trans("auth.error.{$error}")];
}
\Log::info("Successful authentication for {$user->email}");
return ['user' => $user];
}
/**
* Hook for passport
*
* @throws \Throwable
*
* @return \App\User User model object if found
*/
public static function findAndValidateForPassport($username, $password): User
{
$verifyMFA = true;
if (request()->scope == "mfa") {
\Log::info("Not validating MFA because this is a request for an mfa scope.");
// Don't verify MFA if this is only an mfa token.
// If we didn't do this, we couldn't pair backup devices.
$verifyMFA = false;
}
$result = self::findAndAuthenticate($username, $password, null, $verifyMFA);
if (isset($result['reason'])) {
if ($result['reason'] == AuthAttempt::REASON_2FA_GENERIC) {
// This results in a json response of {'error': 'secondfactor', 'error_description': '$errorMessage'}
throw new OAuthServerException($result['errorMessage'], 6, 'secondfactor', 401);
}
// TODO: Display specific error message if 2FA via Companion App was expected?
throw OAuthServerException::invalidCredentials();
}
return $result['user'];
}
}
diff --git a/src/app/VatRate.php b/src/app/VatRate.php
new file mode 100644
index 00000000..898e90db
--- /dev/null
+++ b/src/app/VatRate.php
@@ -0,0 +1,35 @@
+ The attributes that should be cast */
+ protected $casts = [
+ 'start' => 'datetime:Y-m-d H:i:s',
+ 'rate' => 'float'
+ ];
+
+ /** @var array The attributes that are mass assignable */
+ protected $fillable = [
+ 'country',
+ 'rate',
+ 'start',
+ ];
+
+ /** @var bool Indicates if the model should be timestamped. */
+ public $timestamps = false;
+}
diff --git a/src/app/Wallet.php b/src/app/Wallet.php
index 1ee2e38f..9554a5a0 100644
--- a/src/app/Wallet.php
+++ b/src/app/Wallet.php
@@ -1,559 +1,733 @@
0,
];
/** @var array The attributes that are mass assignable */
protected $fillable = [
'currency',
'description'
];
/** @var array The attributes that can be not set */
protected $nullable = [
'description',
];
/** @var array The types of attributes to which its values will be cast */
protected $casts = [
'balance' => 'integer',
];
/**
* Add a controller to this wallet.
*
* @param \App\User $user The user to add as a controller to this wallet.
*
* @return void
*/
public function addController(User $user)
{
if (!$this->controllers->contains($user)) {
$this->controllers()->save($user);
}
}
+ /**
+ * Add an award to this wallet's balance.
+ *
+ * @param int|\App\Payment $amount The amount of award (in cents) or Payment object
+ * @param string $description The transaction description
+ *
+ * @return Wallet Self
+ */
+ public function award(int|Payment $amount, string $description = ''): Wallet
+ {
+ return $this->balanceUpdate(Transaction::WALLET_AWARD, $amount, $description);
+ }
+
+ /**
+ * Charge a specific entitlement (for use on entitlement delete).
+ *
+ * @param \App\Entitlement $entitlement The entitlement.
+ */
+ public function chargeEntitlement(Entitlement $entitlement): void
+ {
+ // Sanity checks
+ if ($entitlement->trashed() || $entitlement->wallet->id != $this->id || !$this->owner) {
+ return;
+ }
+
+ // Start calculating the costs for the consumption of this entitlement if the
+ // existing consumption spans >= 14 days.
+ //
+ // Effect is that anything's free for the first 14 days
+ if ($entitlement->created_at >= Carbon::now()->subDays(14)) {
+ return;
+ }
+
+ if ($this->owner->isDegraded()) {
+ return;
+ }
+
+ $now = Carbon::now();
+
+ // Determine if we're still within the trial period
+ $trial = $this->trialInfo();
+ if (
+ !empty($trial)
+ && $entitlement->updated_at < $trial['end']
+ && in_array($entitlement->sku_id, $trial['skus'])
+ ) {
+ if ($trial['end'] >= $now) {
+ return;
+ }
+
+ $entitlement->updated_at = $trial['end'];
+ }
+
+ // get the discount rate applied to the wallet.
+ $discount = $this->getDiscountRate();
+
+ // just in case this had not been billed yet, ever
+ $diffInMonths = $entitlement->updated_at->diffInMonths($now);
+ $cost = (int) ($entitlement->cost * $discount * $diffInMonths);
+ $fee = (int) ($entitlement->fee * $diffInMonths);
+
+ // this moves the hypothetical updated at forward to however many months past the original
+ $updatedAt = $entitlement->updated_at->copy()->addMonthsWithoutOverflow($diffInMonths);
+
+ // now we have the diff in days since the last "billed" period end.
+ // This may be an entitlement paid up until February 28th, 2020, with today being March
+ // 12th 2020. Calculating the costs for the entitlement is based on the daily price
+
+ // the price per day is based on the number of days in the last month
+ // or the current month if the period does not overlap with the previous month
+ // FIXME: This really should be simplified to $daysInMonth=30
+
+ $diffInDays = $updatedAt->diffInDays($now);
+
+ if ($now->day >= $diffInDays) {
+ $daysInMonth = $now->daysInMonth;
+ } else {
+ $daysInMonth = \App\Utils::daysInLastMonth();
+ }
+
+ $pricePerDay = $entitlement->cost / $daysInMonth;
+ $feePerDay = $entitlement->fee / $daysInMonth;
+
+ $cost += (int) (round($pricePerDay * $discount * $diffInDays, 0));
+ $fee += (int) (round($feePerDay * $diffInDays, 0));
+
+ $profit = $cost - $fee;
+
+ if ($profit != 0 && $this->owner->tenant && ($wallet = $this->owner->tenant->wallet())) {
+ $desc = "Charged user {$this->owner->email}";
+ $method = $profit > 0 ? 'credit' : 'debit';
+ $wallet->{$method}(abs($profit), $desc);
+ }
+
+ if ($cost == 0) {
+ return;
+ }
+
+ // TODO: Create per-entitlement transaction record?
+
+ $this->debit($cost);
+ }
+
/**
* Charge entitlements in the wallet
*
* @param bool $apply Set to false for a dry-run mode
*
* @return int Charged amount in cents
*/
public function chargeEntitlements($apply = true): int
{
$transactions = [];
$profit = 0;
$charges = 0;
$discount = $this->getDiscountRate();
$isDegraded = $this->owner->isDegraded();
$trial = $this->trialInfo();
if ($apply) {
DB::beginTransaction();
}
// Get all entitlements...
$entitlements = $this->entitlements()
// Skip entitlements created less than or equal to 14 days ago (this is at
// maximum the fourteenth 24-hour period).
// ->where('created_at', '<=', Carbon::now()->subDays(14))
// Skip entitlements created, or billed last, less than a month ago.
->where('updated_at', '<=', Carbon::now()->subMonthsWithoutOverflow(1))
->get();
foreach ($entitlements as $entitlement) {
// If in trial, move entitlement's updated_at timestamps forward to the trial end.
if (
!empty($trial)
&& $entitlement->updated_at < $trial['end']
&& in_array($entitlement->sku_id, $trial['skus'])
) {
// TODO: Consider not updating the updated_at to a future date, i.e. bump it
// as many months as possible, but not into the future
// if we're in dry-run, you know...
if ($apply) {
$entitlement->updated_at = $trial['end'];
$entitlement->save();
}
continue;
}
$diff = $entitlement->updated_at->diffInMonths(Carbon::now());
if ($diff <= 0) {
continue;
}
$cost = (int) ($entitlement->cost * $discount * $diff);
$fee = (int) ($entitlement->fee * $diff);
if ($isDegraded) {
$cost = 0;
}
$charges += $cost;
$profit += $cost - $fee;
// if we're in dry-run, you know...
if (!$apply) {
continue;
}
$entitlement->updated_at = $entitlement->updated_at->copy()->addMonthsWithoutOverflow($diff);
$entitlement->save();
if ($cost == 0) {
continue;
}
$transactions[] = $entitlement->createTransaction(Transaction::ENTITLEMENT_BILLED, $cost);
}
if ($apply) {
$this->debit($charges, '', $transactions);
// Credit/debit the reseller
if ($profit != 0 && $this->owner->tenant) {
// FIXME: Should we have a simpler way to skip this for non-reseller tenant(s)
if ($wallet = $this->owner->tenant->wallet()) {
$desc = "Charged user {$this->owner->email}";
$method = $profit > 0 ? 'credit' : 'debit';
$wallet->{$method}(abs($profit), $desc);
}
}
DB::commit();
}
return $charges;
}
/**
* Calculate for how long the current balance will last.
*
* Returns NULL for balance < 0 or discount = 100% or on a fresh account
*
* @return \Carbon\Carbon|null Date
*/
public function balanceLastsUntil()
{
if ($this->balance < 0 || $this->getDiscount() == 100) {
return null;
}
$balance = $this->balance;
$discount = $this->getDiscountRate();
$trial = $this->trialInfo();
// Get all entitlements...
$entitlements = $this->entitlements()->orderBy('updated_at')->get()
->filter(function ($entitlement) {
return $entitlement->cost > 0;
})
->map(function ($entitlement) {
return [
'date' => $entitlement->updated_at ?: $entitlement->created_at,
'cost' => $entitlement->cost,
'sku_id' => $entitlement->sku_id,
];
})
->all();
$max = 12 * 25;
while ($max > 0) {
foreach ($entitlements as &$entitlement) {
$until = $entitlement['date'] = $entitlement['date']->addMonthsWithoutOverflow(1);
if (
!empty($trial)
&& $entitlement['date'] < $trial['end']
&& in_array($entitlement['sku_id'], $trial['skus'])
) {
continue;
}
$balance -= (int) ($entitlement['cost'] * $discount);
if ($balance < 0) {
break 2;
}
}
$max--;
}
if (empty($until)) {
return null;
}
// Don't return dates from the past
if ($until <= Carbon::now() && !$until->isToday()) {
return null;
}
return $until;
}
+ /**
+ * Chargeback an amount of pecunia from this wallet's balance.
+ *
+ * @param int|\App\Payment $amount The amount of pecunia to charge back (in cents) or Payment object
+ * @param string $description The transaction description
+ *
+ * @return Wallet Self
+ */
+ public function chargeback(int|Payment $amount, string $description = ''): Wallet
+ {
+ return $this->balanceUpdate(Transaction::WALLET_CHARGEBACK, $amount, $description);
+ }
+
/**
* Controllers of this wallet.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function controllers()
{
return $this->belongsToMany(
User::class, // The foreign object definition
'user_accounts', // The table name
'wallet_id', // The local foreign key
'user_id' // The remote foreign key
);
}
/**
* Add an amount of pecunia to this wallet's balance.
*
- * @param int $amount The amount of pecunia to add (in cents).
- * @param string $description The transaction description
+ * @param int|\App\Payment $amount The amount of pecunia to add (in cents) or Payment object
+ * @param string $description The transaction description
*
* @return Wallet Self
*/
- public function credit(int $amount, string $description = ''): Wallet
+ public function credit(int|Payment $amount, string $description = ''): Wallet
{
- $this->balance += $amount;
-
- $this->save();
-
- Transaction::create(
- [
- 'object_id' => $this->id,
- 'object_type' => Wallet::class,
- 'type' => Transaction::WALLET_CREDIT,
- 'amount' => $amount,
- 'description' => $description
- ]
- );
-
- return $this;
+ return $this->balanceUpdate(Transaction::WALLET_CREDIT, $amount, $description);
}
/**
* Deduct an amount of pecunia from this wallet's balance.
*
- * @param int $amount The amount of pecunia to deduct (in cents).
- * @param string $description The transaction description
- * @param array $eTIDs List of transaction IDs for the individual entitlements
- * that make up this debit record, if any.
+ * @param int|\App\Payment $amount The amount of pecunia to deduct (in cents) or Payment object
+ * @param string $description The transaction description
+ * @param array $eTIDs List of transaction IDs for the individual entitlements
+ * that make up this debit record, if any.
* @return Wallet Self
*/
- public function debit(int $amount, string $description = '', array $eTIDs = []): Wallet
+ public function debit(int|Payment $amount, string $description = '', array $eTIDs = []): Wallet
{
- if ($amount == 0) {
- return $this;
- }
-
- $this->balance -= $amount;
-
- $this->save();
-
- $transaction = Transaction::create(
- [
- 'object_id' => $this->id,
- 'object_type' => Wallet::class,
- 'type' => Transaction::WALLET_DEBIT,
- 'amount' => $amount * -1,
- 'description' => $description
- ]
- );
-
- if (!empty($eTIDs)) {
- Transaction::whereIn('id', $eTIDs)->update(['transaction_id' => $transaction->id]);
- }
-
- return $this;
+ return $this->balanceUpdate(Transaction::WALLET_DEBIT, $amount, $description, $eTIDs);
}
/**
* The discount assigned to the wallet.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function discount()
{
return $this->belongsTo(Discount::class, 'discount_id', 'id');
}
/**
* Entitlements billed to this wallet.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function entitlements()
{
return $this->hasMany(Entitlement::class);
}
/**
* Calculate the expected charges to this wallet.
*
* @return int
*/
public function expectedCharges()
{
return $this->chargeEntitlements(false);
}
/**
* Return the exact, numeric version of the discount to be applied.
*
* Ranges from 0 - 100.
*
* @return int
*/
public function getDiscount()
{
return $this->discount ? $this->discount->discount : 0;
}
/**
* The actual discount rate for use in multiplication
*
* Ranges from 0.00 to 1.00.
*/
public function getDiscountRate()
{
return (100 - $this->getDiscount()) / 100;
}
/**
* Check if the specified user is a controller to this wallet.
*
* @param \App\User $user The user object.
*
* @return bool True if the user is one of the wallet controllers (including user), False otherwise
*/
public function isController(User $user): bool
{
return $user->id == $this->user_id || $this->controllers->contains($user);
}
/**
* A helper to display human-readable amount of money using
* the wallet currency and specified locale.
*
* @param int $amount A amount of money (in cents)
* @param string $locale A locale for the output
*
* @return string String representation, e.g. "9.99 CHF"
*/
public function money(int $amount, $locale = 'de_DE')
{
$amount = round($amount / 100, 2);
$nf = new \NumberFormatter($locale, \NumberFormatter::CURRENCY);
$result = $nf->formatCurrency($amount, $this->currency);
// Replace non-breaking space
return str_replace("\xC2\xA0", " ", $result);
}
/**
* The owner of the wallet -- the wallet is in his/her back pocket.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function owner()
{
return $this->belongsTo(User::class, 'user_id', 'id');
}
/**
* Payments on this wallet.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function payments()
{
return $this->hasMany(Payment::class);
}
+ /**
+ * Add a penalty to this wallet's balance.
+ *
+ * @param int|\App\Payment $amount The amount of penalty (in cents) or Payment object
+ * @param string $description The transaction description
+ *
+ * @return Wallet Self
+ */
+ public function penalty(int|Payment $amount, string $description = ''): Wallet
+ {
+ return $this->balanceUpdate(Transaction::WALLET_PENALTY, $amount, $description);
+ }
+
/**
* Plan of the wallet.
*
* @return ?\App\Plan
*/
public function plan()
{
$planId = $this->owner->getSetting('plan_id');
return $planId ? Plan::find($planId) : null;
}
/**
* Remove a controller from this wallet.
*
* @param \App\User $user The user to remove as a controller from this wallet.
*
* @return void
*/
public function removeController(User $user)
{
if ($this->controllers->contains($user)) {
$this->controllers()->detach($user);
}
}
+ /**
+ * Refund an amount of pecunia from this wallet's balance.
+ *
+ * @param int|\App\Payment $amount The amount of pecunia to refund (in cents) or Payment object
+ * @param string $description The transaction description
+ *
+ * @return Wallet Self
+ */
+ public function refund($amount, string $description = ''): Wallet
+ {
+ return $this->balanceUpdate(Transaction::WALLET_REFUND, $amount, $description);
+ }
+
+ /**
+ * Get the VAT rate for the wallet owner country.
+ *
+ * @param ?\DateTime $start Get the rate valid for the specified date-time,
+ * without it the current rate will be returned (if exists).
+ *
+ * @return ?\App\VatRate VAT rate
+ */
+ public function vatRate(\DateTime $start = null): ?VatRate
+ {
+ $owner = $this->owner;
+
+ // Make it working with deleted accounts too
+ if (!$owner) {
+ $owner = $this->owner()->withTrashed()->first();
+ }
+
+ $country = $owner->getSetting('country');
+
+ if (!$country) {
+ return null;
+ }
+
+ return VatRate::where('country', $country)
+ ->where('start', '<=', ($start ?: now())->format('Y-m-d h:i:s'))
+ ->orderByDesc('start')
+ ->limit(1)
+ ->first();
+ }
+
/**
* Retrieve the transactions against this wallet.
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
public function transactions()
{
return Transaction::where('object_id', $this->id)->where('object_type', Wallet::class);
}
/**
* Returns trial related information.
*
* @return ?array Plan ID, plan SKUs, trial end date, number of free months (planId, skus, end, months)
*/
public function trialInfo(): ?array
{
$plan = $this->plan();
$freeMonths = $plan ? $plan->free_months : 0;
$trialEnd = $freeMonths ? $this->owner->created_at->copy()->addMonthsWithoutOverflow($freeMonths) : null;
if ($trialEnd) {
// Get all SKUs assigned to the plan (they are free in trial)
// TODO: We could store the list of plan's SKUs in the wallet settings, for two reasons:
// - performance
// - if we change plan definition at some point in time, the old users would use
// the old definition, instead of the current one
// TODO: The same for plan's free_months value
$trialSkus = \App\Sku::select('id')
->whereIn('id', function ($query) use ($plan) {
$query->select('sku_id')
->from('package_skus')
->whereIn('package_id', function ($query) use ($plan) {
$query->select('package_id')
->from('plan_packages')
->where('plan_id', $plan->id);
});
})
->whereNot('title', 'storage')
->pluck('id')
->all();
return [
'end' => $trialEnd,
'skus' => $trialSkus,
'planId' => $plan->id,
'months' => $freeMonths,
];
}
return null;
}
/**
* Force-update entitlements' updated_at, charge if needed.
*
* @param bool $withCost When enabled the cost will be charged
*
* @return int Charged amount in cents
*/
public function updateEntitlements($withCost = true): int
{
$charges = 0;
$discount = $this->getDiscountRate();
$now = Carbon::now();
DB::beginTransaction();
// used to parent individual entitlement billings to the wallet debit.
$entitlementTransactions = [];
foreach ($this->entitlements()->get() as $entitlement) {
$cost = 0;
$diffInDays = $entitlement->updated_at->diffInDays($now);
// This entitlement has been created less than or equal to 14 days ago (this is at
// maximum the fourteenth 24-hour period).
if ($entitlement->created_at > Carbon::now()->subDays(14)) {
// $cost=0
} elseif ($withCost && $diffInDays > 0) {
// The price per day is based on the number of days in the last month
// or the current month if the period does not overlap with the previous month
// FIXME: This really should be simplified to constant $daysInMonth=30
if ($now->day >= $diffInDays && $now->month == $entitlement->updated_at->month) {
$daysInMonth = $now->daysInMonth;
} else {
$daysInMonth = \App\Utils::daysInLastMonth();
}
$pricePerDay = $entitlement->cost / $daysInMonth;
$cost = (int) (round($pricePerDay * $discount * $diffInDays, 0));
}
if ($diffInDays > 0) {
$entitlement->updated_at = $entitlement->updated_at->setDateFrom($now);
$entitlement->save();
}
if ($cost == 0) {
continue;
}
$charges += $cost;
// FIXME: Shouldn't we store also cost=0 transactions (to have the full history)?
$entitlementTransactions[] = $entitlement->createTransaction(
Transaction::ENTITLEMENT_BILLED,
$cost
);
}
if ($charges > 0) {
$this->debit($charges, '', $entitlementTransactions);
}
DB::commit();
return $charges;
}
+
+ /**
+ * Update the wallet balance, and create a transaction record
+ */
+ protected function balanceUpdate(string $type, int|Payment $amount, $description = null, array $eTIDs = [])
+ {
+ if ($amount instanceof Payment) {
+ $amount = $amount->credit_amount;
+ }
+
+ if ($amount === 0) {
+ return $this;
+ }
+
+ if (in_array($type, [Transaction::WALLET_CREDIT, Transaction::WALLET_AWARD])) {
+ $amount = abs($amount);
+ } else {
+ $amount = abs($amount) * -1;
+ }
+
+ $this->balance += $amount;
+ $this->save();
+
+ $transaction = Transaction::create([
+ 'user_email' => \App\Utils::userEmailOrNull(),
+ 'object_id' => $this->id,
+ 'object_type' => Wallet::class,
+ 'type' => $type,
+ 'amount' => $amount,
+ 'description' => $description,
+ ]);
+
+ if (!empty($eTIDs)) {
+ Transaction::whereIn('id', $eTIDs)->update(['transaction_id' => $transaction->id]);
+ }
+
+ return $this;
+ }
}
diff --git a/src/config/app.php b/src/config/app.php
index 1fdaf069..b7ab50d3 100644
--- a/src/config/app.php
+++ b/src/config/app.php
@@ -1,283 +1,282 @@
env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Application Environment
|--------------------------------------------------------------------------
|
| This value determines the "environment" your application is currently
| running in. This may determine how you prefer to configure various
| services the application utilizes. Set this in your ".env" file.
|
*/
'env' => env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/
'debug' => env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Application URL
|--------------------------------------------------------------------------
|
| This URL is used by the console to properly generate URLs when using
| the Artisan command line tool. You should set this to the root of
| your application so that it is used when running Artisan tasks.
*/
'url' => env('APP_URL', 'http://localhost'),
'passphrase' => env('APP_PASSPHRASE', null),
'public_url' => env('APP_PUBLIC_URL', env('APP_URL', 'http://localhost')),
'asset_url' => env('ASSET_URL'),
'support_url' => env('SUPPORT_URL', null),
'support_email' => env('SUPPORT_EMAIL', null),
'webmail_url' => env('WEBMAIL_URL', null),
'theme' => env('APP_THEME', 'default'),
'tenant_id' => env('APP_TENANT_ID', null),
'currency' => \strtoupper(env('APP_CURRENCY', 'CHF')),
/*
|--------------------------------------------------------------------------
| Application Domain
|--------------------------------------------------------------------------
|
| System domain used for user signup (kolab identity)
*/
'domain' => env('APP_DOMAIN', 'domain.tld'),
'website_domain' => env('APP_WEBSITE_DOMAIN', env('APP_DOMAIN', 'domain.tld')),
'services_domain' => env(
'APP_SERVICES_DOMAIN',
"services." . env('APP_WEBSITE_DOMAIN', env('APP_DOMAIN', 'domain.tld'))
),
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. We have gone
| ahead and set this to a sensible default for you out of the box.
|
*/
'timezone' => 'UTC',
/*
|--------------------------------------------------------------------------
| Application Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by the translation service provider. You are free to set this value
| to any of the locales which will be supported by the application.
|
*/
'locale' => env('APP_LOCALE', 'en'),
/*
|--------------------------------------------------------------------------
| Application Fallback Locale
|--------------------------------------------------------------------------
|
| The fallback locale determines the locale to use when the current one
| is not available. You may change the value to correspond to any of
| the language folders that are provided through your application.
|
*/
'fallback_locale' => 'en',
/*
|--------------------------------------------------------------------------
| Faker Locale
|--------------------------------------------------------------------------
|
| This locale will be used by the Faker PHP library when generating fake
| data for your database seeds. For example, this will be used to get
| localized telephone numbers, street address information and more.
|
*/
'faker_locale' => 'en_US',
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is used by the Illuminate encrypter service and should be set
| to a random, 32 character string, otherwise these encrypted strings
| will not be safe. Please do this before deploying an application!
|
*/
'key' => env('APP_KEY'),
'cipher' => 'AES-256-CBC',
/*
|--------------------------------------------------------------------------
| Autoloaded Service Providers
|--------------------------------------------------------------------------
|
| The service providers listed here will be automatically loaded on the
| request to your application. Feel free to add your own services to
| this array to grant expanded functionality to your applications.
|
*/
'providers' => [
/*
* Laravel Framework Service Providers...
*/
Illuminate\Auth\AuthServiceProvider::class,
Illuminate\Broadcasting\BroadcastServiceProvider::class,
Illuminate\Bus\BusServiceProvider::class,
Illuminate\Cache\CacheServiceProvider::class,
Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class,
Illuminate\Cookie\CookieServiceProvider::class,
Illuminate\Database\DatabaseServiceProvider::class,
Illuminate\Encryption\EncryptionServiceProvider::class,
Illuminate\Filesystem\FilesystemServiceProvider::class,
Illuminate\Foundation\Providers\FoundationServiceProvider::class,
Illuminate\Hashing\HashServiceProvider::class,
Illuminate\Mail\MailServiceProvider::class,
Illuminate\Notifications\NotificationServiceProvider::class,
Illuminate\Pagination\PaginationServiceProvider::class,
Illuminate\Pipeline\PipelineServiceProvider::class,
Illuminate\Queue\QueueServiceProvider::class,
Illuminate\Redis\RedisServiceProvider::class,
Illuminate\Auth\Passwords\PasswordResetServiceProvider::class,
Illuminate\Session\SessionServiceProvider::class,
Illuminate\Translation\TranslationServiceProvider::class,
Illuminate\Validation\ValidationServiceProvider::class,
Illuminate\View\ViewServiceProvider::class,
/*
* Application Service Providers...
*/
App\Providers\AppServiceProvider::class,
App\Providers\AuthServiceProvider::class,
// App\Providers\BroadcastServiceProvider::class,
App\Providers\EventServiceProvider::class,
App\Providers\HorizonServiceProvider::class,
App\Providers\PassportServiceProvider::class,
App\Providers\RouteServiceProvider::class,
],
/*
|--------------------------------------------------------------------------
| Class Aliases
|--------------------------------------------------------------------------
|
| This array of class aliases will be registered when this application
| is started. However, feel free to register as many as you wish as
| the aliases are "lazy" loaded so they don't hinder performance.
|
*/
'aliases' => \Illuminate\Support\Facades\Facade::defaultAliases()->toArray(),
'headers' => [
'csp' => env('APP_HEADER_CSP', ""),
'xfo' => env('APP_HEADER_XFO', ""),
],
// Locations of knowledge base articles
'kb' => [
// An article about suspended accounts
'account_suspended' => env('KB_ACCOUNT_SUSPENDED'),
// An article about a way to delete an owned account
'account_delete' => env('KB_ACCOUNT_DELETE'),
// An article about the payment system
'payment_system' => env('KB_PAYMENT_SYSTEM'),
],
'company' => [
'name' => env('COMPANY_NAME'),
'address' => env('COMPANY_ADDRESS'),
'details' => env('COMPANY_DETAILS'),
'email' => env('COMPANY_EMAIL'),
'logo' => env('COMPANY_LOGO'),
'footer' => env('COMPANY_FOOTER', env('COMPANY_DETAILS')),
'copyright' => env('COMPANY_COPYRIGHT', env('COMPANY_NAME', 'Apheleia IT AG')),
],
'storage' => [
'min_qty' => (int) env('STORAGE_MIN_QTY', 5), // in GB
],
'vat' => [
- 'countries' => env('VAT_COUNTRIES'),
- 'rate' => (float) env('VAT_RATE'),
+ 'mode' => (int) env('VAT_MODE', 0),
],
'password_policy' => env('PASSWORD_POLICY') ?: 'min:6,max:255',
'payment' => [
'methods_oneoff' => env('PAYMENT_METHODS_ONEOFF', 'creditcard,paypal,banktransfer,bitcoin'),
'methods_recurring' => env('PAYMENT_METHODS_RECURRING', 'creditcard'),
],
'with_ldap' => (bool) env('APP_LDAP', true),
'with_imap' => (bool) env('APP_IMAP', false),
'with_admin' => (bool) env('APP_WITH_ADMIN', false),
'with_files' => (bool) env('APP_WITH_FILES', false),
'with_reseller' => (bool) env('APP_WITH_RESELLER', false),
'with_services' => (bool) env('APP_WITH_SERVICES', false),
'signup' => [
'email_limit' => (int) env('SIGNUP_LIMIT_EMAIL', 0),
'ip_limit' => (int) env('SIGNUP_LIMIT_IP', 0),
],
'woat_ns1' => env('WOAT_NS1', 'ns01.' . env('APP_DOMAIN')),
'woat_ns2' => env('WOAT_NS2', 'ns02.' . env('APP_DOMAIN')),
'ratelimit_whitelist' => explode(',', env('RATELIMIT_WHITELIST', '')),
'companion_download_link' => env(
'COMPANION_DOWNLOAD_LINK',
"https://mirror.apheleia-it.ch/pub/companion-app-beta.apk"
)
];
diff --git a/src/database/migrations/2023_02_17_100000_vat_rates_table.php b/src/database/migrations/2023_02_17_100000_vat_rates_table.php
new file mode 100644
index 00000000..86cc3b82
--- /dev/null
+++ b/src/database/migrations/2023_02_17_100000_vat_rates_table.php
@@ -0,0 +1,88 @@
+string('id', 36)->primary();
+ $table->string('country', 2);
+ $table->timestamp('start')->useCurrent();
+ $table->double('rate', 5, 2);
+
+ $table->unique(['country', 'start']);
+ });
+
+ Schema::table(
+ 'payments',
+ function (Blueprint $table) {
+ $table->string('vat_rate_id', 36)->nullable();
+ $table->integer('credit_amount')->nullable(); // temporarily allow null
+
+ $table->foreign('vat_rate_id')->references('id')->on('vat_rates')->onUpdate('cascade');
+ }
+ );
+
+ DB::table('payments')->update(['credit_amount' => DB::raw("`amount`")]);
+
+ Schema::table(
+ 'payments',
+ function (Blueprint $table) {
+ $table->integer('credit_amount')->nullable(false)->change(); // remove nullable
+ }
+ );
+
+ // Migrate old tax rates (and existing payments)
+ if (($countries = \env('VAT_COUNTRIES')) && ($rate = \env('VAT_RATE'))) {
+ $countries = explode(',', strtoupper(trim($countries)));
+
+ foreach ($countries as $country) {
+ $vatRate = \App\VatRate::create([
+ 'start' => new DateTime('2010-01-01 00:00:00'),
+ 'rate' => $rate,
+ 'country' => $country,
+ ]);
+
+ DB::table('payments')->whereIn('wallet_id', function ($query) use ($country) {
+ $query->select('id')
+ ->from('wallets')
+ ->whereIn('user_id', function ($query) use ($country) {
+ $query->select('user_id')
+ ->from('user_settings')
+ ->where('key', 'country')
+ ->where('value', $country);
+ });
+ })
+ ->update(['vat_rate_id' => $vatRate->id]);
+ }
+ }
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::table(
+ 'payments',
+ function (Blueprint $table) {
+ $table->dropColumn('vat_rate_id');
+ $table->dropColumn('credit_amount');
+ }
+ );
+
+ Schema::dropIfExists('vat_rates');
+ }
+};
diff --git a/src/tests/Browser/Reseller/WalletTest.php b/src/tests/Browser/Reseller/WalletTest.php
index e09d368d..2c0c379c 100644
--- a/src/tests/Browser/Reseller/WalletTest.php
+++ b/src/tests/Browser/Reseller/WalletTest.php
@@ -1,252 +1,254 @@
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,
'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,
'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 45fb9009..e91123ed 100644
--- a/src/tests/Browser/WalletTest.php
+++ b/src/tests/Browser/WalletTest.php
@@ -1,277 +1,279 @@
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,
'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,
'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 3e692a59..be3aec48 100644
--- a/src/tests/Feature/Console/Data/Stats/CollectorTest.php
+++ b/src/tests/Feature/Console/Data/Stats/CollectorTest.php
@@ -1,77 +1,78 @@
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,
'amount' => 1000,
+ 'credit_amount' => 1000,
'type' => PaymentProvider::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 26ebba4e..58a42d39 100644
--- a/src/tests/Feature/Controller/Admin/StatsTest.php
+++ b/src/tests/Feature/Controller/Admin/StatsTest.php
@@ -1,256 +1,262 @@
delete();
DB::table('wallets')->update(['discount_id' => null]);
$this->deleteTestUser('test-stats@' . \config('app.domain'));
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
- Payment::truncate();
+ 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/)
*/
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,
- 'amount' => 1000, // EUR
+ 'amount' => 1000,
+ 'credit_amount' => 1000,
'type' => PaymentProvider::TYPE_ONEOFF,
'wallet_id' => $wallet->id,
'provider' => 'mollie',
'currency' => 'EUR',
'currency_amount' => 1000,
]);
Payment::create([
'id' => 'test2',
'description' => '',
'status' => PaymentProvider::STATUS_PAID,
- 'amount' => 2000, // EUR
+ 'amount' => 2000,
+ 'credit_amount' => 2000,
'type' => PaymentProvider::TYPE_RECURRING,
'wallet_id' => $wallet->id,
'provider' => 'mollie',
'currency' => 'EUR',
'currency_amount' => 2000,
]);
Payment::create([
'id' => 'test3',
'description' => '',
'status' => PaymentProvider::STATUS_PAID,
- 'amount' => 3000, // CHF
+ 'amount' => 3000,
+ 'credit_amount' => 3000,
'type' => PaymentProvider::TYPE_ONEOFF,
'wallet_id' => $johns_wallet->id,
'provider' => 'mollie',
'currency' => 'EUR',
'currency_amount' => 2800,
]);
Payment::create([
'id' => 'test4',
'description' => '',
'status' => PaymentProvider::STATUS_PAID,
- 'amount' => 4000, // CHF
+ 'amount' => 4000,
+ 'credit_amount' => 4000,
'type' => PaymentProvider::TYPE_RECURRING,
'wallet_id' => $johns_wallet->id,
'provider' => 'mollie',
'currency' => 'CHF',
'currency_amount' => 4000,
]);
Payment::create([
'id' => 'test5',
'description' => '',
'status' => PaymentProvider::STATUS_OPEN,
- 'amount' => 5000, // CHF
+ 'amount' => 5000,
+ 'credit_amount' => 5000,
'type' => PaymentProvider::TYPE_ONEOFF,
'wallet_id' => $johns_wallet->id,
'provider' => 'mollie',
'currency' => 'CHF',
'currency_amount' => 5000,
]);
Payment::create([
'id' => 'test6',
'description' => '',
'status' => PaymentProvider::STATUS_FAILED,
- 'amount' => 6000, // CHF
+ 'amount' => 6000,
+ 'credit_amount' => 6000,
'type' => PaymentProvider::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/Admin/UsersTest.php b/src/tests/Feature/Controller/Admin/UsersTest.php
index 9a3a0215..c8f3b222 100644
--- a/src/tests/Feature/Controller/Admin/UsersTest.php
+++ b/src/tests/Feature/Controller/Admin/UsersTest.php
@@ -1,554 +1,559 @@
deleteTestUser('UsersControllerTest1@userscontroller.com');
$this->deleteTestUser('test@testsearch.com');
$this->deleteTestDomain('testsearch.com');
$this->deleteTestGroup('group-test@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$jack->setSetting('external_email', null);
+
+ \App\SharedFolderAlias::truncate();
+ \App\Payment::query()->delete();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('UsersControllerTest1@userscontroller.com');
$this->deleteTestUser('test@testsearch.com');
$this->deleteTestDomain('testsearch.com');
$jack = $this->getTestUser('jack@kolab.org');
$jack->setSetting('external_email', null);
\App\SharedFolderAlias::truncate();
+ \App\Payment::query()->delete();
parent::tearDown();
}
/**
* Test user deleting (DELETE /api/v4/users/)
*/
public function testDestroy(): void
{
$john = $this->getTestUser('john@kolab.org');
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
// Test unauth access
$response = $this->delete("api/v4/users/{$user->id}");
$response->assertStatus(401);
// The end-point does not exist
$response = $this->actingAs($admin)->delete("api/v4/users/{$user->id}");
$response->assertStatus(404);
}
/**
* Test users searching (/api/v4/users)
*/
public function testIndex(): void
{
$user = $this->getTestUser('john@kolab.org');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$group = $this->getTestGroup('group-test@kolab.org');
$group->assignToWallet($user->wallets->first());
// Non-admin user
$response = $this->actingAs($user)->get("api/v4/users");
$response->assertStatus(403);
// Search with no search criteria
$response = $this->actingAs($admin)->get("api/v4/users");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(0, $json['count']);
$this->assertSame([], $json['list']);
// Search with no matches expected
$response = $this->actingAs($admin)->get("api/v4/users?search=abcd1234efgh5678");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(0, $json['count']);
$this->assertSame([], $json['list']);
// Search by domain
$response = $this->actingAs($admin)->get("api/v4/users?search=kolab.org");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($user->id, $json['list'][0]['id']);
$this->assertSame($user->email, $json['list'][0]['email']);
// Search by user ID
$response = $this->actingAs($admin)->get("api/v4/users?search={$user->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($user->id, $json['list'][0]['id']);
$this->assertSame($user->email, $json['list'][0]['email']);
// Search by email (primary)
$response = $this->actingAs($admin)->get("api/v4/users?search=john@kolab.org");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($user->id, $json['list'][0]['id']);
$this->assertSame($user->email, $json['list'][0]['email']);
// Search by email (alias)
$response = $this->actingAs($admin)->get("api/v4/users?search=john.doe@kolab.org");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($user->id, $json['list'][0]['id']);
$this->assertSame($user->email, $json['list'][0]['email']);
// Search by email (external), expect two users in a result
$jack = $this->getTestUser('jack@kolab.org');
$jack->setSetting('external_email', 'john.doe.external@gmail.com');
$response = $this->actingAs($admin)->get("api/v4/users?search=john.doe.external@gmail.com");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(2, $json['count']);
$this->assertCount(2, $json['list']);
$emails = array_column($json['list'], 'email');
$this->assertContains($user->email, $emails);
$this->assertContains($jack->email, $emails);
// Search by owner
$response = $this->actingAs($admin)->get("api/v4/users?owner={$user->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(4, $json['count']);
$this->assertCount(4, $json['list']);
// Search by owner (Ned is a controller on John's wallets,
// here we expect only users assigned to Ned's wallet(s))
$ned = $this->getTestUser('ned@kolab.org');
$response = $this->actingAs($admin)->get("api/v4/users?owner={$ned->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(0, $json['count']);
$this->assertCount(0, $json['list']);
// Search by distribution list email
$response = $this->actingAs($admin)->get("api/v4/users?search=group-test@kolab.org");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($user->id, $json['list'][0]['id']);
$this->assertSame($user->email, $json['list'][0]['email']);
// Search by resource email
$response = $this->actingAs($admin)->get("api/v4/users?search=resource-test1@kolab.org");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($user->id, $json['list'][0]['id']);
$this->assertSame($user->email, $json['list'][0]['email']);
// Search by shared folder email
$response = $this->actingAs($admin)->get("api/v4/users?search=folder-event@kolab.org");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($user->id, $json['list'][0]['id']);
$this->assertSame($user->email, $json['list'][0]['email']);
// Search by shared folder alias
$folder = $this->getTestSharedFolder('folder-event@kolab.org');
$folder->setAliases(['folder-alias@kolab.org']);
$response = $this->actingAs($admin)->get("api/v4/users?search=folder-alias@kolab.org");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($user->id, $json['list'][0]['id']);
$this->assertSame($user->email, $json['list'][0]['email']);
// Deleted users/domains
$domain = $this->getTestDomain('testsearch.com', ['type' => \App\Domain::TYPE_EXTERNAL]);
$user = $this->getTestUser('test@testsearch.com');
$plan = \App\Plan::where('title', 'group')->first();
$user->assignPlan($plan, $domain);
$user->setAliases(['alias@testsearch.com']);
$wallet = $user->wallets()->first();
$wallet->setSetting('mollie_id', 'cst_nonsense');
\App\Payment::create(
[
'id' => 'tr_nonsense',
'wallet_id' => $wallet->id,
'status' => 'paid',
'amount' => 1337,
+ 'credit_amount' => 1337,
'description' => 'nonsense transaction for testing',
'provider' => 'self',
'type' => 'oneoff',
'currency' => 'CHF',
'currency_amount' => 1337
]
);
Queue::fake();
$user->delete();
$response = $this->actingAs($admin)->get("api/v4/users?search=test@testsearch.com");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($user->id, $json['list'][0]['id']);
$this->assertSame($user->email, $json['list'][0]['email']);
$this->assertTrue($json['list'][0]['isDeleted']);
$response = $this->actingAs($admin)->get("api/v4/users?search=alias@testsearch.com");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($user->id, $json['list'][0]['id']);
$this->assertSame($user->email, $json['list'][0]['email']);
$this->assertTrue($json['list'][0]['isDeleted']);
$response = $this->actingAs($admin)->get("api/v4/users?search=testsearch.com");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($user->id, $json['list'][0]['id']);
$this->assertSame($user->email, $json['list'][0]['email']);
$this->assertTrue($json['list'][0]['isDeleted']);
$response = $this->actingAs($admin)->get("api/v4/users?search={$wallet->id}");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($user->id, $json['list'][0]['id']);
$this->assertSame($user->email, $json['list'][0]['email']);
$this->assertTrue($json['list'][0]['isDeleted']);
$response = $this->actingAs($admin)->get("api/v4/users?search=tr_nonsense");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($user->id, $json['list'][0]['id']);
$this->assertSame($user->email, $json['list'][0]['email']);
$this->assertTrue($json['list'][0]['isDeleted']);
$response = $this->actingAs($admin)->get("api/v4/users?search=cst_nonsense");
$response->assertStatus(200);
$json = $response->json();
$this->assertSame(1, $json['count']);
$this->assertCount(1, $json['list']);
$this->assertSame($user->id, $json['list'][0]['id']);
$this->assertSame($user->email, $json['list'][0]['email']);
$this->assertTrue($json['list'][0]['isDeleted']);
}
/**
* Test reseting 2FA (POST /api/v4/users//reset2FA)
*/
public function testReset2FA(): void
{
Queue::fake();
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$sku2fa = Sku::withEnvTenantContext()->where(['title' => '2fa'])->first();
$user->assignSku($sku2fa);
SecondFactor::seed('userscontrollertest1@userscontroller.com');
// Test unauthorized access to admin API
$response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/reset2FA", []);
$response->assertStatus(403);
$entitlements = $user->fresh()->entitlements()->where('sku_id', $sku2fa->id)->get();
$this->assertCount(1, $entitlements);
$sf = new SecondFactor($user);
$this->assertCount(1, $sf->factors());
// Test reseting 2FA
$response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/reset2FA", []);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame("2-Factor authentication reset successfully.", $json['message']);
$this->assertCount(2, $json);
$entitlements = $user->fresh()->entitlements()->where('sku_id', $sku2fa->id)->get();
$this->assertCount(0, $entitlements);
$sf = new SecondFactor($user);
$this->assertCount(0, $sf->factors());
}
/**
* Test reseting Geo-Lock (POST /api/v4/users//resetGeoLock)
*/
public function testResetGeoLock(): void
{
Queue::fake();
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$user->setConfig(['limit_geo' => ['US']]);
// Test unauthorized access to admin API
$response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/resetGeoLock", []);
$response->assertStatus(403);
// Test reseting Geo-Lock
$response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/resetGeoLock", []);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame("Geo-lockin setup reset successfully.", $json['message']);
$this->assertCount(2, $json);
$this->assertNull($user->getSetting('limit_geo'));
}
/**
* Test adding beta SKU (POST /api/v4/users//skus/beta)
*/
public function testAddBetaSku(): void
{
Queue::fake();
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$sku = Sku::withEnvTenantContext()->where(['title' => 'beta'])->first();
// Test unauthorized access to admin API
$response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/skus/beta", []);
$response->assertStatus(403);
// For now we allow only the beta sku
$response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/skus/mailbox", []);
$response->assertStatus(404);
$entitlements = $user->entitlements()->where('sku_id', $sku->id)->get();
$this->assertCount(0, $entitlements);
// Test adding the beta sku
$response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/skus/beta", []);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame("The subscription added successfully.", $json['message']);
$this->assertSame(0, $json['sku']['cost']);
$this->assertSame($sku->id, $json['sku']['id']);
$this->assertSame($sku->name, $json['sku']['name']);
$this->assertCount(3, $json);
$entitlements = $user->entitlements()->where('sku_id', $sku->id)->get();
$this->assertCount(1, $entitlements);
// Test adding the beta sku again, expect an error
$response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/skus/beta", []);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertSame("The subscription already exists.", $json['message']);
$this->assertCount(2, $json);
$entitlements = $user->entitlements()->where('sku_id', $sku->id)->get();
$this->assertCount(1, $entitlements);
}
/**
* Test user creation (POST /api/v4/users)
*/
public function testStore(): void
{
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
// The end-point does not exist
$response = $this->actingAs($admin)->post("/api/v4/users", []);
$response->assertStatus(404);
}
/**
* Test user suspending (POST /api/v4/users//suspend)
*/
public function testSuspend(): void
{
Queue::fake(); // disable jobs
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
// Test unauthorized access to admin API
$response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/suspend", []);
$response->assertStatus(403);
$this->assertFalse($user->isSuspended());
// Test suspending the user
$response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/suspend", []);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame("User suspended successfully.", $json['message']);
$this->assertCount(2, $json);
$this->assertTrue($user->fresh()->isSuspended());
}
/**
* Test user un-suspending (POST /api/v4/users//unsuspend)
*/
public function testUnsuspend(): void
{
Queue::fake(); // disable jobs
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
// Test unauthorized access to admin API
$response = $this->actingAs($user)->post("/api/v4/users/{$user->id}/unsuspend", []);
$response->assertStatus(403);
$this->assertFalse($user->isSuspended());
$user->suspend();
$this->assertTrue($user->isSuspended());
// Test suspending the user
$response = $this->actingAs($admin)->post("/api/v4/users/{$user->id}/unsuspend", []);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame("User unsuspended successfully.", $json['message']);
$this->assertCount(2, $json);
$this->assertFalse($user->fresh()->isSuspended());
}
/**
* Test user update (PUT /api/v4/users/)
*/
public function testUpdate(): void
{
$user = $this->getTestUser('UsersControllerTest1@userscontroller.com');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
// Test unauthorized access to admin API
$response = $this->actingAs($user)->put("/api/v4/users/{$user->id}", []);
$response->assertStatus(403);
// Test updatig the user data (empty data)
$response = $this->actingAs($admin)->put("/api/v4/users/{$user->id}", []);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame("User data updated successfully.", $json['message']);
$this->assertCount(2, $json);
// Test error handling
$post = ['external_email' => 'aaa'];
$response = $this->actingAs($admin)->put("/api/v4/users/{$user->id}", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertSame("The external email must be a valid email address.", $json['errors']['external_email'][0]);
$this->assertCount(2, $json);
// Test real update
$post = ['external_email' => 'modified@test.com'];
$response = $this->actingAs($admin)->put("/api/v4/users/{$user->id}", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame("User data updated successfully.", $json['message']);
$this->assertCount(2, $json);
$this->assertSame('modified@test.com', $user->getSetting('external_email'));
}
}
diff --git a/src/tests/Feature/Controller/PaymentsMollieEuroTest.php b/src/tests/Feature/Controller/PaymentsMollieEuroTest.php
index eac7ee20..e8eda36f 100644
--- a/src/tests/Feature/Controller/PaymentsMollieEuroTest.php
+++ b/src/tests/Feature/Controller/PaymentsMollieEuroTest.php
@@ -1,936 +1,937 @@
'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);
$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];
$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);
// 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);
$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->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->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->assertEquals(1234, $wallet->fresh()->balance);
// Test for payment failure
Bus::fake();
$payment->refresh();
$payment->status = PaymentProvider::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->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->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->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->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->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,
'amount' => 123,
+ 'credit_amount' => 123,
'currency_amount' => 123,
'currency' => 'EUR',
'type' => PaymentProvider::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("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("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('EUR', $json['list'][0]['currency']);
$this->assertSame(PaymentProvider::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->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->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->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 bd407896..cf3d06ef 100644
--- a/src/tests/Feature/Controller/PaymentsMollieTest.php
+++ b/src/tests/Feature/Controller/PaymentsMollieTest.php
@@ -1,1080 +1,1152 @@
'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::where('wallet_id', $wallet->id)->delete();
+ 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::where('wallet_id', $wallet->id)->delete();
+ 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);
$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];
$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);
// 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);
$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->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->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->assertEquals(1234, $wallet->fresh()->balance);
// Test for payment failure
Bus::fake();
$payment->refresh();
$payment->status = PaymentProvider::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->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
- $result = PaymentsController::topUpWallet($wallet);
- $this->assertTrue($result);
+ $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->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->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->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->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->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,
'amount' => 123,
+ 'credit_amount' => 123,
'currency_amount' => 123,
'currency' => 'CHF',
'type' => PaymentProvider::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("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("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,
'amount' => 1234,
+ 'credit_amount' => 1234,
'currency_amount' => 1117,
'currency' => 'EUR',
'type' => PaymentProvider::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('CHF', $json['list'][0]['currency']);
$this->assertSame(PaymentProvider::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->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->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->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 44b51d8b..05028341 100644
--- a/src/tests/Feature/Controller/PaymentsStripeTest.php
+++ b/src/tests/Feature/Controller/PaymentsStripeTest.php
@@ -1,745 +1,859 @@
'stripe']);
+ \config(['app.vat.mode' => 0]);
+
+ $this->deleteTestUser('payment-test@' . \config('app.domain'));
$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();
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();
- 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)
->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);
$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];
$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);
// 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);
$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->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->assertEquals(1234, $wallet->fresh()->balance);
// Test for payment failure ('failed' status)
$payment->refresh();
$payment->status = PaymentProvider::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->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->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->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(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->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->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->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->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->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->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);
}
/**
- * Generate Stripe-Signature header for a webhook payload
+ * Test payment/top-up with VAT_MODE=1
+ *
+ * @group stripe
*/
- protected function webhookRequest($post)
+ public function testPaymentsWithVatModeOne(): void
{
- $secret = \config('services.stripe.webhook_secret');
- $ts = time();
+ \config(['app.vat.mode' => 1]);
- $payload = "$ts." . json_encode($post);
- $sig = sprintf('t=%d,v1=%s', $ts, \hash_hmac('sha256', $payload, $secret));
+ $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(),
+ ]);
- return $this->withHeaders(['Stripe-Signature' => $sig])
- ->json('POST', "api/webhooks/payment/stripe", $post);
+ // 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->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->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/WalletsTest.php b/src/tests/Feature/Controller/WalletsTest.php
index 75abdab5..a84323b5 100644
--- a/src/tests/Feature/Controller/WalletsTest.php
+++ b/src/tests/Feature/Controller/WalletsTest.php
@@ -1,356 +1,357 @@
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,
'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 685a0bd7..6a762c62 100644
--- a/src/tests/Feature/Documents/ReceiptTest.php
+++ b/src/tests/Feature/Documents/ReceiptTest.php
@@ -1,385 +1,410 @@
paymentIDs)->delete();
+ Payment::query()->delete();
+ VatRate::query()->delete();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$this->deleteTestUser('receipt-test@kolabnow.com');
- Payment::whereIn('id', $this->paymentIDs)->delete();
+ 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('', $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
{
- \config(['app.vat.rate' => 7.7]);
- \config(['app.vat.countries' => 'ch']);
-
$appName = \config('app.name');
$wallet = $this->getTestData('CH');
$receipt = new Receipt($wallet, 2020, 5);
$html = $receipt->htmlOutput();
$this->assertStringStartsWith('', $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,
'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,
'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,
'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,
'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,
'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,
'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,
'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,
'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,
'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
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 bbf52205..a515d0d2 100644
--- a/src/tests/Feature/Jobs/PaymentEmailTest.php
+++ b/src/tests/Feature/Jobs/PaymentEmailTest.php
@@ -1,124 +1,125 @@
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->description = 'test';
$payment->provider = 'stripe';
$payment->type = PaymentProvider::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->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->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,
];
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
new file mode 100644
index 00000000..03deb507
--- /dev/null
+++ b/src/tests/Feature/PaymentTest.php
@@ -0,0 +1,169 @@
+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 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,
+ '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($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,
+ '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,
+ '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($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;
+
+ $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($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 ef16993c..afcd29cb 100644
--- a/src/tests/Feature/Stories/RateLimitTest.php
+++ b/src/tests/Feature/Stories/RateLimitTest.php
@@ -1,562 +1,569 @@
setUpTest();
$this->useServicesUrl();
+
+ \App\Payment::query()->delete();
}
public function tearDown(): void
{
+ \App\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 = [
'id' => \App\Utils::uuidInt(),
'status' => \App\Providers\PaymentProvider::STATUS_PAID,
'type' => \App\Providers\PaymentProvider::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);
$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);
$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 = [
'id' => \App\Utils::uuidInt(),
'status' => \App\Providers\PaymentProvider::STATUS_PAID,
'type' => \App\Providers\PaymentProvider::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);
$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);
$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 = [
'id' => \App\Utils::uuidInt(),
'status' => \App\Providers\PaymentProvider::STATUS_PAID,
'type' => \App\Providers\PaymentProvider::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);
$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);
$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);
}
}
}
diff --git a/src/tests/Feature/WalletTest.php b/src/tests/Feature/WalletTest.php
index a431e288..6341af27 100644
--- a/src/tests/Feature/WalletTest.php
+++ b/src/tests/Feature/WalletTest.php
@@ -1,567 +1,640 @@
users as $user) {
$this->deleteTestUser($user);
}
Sku::select()->update(['fee' => 0]);
+ Payment::query()->delete();
+ VatRate::query()->delete();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
foreach ($this->users as $user) {
$this->deleteTestUser($user);
}
Sku::select()->update(['fee' => 0]);
+ Payment::query()->delete();
+ VatRate::query()->delete();
parent::tearDown();
}
/**
* Test that turning wallet balance from negative to positive
* unsuspends and undegrades the account
*/
public function testBalanceTurnsPositive(): void
{
Queue::fake();
$user = $this->getTestUser('UserWallet1@UserWallet.com');
$user->suspend();
$user->degrade();
$wallet = $user->wallets()->first();
$wallet->balance = -100;
$wallet->save();
$this->assertTrue($user->isSuspended());
$this->assertTrue($user->isDegraded());
$this->assertNotNull($wallet->getSetting('balance_negative_since'));
$wallet->balance = 100;
$wallet->save();
$user->refresh();
$this->assertFalse($user->isSuspended());
$this->assertFalse($user->isDegraded());
$this->assertNull($wallet->getSetting('balance_negative_since'));
// Test un-restricting users on balance change
$this->deleteTestUser('UserWallet1@UserWallet.com');
$owner = $this->getTestUser('UserWallet1@UserWallet.com');
$user1 = $this->getTestUser('UserWallet2@UserWallet.com');
$user2 = $this->getTestUser('UserWallet3@UserWallet.com');
$package = Package::withEnvTenantContext()->where('title', 'lite')->first();
$owner->assignPackage($package, $user1);
$owner->assignPackage($package, $user2);
$wallet = $owner->wallets()->first();
$owner->restrict();
$user1->restrict();
$user2->restrict();
$this->assertTrue($owner->isRestricted());
$this->assertTrue($user1->isRestricted());
$this->assertTrue($user2->isRestricted());
Queue::fake();
$wallet->balance = 100;
$wallet->save();
$this->assertFalse($owner->fresh()->isRestricted());
$this->assertFalse($user1->fresh()->isRestricted());
$this->assertFalse($user2->fresh()->isRestricted());
Queue::assertPushed(\App\Jobs\User\UpdateJob::class, 3);
// TODO: Test group account and unsuspending domain/members/groups
}
/**
* Test for Wallet::balanceLastsUntil()
*/
public function testBalanceLastsUntil(): void
{
// Monthly cost of all entitlements: 990
// 28 days: 35.36 per day
// 31 days: 31.93 per day
$user = $this->getTestUser('jane@kolabnow.com');
$plan = Plan::withEnvTenantContext()->where('title', 'individual')->first();
$user->assignPlan($plan);
$wallet = $user->wallets()->first();
// User/entitlements created today, balance=0
$until = $wallet->balanceLastsUntil();
$this->assertSame(
Carbon::now()->addMonthsWithoutOverflow(1)->toDateString(),
$until->toDateString()
);
// User/entitlements created today, balance=-10 CHF
$wallet->balance = -1000;
$until = $wallet->balanceLastsUntil();
$this->assertSame(null, $until);
// User/entitlements created today, balance=-9,99 CHF (monthly cost)
$wallet->balance = 990;
$until = $wallet->balanceLastsUntil();
$daysInLastMonth = \App\Utils::daysInLastMonth();
$delta = Carbon::now()->addMonthsWithoutOverflow(1)->addDays($daysInLastMonth)->diff($until)->days;
$this->assertTrue($delta <= 1);
$this->assertTrue($delta >= -1);
// Old entitlements, 100% discount
$this->backdateEntitlements($wallet->entitlements, Carbon::now()->subDays(40));
$discount = \App\Discount::withEnvTenantContext()->where('discount', 100)->first();
$wallet->discount()->associate($discount);
$until = $wallet->refresh()->balanceLastsUntil();
$this->assertSame(null, $until);
// User with no entitlements
$wallet->discount()->dissociate($discount);
$wallet->entitlements()->delete();
$until = $wallet->refresh()->balanceLastsUntil();
$this->assertSame(null, $until);
}
/**
* Verify a wallet is created, when a user is created.
*/
public function testCreateUserCreatesWallet(): void
{
$user = $this->getTestUser('UserWallet1@UserWallet.com');
$this->assertCount(1, $user->wallets);
$this->assertSame(\config('app.currency'), $user->wallets[0]->currency);
$this->assertSame(0, $user->wallets[0]->balance);
}
/**
* Verify a user can haz more wallets.
*/
public function testAddWallet(): void
{
$user = $this->getTestUser('UserWallet2@UserWallet.com');
$user->wallets()->save(
new Wallet(['currency' => 'USD'])
);
$this->assertCount(2, $user->wallets);
$user->wallets()->each(
function ($wallet) {
$this->assertEquals(0, $wallet->balance);
}
);
// For now all wallets use system currency
$this->assertFalse($user->wallets()->where('currency', 'USD')->exists());
}
/**
* Verify we can not delete a user wallet that holds balance.
*/
public function testDeleteWalletWithCredit(): void
{
$user = $this->getTestUser('UserWallet3@UserWallet.com');
$user->wallets()->each(
function ($wallet) {
$wallet->credit(100)->save();
}
);
$user->wallets()->each(
function ($wallet) {
$this->assertFalse($wallet->delete());
}
);
}
/**
* Verify we can not delete a wallet that is the last wallet.
*/
public function testDeleteLastWallet(): void
{
$user = $this->getTestUser('UserWallet4@UserWallet.com');
$this->assertCount(1, $user->wallets);
$user->wallets()->each(
function ($wallet) {
$this->assertFalse($wallet->delete());
}
);
}
/**
* Verify we can remove a wallet that is an additional wallet.
*/
public function testDeleteAddtWallet(): void
{
$user = $this->getTestUser('UserWallet5@UserWallet.com');
$user->wallets()->save(
new Wallet(['currency' => 'USD'])
);
// For now additional wallets with a different currency is not allowed
$this->assertFalse($user->wallets()->where('currency', 'USD')->exists());
/*
$user->wallets()->each(
function ($wallet) {
if ($wallet->currency == 'USD') {
$this->assertNotFalse($wallet->delete());
}
}
);
*/
}
/**
* Verify a wallet can be assigned a controller.
*/
public function testAddWalletController(): void
{
$userA = $this->getTestUser('WalletControllerA@WalletController.com');
$userB = $this->getTestUser('WalletControllerB@WalletController.com');
$userA->wallets()->each(
function ($wallet) use ($userB) {
$wallet->addController($userB);
}
);
$this->assertCount(1, $userB->accounts);
$aWallet = $userA->wallets()->first();
$bAccount = $userB->accounts()->first();
$this->assertTrue($bAccount->id === $aWallet->id);
}
/**
* Test Wallet::isController()
*/
public function testIsController(): void
{
$john = $this->getTestUser('john@kolab.org');
$jack = $this->getTestUser('jack@kolab.org');
$ned = $this->getTestUser('ned@kolab.org');
$wallet = $jack->wallet();
$this->assertTrue($wallet->isController($john));
$this->assertTrue($wallet->isController($ned));
$this->assertFalse($wallet->isController($jack));
}
/**
* Verify controllers can also be removed from wallets.
*/
public function testRemoveWalletController(): void
{
$userA = $this->getTestUser('WalletController2A@WalletController.com');
$userB = $this->getTestUser('WalletController2B@WalletController.com');
$userA->wallets()->each(
function ($wallet) use ($userB) {
$wallet->addController($userB);
}
);
$userB->refresh();
$userB->accounts()->each(
function ($wallet) use ($userB) {
$wallet->removeController($userB);
}
);
$this->assertCount(0, $userB->accounts);
}
/**
* Test for charging and removing entitlements (including tenant commission calculations)
*/
public function testChargeAndDeleteEntitlements(): void
{
$user = $this->getTestUser('jane@kolabnow.com');
$wallet = $user->wallets()->first();
$discount = \App\Discount::withEnvTenantContext()->where('discount', 30)->first();
$wallet->discount()->associate($discount);
$wallet->save();
// Add 40% fee to all SKUs
Sku::select()->update(['fee' => DB::raw("`cost` * 0.4")]);
$plan = Plan::withEnvTenantContext()->where('title', 'individual')->first();
$storage = Sku::withEnvTenantContext()->where('title', 'storage')->first();
$user->assignPlan($plan);
$user->assignSku($storage, 5);
$user->setSetting('plan_id', null); // disable plan and trial
// Reset reseller's wallet balance and transactions
$reseller_wallet = $user->tenant->wallet();
$reseller_wallet->balance = 0;
$reseller_wallet->save();
Transaction::where('object_id', $reseller_wallet->id)->where('object_type', \App\Wallet::class)->delete();
// ------------------------------------
// Test normal charging of entitlements
// ------------------------------------
// Backdate and charge entitlements, we're expecting one month to be charged
// Set fake NOW date to make simpler asserting results that depend on number of days in current/last month
Carbon::setTestNow(Carbon::create(2021, 5, 21, 12));
$backdate = Carbon::now()->subWeeks(7);
$this->backdateEntitlements($user->entitlements, $backdate);
$charge = $wallet->chargeEntitlements();
$wallet->refresh();
$reseller_wallet->refresh();
// User discount is 30%
// Expected: groupware: 490 x 70% + mailbox: 500 x 70% + storage: 5 x round(25x70%) = 778
$this->assertSame(-778, $wallet->balance);
// Reseller fee is 40%
// Expected: groupware: 490 x 30% + mailbox: 500 x 30% + storage: 5 x round(25x30%) = 332
$this->assertSame(332, $reseller_wallet->balance);
$transactions = Transaction::where('object_id', $wallet->id)
->where('object_type', \App\Wallet::class)->get();
$reseller_transactions = Transaction::where('object_id', $reseller_wallet->id)
->where('object_type', \App\Wallet::class)->get();
$this->assertCount(1, $reseller_transactions);
$trans = $reseller_transactions[0];
$this->assertSame("Charged user jane@kolabnow.com", $trans->description);
$this->assertSame(332, $trans->amount);
$this->assertSame(Transaction::WALLET_CREDIT, $trans->type);
$this->assertCount(1, $transactions);
$trans = $transactions[0];
$this->assertSame('', $trans->description);
$this->assertSame(-778, $trans->amount);
$this->assertSame(Transaction::WALLET_DEBIT, $trans->type);
// Assert all entitlements' updated_at timestamp
$date = $backdate->addMonthsWithoutOverflow(1);
$this->assertCount(12, $wallet->entitlements()->where('updated_at', $date)->get());
// -----------------------------------
// Test charging on entitlement delete
// -----------------------------------
$reseller_wallet->balance = 0;
$reseller_wallet->save();
$transactions = Transaction::where('object_id', $wallet->id)
->where('object_type', \App\Wallet::class)->delete();
$reseller_transactions = Transaction::where('object_id', $reseller_wallet->id)
->where('object_type', \App\Wallet::class)->delete();
$user->removeSku($storage, 2);
// we expect the wallet to have been charged for 19 days of use of
// 2 deleted storage entitlements
$wallet->refresh();
$reseller_wallet->refresh();
// 2 x round(25 / 31 * 19 * 0.7) = 22
$this->assertSame(-(778 + 22), $wallet->balance);
// 22 - 2 x round(25 * 0.4 / 31 * 19) = 10
$this->assertSame(10, $reseller_wallet->balance);
$transactions = Transaction::where('object_id', $wallet->id)
->where('object_type', \App\Wallet::class)->get();
$reseller_transactions = Transaction::where('object_id', $reseller_wallet->id)
->where('object_type', \App\Wallet::class)->get();
$this->assertCount(2, $reseller_transactions);
$trans = $reseller_transactions[0];
$this->assertSame("Charged user jane@kolabnow.com", $trans->description);
$this->assertSame(5, $trans->amount);
$this->assertSame(Transaction::WALLET_CREDIT, $trans->type);
$trans = $reseller_transactions[1];
$this->assertSame("Charged user jane@kolabnow.com", $trans->description);
$this->assertSame(5, $trans->amount);
$this->assertSame(Transaction::WALLET_CREDIT, $trans->type);
$this->assertCount(2, $transactions);
$trans = $transactions[0];
$this->assertSame('', $trans->description);
$this->assertSame(-11, $trans->amount);
$this->assertSame(Transaction::WALLET_DEBIT, $trans->type);
$trans = $transactions[1];
$this->assertSame('', $trans->description);
$this->assertSame(-11, $trans->amount);
$this->assertSame(Transaction::WALLET_DEBIT, $trans->type);
// TODO: Test entitlement transaction records
}
/**
* Test for charging and removing entitlements when in trial
*/
public function testChargeAndDeleteEntitlementsTrial(): void
{
$user = $this->getTestUser('jane@kolabnow.com');
$wallet = $user->wallets()->first();
$plan = Plan::withEnvTenantContext()->where('title', 'individual')->first();
$storage = Sku::withEnvTenantContext()->where('title', 'storage')->first();
$user->assignPlan($plan);
$user->assignSku($storage, 5);
// ------------------------------------
// Test normal charging of entitlements
// ------------------------------------
// Backdate and charge entitlements, we're expecting one month to be charged
// Set fake NOW date to make simpler asserting results that depend on number of days in current/last month
Carbon::setTestNow(Carbon::create(2021, 5, 21, 12));
$backdate = Carbon::now()->subWeeks(7);
$this->backdateEntitlements($user->entitlements, $backdate);
$charge = $wallet->chargeEntitlements();
$wallet->refresh();
// Expected: storage: 5 x 25 = 125 (the rest is free in trial)
$this->assertSame($balance = -125, $wallet->balance);
// Assert wallet transaction
$transactions = $wallet->transactions()->get();
$this->assertCount(1, $transactions);
$trans = $transactions[0];
$this->assertSame('', $trans->description);
$this->assertSame($balance, $trans->amount);
$this->assertSame(Transaction::WALLET_DEBIT, $trans->type);
// Assert entitlement transactions
$etransactions = Transaction::where('transaction_id', $trans->id)->get();
$this->assertCount(5, $etransactions);
$trans = $etransactions[0];
$this->assertSame(null, $trans->description);
$this->assertSame(25, $trans->amount);
$this->assertSame(Transaction::ENTITLEMENT_BILLED, $trans->type);
// Assert all entitlements' updated_at timestamp
$date = $backdate->addMonthsWithoutOverflow(1);
$this->assertCount(12, $wallet->entitlements()->where('updated_at', $date)->get());
// Run again, expect no changes
$charge = $wallet->chargeEntitlements();
$wallet->refresh();
$this->assertSame($balance, $wallet->balance);
$this->assertCount(1, $wallet->transactions()->get());
$this->assertCount(12, $wallet->entitlements()->where('updated_at', $date)->get());
// -----------------------------------
// Test charging on entitlement delete
// -----------------------------------
$wallet->transactions()->delete();
$user->removeSku($storage, 2);
$wallet->refresh();
// we expect the wallet to have been charged for 19 days of use of
// 2 deleted storage entitlements: 2 x round(25 / 31 * 19) = 30
$this->assertSame($balance -= 30, $wallet->balance);
// Assert wallet transactions
$transactions = $wallet->transactions()->get();
$this->assertCount(2, $transactions);
$trans = $transactions[0];
$this->assertSame('', $trans->description);
$this->assertSame(-15, $trans->amount);
$this->assertSame(Transaction::WALLET_DEBIT, $trans->type);
$trans = $transactions[1];
$this->assertSame('', $trans->description);
$this->assertSame(-15, $trans->amount);
$this->assertSame(Transaction::WALLET_DEBIT, $trans->type);
// Assert entitlement transactions
/* Note: Commented out because the observer does not create per-entitlement transactions
$etransactions = Transaction::where('transaction_id', $transactions[0]->id)->get();
$this->assertCount(1, $etransactions);
$trans = $etransactions[0];
$this->assertSame(null, $trans->description);
$this->assertSame(15, $trans->amount);
$this->assertSame(Transaction::ENTITLEMENT_BILLED, $trans->type);
$etransactions = Transaction::where('transaction_id', $transactions[1]->id)->get();
$this->assertCount(1, $etransactions);
$trans = $etransactions[0];
$this->assertSame(null, $trans->description);
$this->assertSame(15, $trans->amount);
$this->assertSame(Transaction::ENTITLEMENT_BILLED, $trans->type);
*/
}
+ /**
+ * Tests for award() and penalty()
+ */
+ public function testAwardAndPenalty(): void
+ {
+ $this->markTestIncomplete();
+ }
+
+ /**
+ * Tests for chargeback() and refund()
+ */
+ public function testChargebackAndRefund(): void
+ {
+ $this->markTestIncomplete();
+ }
+
+ /**
+ * Tests for chargeEntitlement()
+ */
+ public function testChargeEntitlement(): void
+ {
+ $this->markTestIncomplete();
+ }
+
/**
* Tests for updateEntitlements()
*/
public function testUpdateEntitlements(): void
{
$this->markTestIncomplete();
}
+
+ /**
+ * Tests for vatRate()
+ */
+ public function testVatRate(): void
+ {
+ $rate1 = VatRate::create([
+ 'start' => now()->subDay(),
+ 'country' => 'US',
+ 'rate' => 7.5,
+ ]);
+ $rate2 = VatRate::create([
+ 'start' => now()->subDay(),
+ 'country' => 'DE',
+ 'rate' => 10.0,
+ ]);
+
+ $user = $this->getTestUser('UserWallet1@UserWallet.com');
+ $wallet = $user->wallets()->first();
+
+ $user->setSetting('country', null);
+ $this->assertSame(null, $wallet->vatRate());
+
+ $user->setSetting('country', 'PL');
+ $this->assertSame(null, $wallet->vatRate());
+
+ $user->setSetting('country', 'US');
+ $this->assertSame($rate1->id, $wallet->vatRate()->id); // @phpstan-ignore-line
+
+ $user->setSetting('country', 'DE');
+ $this->assertSame($rate2->id, $wallet->vatRate()->id); // @phpstan-ignore-line
+
+ // Test $start argument
+ $rate3 = VatRate::create([
+ 'start' => now()->subYear(),
+ 'country' => 'DE',
+ 'rate' => 5.0,
+ ]);
+
+ $this->assertSame($rate2->id, $wallet->vatRate()->id); // @phpstan-ignore-line
+ $this->assertSame($rate3->id, $wallet->vatRate(now()->subMonth())->id);
+ $this->assertSame(null, $wallet->vatRate(now()->subYears(2)));
+ }
}
diff --git a/src/tests/Unit/Backends/DAV/VcardTest.php b/src/tests/Unit/Backends/DAV/VcardTest.php
index 298eef5a..d4ac9436 100644
--- a/src/tests/Unit/Backends/DAV/VcardTest.php
+++ b/src/tests/Unit/Backends/DAV/VcardTest.php
@@ -1,50 +1,50 @@
/dav/addressbooks/user/test@test.com/Default/$uid.vcf
"d27382e0b401384becb0d5b157d6b73a2c2084a2"
+]]>
HTTP/1.1 200 OK
XML;
$doc = new \DOMDocument('1.0', 'UTF-8');
$doc->loadXML($vcard);
$contact = Vcard::fromDomElement($doc->getElementsByTagName('response')->item(0));
$this->assertInstanceOf(Vcard::class, $contact);
$this->assertSame('d27382e0b401384becb0d5b157d6b73a2c2084a2', $contact->etag);
$this->assertSame("/dav/addressbooks/user/test@test.com/Default/{$uid}.vcf", $contact->href);
$this->assertSame('text/vcard; charset=utf-8', $contact->contentType);
$this->assertSame($uid, $contact->uid);
// TODO: Test all supported properties in detail
}
}