Page MenuHomePhorge

No OneTemporary

Size
95 KB
Referenced Files
None
Subscribers
None
diff --git a/src/app/Http/Controllers/API/V4/Admin/WalletsController.php b/src/app/Http/Controllers/API/V4/Admin/WalletsController.php
index 1c8462be..f03e0563 100644
--- a/src/app/Http/Controllers/API/V4/Admin/WalletsController.php
+++ b/src/app/Http/Controllers/API/V4/Admin/WalletsController.php
@@ -1,148 +1,148 @@
<?php
namespace App\Http\Controllers\API\V4\Admin;
use App\Discount;
use App\Http\Controllers\API\V4\PaymentsController;
use App\Providers\PaymentProvider;
use App\Transaction;
use App\Wallet;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
class WalletsController extends \App\Http\Controllers\API\V4\WalletsController
{
/**
* Return data of the specified wallet.
*
* @param string $id A wallet identifier
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function show($id)
{
$wallet = Wallet::find($id);
if (empty($wallet)) {
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);
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);
if (empty($wallet)) {
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;
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 < 0 ? $amount * -1 : $amount,
+ 'amount' => $amount,
'description' => $request->description
]
);
DB::commit();
$response = [
'status' => 'success',
'message' => \trans("app.wallet-{$type}-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)) {
return $this->errorResponse(404);
}
if (array_key_exists('discount', $request->input())) {
if (empty($request->discount)) {
$wallet->discount()->dissociate();
$wallet->save();
} elseif ($discount = Discount::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/WalletsController.php b/src/app/Http/Controllers/API/V4/WalletsController.php
index ce1df494..481ebc1d 100644
--- a/src/app/Http/Controllers/API/V4/WalletsController.php
+++ b/src/app/Http/Controllers/API/V4/WalletsController.php
@@ -1,334 +1,321 @@
<?php
namespace App\Http\Controllers\API\V4;
use App\Transaction;
use App\Wallet;
use App\Http\Controllers\Controller;
use App\Providers\PaymentProvider;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
/**
* API\WalletsController
*/
class WalletsController extends Controller
{
/**
* Display a listing of the resource.
*
* @return \Illuminate\Http\JsonResponse
*/
public function index()
{
return $this->errorResponse(404);
}
/**
* Show the form for creating a new resource.
*
* @return \Illuminate\Http\JsonResponse
*/
public function create()
{
return $this->errorResponse(404);
}
/**
* Store a newly created resource in storage.
*
* @param \Illuminate\Http\Request $request
*
* @return \Illuminate\Http\JsonResponse
*/
public function store(Request $request)
{
return $this->errorResponse(404);
}
/**
* Return data of the specified wallet.
*
* @param string $id A wallet identifier
*
* @return \Illuminate\Http\JsonResponse The response
*/
public function show($id)
{
$wallet = Wallet::find($id);
if (empty($wallet)) {
return $this->errorResponse(404);
}
// Only owner (or admin) has access to the wallet
if (!Auth::guard()->user()->canRead($wallet)) {
return $this->errorResponse(403);
}
$result = $wallet->toArray();
$provider = \App\Providers\PaymentProvider::factory($wallet);
$result['provider'] = $provider->name();
$result['notice'] = $this->getWalletNotice($wallet);
return response()->json($result);
}
/**
* Show the form for editing the specified resource.
*
* @param int $id
*
* @return \Illuminate\Http\JsonResponse
*/
public function edit($id)
{
return $this->errorResponse(404);
}
/**
* Update the specified resource in storage.
*
* @param \Illuminate\Http\Request $request
* @param string $id
*
* @return \Illuminate\Http\JsonResponse
*/
public function update(Request $request, $id)
{
return $this->errorResponse(404);
}
/**
* Remove the specified resource from storage.
*
* @param int $id
*
* @return \Illuminate\Http\JsonResponse
*/
public function destroy($id)
{
return $this->errorResponse(404);
}
/**
* Download a receipt in pdf format.
*
* @param string $id Wallet identifier
* @param string $receipt Receipt identifier (YYYY-MM)
*
* @return \Illuminate\Http\Response
*/
public function receiptDownload($id, $receipt)
{
$wallet = Wallet::find($id);
// Only owner (or admin) has access to the wallet
if (!Auth::guard()->user()->canRead($wallet)) {
abort(403);
}
list ($year, $month) = explode('-', $receipt);
if (empty($year) || empty($month) || $year < 2000 || $month < 1 || $month > 12) {
abort(404);
}
if ($receipt >= date('Y-m')) {
abort(404);
}
$params = [
'id' => sprintf('%04d-%02d', $year, $month),
'site' => \config('app.name')
];
$filename = \trans('documents.receipt-filename', $params);
$receipt = new \App\Documents\Receipt($wallet, (int) $year, (int) $month);
$content = $receipt->pdfOutput();
return response($content)
->withHeaders([
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
'Content-Length' => strlen($content),
]);
}
/**
* Fetch wallet receipts list.
*
* @param string $id Wallet identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function receipts($id)
{
$wallet = Wallet::find($id);
// Only owner (or admin) has access to the wallet
if (!Auth::guard()->user()->canRead($wallet)) {
return $this->errorResponse(403);
}
$result = $wallet->payments()
->selectRaw('distinct date_format(updated_at, "%Y-%m") as ident')
->where('status', PaymentProvider::STATUS_PAID)
->where('amount', '<>', 0)
->orderBy('ident', 'desc')
->get()
->whereNotIn('ident', [date('Y-m')]) // exclude current month
->pluck('ident');
return response()->json([
'status' => 'success',
'list' => $result,
'count' => count($result),
'hasMore' => false,
'page' => 1,
]);
}
/**
* Fetch wallet transactions.
*
* @param string $id Wallet identifier
*
* @return \Illuminate\Http\JsonResponse
*/
public function transactions($id)
{
$wallet = Wallet::find($id);
// Only owner (or admin) has access to the wallet
if (!Auth::guard()->user()->canRead($wallet)) {
return $this->errorResponse(403);
}
$pageSize = 10;
$page = intval(request()->input('page')) ?: 1;
$hasMore = false;
$isAdmin = $this instanceof Admin\WalletsController;
if ($transaction = request()->input('transaction')) {
// Get sub-transactions for the specified transaction ID, first
// check access rights to the transaction's wallet
$transaction = $wallet->transactions()->where('id', $transaction)->first();
if (!$transaction) {
return $this->errorResponse(404);
}
$result = Transaction::where('transaction_id', $transaction->id)->get();
} else {
// Get main transactions (paged)
$result = $wallet->transactions()
// FIXME: Do we know which (type of) transaction has sub-transactions
// without the sub-query?
->selectRaw("*, (SELECT count(*) FROM transactions sub "
. "WHERE sub.transaction_id = transactions.id) AS cnt")
->whereNull('transaction_id')
->latest()
->limit($pageSize + 1)
->offset($pageSize * ($page - 1))
->get();
if (count($result) > $pageSize) {
$result->pop();
$hasMore = true;
}
}
$result = $result->map(function ($item) use ($isAdmin) {
- $amount = $item->amount;
-
- $negatives = [
- Transaction::WALLET_CHARGEBACK,
- Transaction::WALLET_DEBIT,
- Transaction::WALLET_PENALTY,
- Transaction::WALLET_REFUND,
- ];
-
- if (in_array($item->type, $negatives)) {
- $amount *= -1;
- }
-
$entry = [
'id' => $item->id,
'createdAt' => $item->created_at->format('Y-m-d H:i'),
'type' => $item->type,
'description' => $item->shortDescription(),
- 'amount' => $amount,
+ 'amount' => $item->amount,
'hasDetails' => !empty($item->cnt),
];
if ($isAdmin && $item->user_email) {
$entry['user'] = $item->user_email;
}
return $entry;
});
return response()->json([
'status' => 'success',
'list' => $result,
'count' => count($result),
'hasMore' => $hasMore,
'page' => $page,
]);
}
/**
* Returns human readable notice about the wallet state.
*
* @param \App\Wallet $wallet The wallet
*/
protected function getWalletNotice(Wallet $wallet): ?string
{
// there is no credit
if ($wallet->balance < 0) {
return \trans('app.wallet-notice-nocredit');
}
// the discount is 100%, no credit is needed
if ($wallet->discount && $wallet->discount->discount == 100) {
return null;
}
// the owner was created less than a month ago
if ($wallet->owner->created_at > Carbon::now()->subMonthsWithoutOverflow(1)) {
// but more than two weeks ago, notice of trial ending
if ($wallet->owner->created_at <= Carbon::now()->subWeeks(2)) {
return \trans('app.wallet-notice-trial-end');
}
return \trans('app.wallet-notice-trial');
}
if ($until = $wallet->balanceLastsUntil()) {
if ($until->isToday()) {
return \trans('app.wallet-notice-today');
}
// Once in a while we got e.g. "3 weeks" instead of expected "4 weeks".
// It's because $until uses full seconds, but $now is more precise.
// We make sure both have the same time set.
$now = Carbon::now()->setTimeFrom($until);
$params = [
'date' => $until->toDateString(),
'days' => $now->diffForHumans($until, Carbon::DIFF_ABSOLUTE),
];
return \trans('app.wallet-notice-date', $params);
}
return null;
}
}
diff --git a/src/app/Providers/PaymentProvider.php b/src/app/Providers/PaymentProvider.php
index 67dea4b7..aa27ceca 100644
--- a/src/app/Providers/PaymentProvider.php
+++ b/src/app/Providers/PaymentProvider.php
@@ -1,197 +1,197 @@
<?php
namespace App\Providers;
use App\Transaction;
use App\Payment;
use App\Wallet;
abstract class PaymentProvider
{
public const STATUS_OPEN = 'open';
public const STATUS_CANCELED = 'canceled';
public const STATUS_PENDING = 'pending';
public const STATUS_AUTHORIZED = 'authorized';
public const STATUS_EXPIRED = 'expired';
public const STATUS_FAILED = 'failed';
public const STATUS_PAID = 'paid';
public const TYPE_ONEOFF = 'oneoff';
public const TYPE_RECURRING = 'recurring';
public const TYPE_MANDATE = 'mandate';
public const TYPE_REFUND = 'refund';
public const TYPE_CHARGEBACK = 'chargeback';
/** const int Minimum amount of money in a single payment (in cents) */
public const MIN_AMOUNT = 1000;
/**
* Factory method
*
* @param \App\Wallet|string|null $provider_or_wallet
*/
public static function factory($provider_or_wallet = null)
{
if ($provider_or_wallet instanceof Wallet) {
if ($provider_or_wallet->getSetting('stripe_id')) {
$provider = 'stripe';
} elseif ($provider_or_wallet->getSetting('mollie_id')) {
$provider = 'mollie';
}
} else {
$provider = $provider_or_wallet;
}
if (empty($provider)) {
$provider = \config('services.payment_provider') ?: 'mollie';
}
switch (\strtolower($provider)) {
case 'stripe':
return new \App\Providers\Payment\Stripe();
case 'mollie':
return new \App\Providers\Payment\Mollie();
default:
throw new \Exception("Invalid payment provider: {$provider}");
}
}
/**
* Create a new auto-payment mandate for a wallet.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data:
* - amount: Value in cents
* - currency: The operation currency
* - description: Operation desc.
*
* @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.
* - isPending: the process didn't complete yet
* - isValid: the mandate is valid
*/
abstract public function getMandate(Wallet $wallet): ?array;
/**
* Get a link to the customer in the provider's control panel
*
* @param \App\Wallet $wallet The wallet
*
* @return string|null The string representing <a> tag
*/
abstract public function customerLink(Wallet $wallet): ?string;
/**
* Get a provider name
*
* @return string Provider name
*/
abstract public function name(): string;
/**
* Create a new payment.
*
* @param \App\Wallet $wallet The wallet
* @param array $payment Payment data:
* - amount: Value in cents
* - currency: The operation currency
* - type: first/oneoff/recurring
* - description: Operation description
*
* @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->save();
return $db_payment;
}
/**
* 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;
}
$wallet->balance -= $refund['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' => $refund['amount'],
+ 'amount' => $refund['amount'] * -1,
'description' => $refund['description'] ?? '',
]);
$refund['status'] = self::STATUS_PAID;
$refund['amount'] *= -1;
$this->storePayment($refund, $wallet->id);
}
}
diff --git a/src/app/Transaction.php b/src/app/Transaction.php
index 08138ff4..684d8f3a 100644
--- a/src/app/Transaction.php
+++ b/src/app/Transaction.php
@@ -1,197 +1,199 @@
<?php
namespace App;
use App\Entitlement;
use App\Wallet;
use Illuminate\Database\Eloquent\Model;
/**
* The eloquent definition of a Transaction.
*
* @property int $amount
* @property string $description
* @property string $id
* @property string $object_id
* @property string $object_type
* @property string $type
* @property string $transaction_id
* @property string $user_email
*/
class Transaction extends Model
{
public const ENTITLEMENT_BILLED = 'billed';
public const ENTITLEMENT_CREATED = 'created';
public const ENTITLEMENT_DELETED = 'deleted';
public const WALLET_AWARD = 'award';
public const WALLET_CREDIT = 'credit';
public const WALLET_DEBIT = 'debit';
public const WALLET_PENALTY = 'penalty';
public const WALLET_REFUND = 'refund';
public const WALLET_CHARGEBACK = 'chback';
protected $fillable = [
// actor, if any
'user_email',
// entitlement, wallet
'object_id',
'object_type',
// entitlement: created, deleted, billed
// wallet: debit, credit, award, penalty
'type',
'amount',
'description',
// parent, for example wallet debit is parent for entitlements charged.
'transaction_id'
];
/** @var array Casts properties as type */
protected $casts = [
'amount' => 'integer',
];
/** @var boolean This model uses an automatically incrementing integer primary key? */
public $incrementing = false;
/** @var string The type of the primary key */
protected $keyType = 'string';
/**
* Returns the entitlement to which the transaction is assigned (if any)
*
* @return \App\Entitlement|null The entitlement
*/
public function entitlement(): ?Entitlement
{
if ($this->object_type !== Entitlement::class) {
return null;
}
return Entitlement::withTrashed()->find($this->object_id);
}
/**
* Transaction type mutator
*
* @throws \Exception
*/
public function setTypeAttribute($value): void
{
switch ($value) {
case self::ENTITLEMENT_BILLED:
case self::ENTITLEMENT_CREATED:
case self::ENTITLEMENT_DELETED:
// TODO: Must be an entitlement.
$this->attributes['type'] = $value;
break;
case self::WALLET_AWARD:
case self::WALLET_CREDIT:
case self::WALLET_DEBIT:
case self::WALLET_PENALTY:
case self::WALLET_REFUND:
case self::WALLET_CHARGEBACK:
// TODO: This must be a wallet.
$this->attributes['type'] = $value;
break;
default:
throw new \Exception("Invalid type value");
}
}
/**
* Returns a short text describing the transaction.
*
* @return string The description
*/
public function shortDescription(): string
{
$label = $this->objectTypeToLabelString() . '-' . $this->{'type'} . '-short';
$result = \trans("transactions.{$label}", $this->descriptionParams());
return trim($result, ': ');
}
/**
* Returns a text describing the transaction.
*
* @return string The description
*/
public function toString(): string
{
$label = $this->objectTypeToLabelString() . '-' . $this->{'type'};
return \trans("transactions.{$label}", $this->descriptionParams());
}
/**
* Returns a wallet to which the transaction is assigned (if any)
*
* @return \App\Wallet|null The wallet
*/
public function wallet(): ?Wallet
{
if ($this->object_type !== Wallet::class) {
return null;
}
return Wallet::find($this->object_id);
}
/**
* Collect transaction parameters used in (localized) descriptions
*
* @return array Parameters
*/
private function descriptionParams(): array
{
$result = [
'user_email' => $this->user_email,
'description' => $this->{'description'},
];
+ $amount = $this->amount * ($this->amount < 0 ? -1 : 1);
+
if ($entitlement = $this->entitlement()) {
$wallet = $entitlement->wallet;
$cost = $entitlement->cost;
$discount = $entitlement->wallet->getDiscountRate();
$result['entitlement_cost'] = $cost * $discount;
$result['object'] = $entitlement->entitleableTitle();
$result['sku_title'] = $entitlement->sku->{'title'};
} else {
$wallet = $this->wallet();
}
$result['wallet'] = $wallet->{'description'} ?: 'Default wallet';
- $result['amount'] = $wallet->money($this->amount);
+ $result['amount'] = $wallet->money($amount);
return $result;
}
/**
* Get a string for use in translation tables derived from the object type.
*
* @return string|null
*/
private function objectTypeToLabelString(): ?string
{
if ($this->object_type == Entitlement::class) {
return 'entitlement';
}
if ($this->object_type == Wallet::class) {
return 'wallet';
}
return null;
}
}
diff --git a/src/app/Wallet.php b/src/app/Wallet.php
index e5ee16de..8c0a77ec 100644
--- a/src/app/Wallet.php
+++ b/src/app/Wallet.php
@@ -1,400 +1,400 @@
<?php
namespace App;
use App\User;
use App\Traits\SettingsTrait;
use Carbon\Carbon;
use Iatstuti\Database\Support\NullableFields;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
/**
* The eloquent definition of a wallet -- a container with a chunk of change.
*
* A wallet is owned by an {@link \App\User}.
*
* @property integer $balance
*/
class Wallet extends Model
{
use NullableFields;
use SettingsTrait;
public $incrementing = false;
protected $keyType = 'string';
public $timestamps = false;
protected $attributes = [
'balance' => 0,
'currency' => 'CHF'
];
protected $fillable = [
'currency'
];
protected $nullable = [
'description',
];
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);
}
}
public function chargeEntitlements($apply = true)
{
// This wallet has been created less than a month ago, this is the trial period
if ($this->owner->created_at >= Carbon::now()->subMonthsWithoutOverflow(1)) {
// Move all the current entitlement's updated_at timestamps forward to one month after
// this wallet was created.
$freeMonthEnds = $this->owner->created_at->copy()->addMonthsWithoutOverflow(1);
foreach ($this->entitlements()->get()->fresh() as $entitlement) {
if ($entitlement->updated_at < $freeMonthEnds) {
$entitlement->updated_at = $freeMonthEnds;
$entitlement->save();
}
}
return 0;
}
$charges = 0;
$discount = $this->getDiscountRate();
DB::beginTransaction();
// used to parent individual entitlement billings to the wallet debit.
$entitlementTransactions = [];
foreach ($this->entitlements()->get()->fresh() as $entitlement) {
// 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)) {
continue;
}
// This entitlement was created, or billed last, less than a month ago.
if ($entitlement->updated_at > Carbon::now()->subMonthsWithoutOverflow(1)) {
continue;
}
// updated last more than a month ago -- was it billed?
if ($entitlement->updated_at <= Carbon::now()->subMonthsWithoutOverflow(1)) {
$diff = $entitlement->updated_at->diffInMonths(Carbon::now());
$cost = (int) ($entitlement->cost * $discount * $diff);
$charges += $cost;
// 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;
}
$entitlementTransactions[] = $entitlement->createTransaction(
\App\Transaction::ENTITLEMENT_BILLED,
$cost
);
}
}
if ($apply) {
$this->debit($charges, $entitlementTransactions);
}
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;
}
// retrieve any expected charges
$expectedCharge = $this->expectedCharges();
// get the costs per day for all entitlements billed against this wallet
$costsPerDay = $this->costsPerDay();
if (!$costsPerDay) {
return null;
}
// the number of days this balance, minus the expected charges, would last
$daysDelta = ($this->balance - $expectedCharge) / $costsPerDay;
// calculate from the last entitlement billed
$entitlement = $this->entitlements()->orderBy('updated_at', 'desc')->first();
$until = $entitlement->updated_at->copy()->addDays($daysDelta);
// Don't return dates from the past
if ($until < Carbon::now() && !$until->isToday()) {
return null;
}
return $until;
}
/**
* Controllers of this wallet.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function controllers()
{
return $this->belongsToMany(
'App\User', // The foreign object definition
'user_accounts', // The table name
'wallet_id', // The local foreign key
'user_id' // The remote foreign key
);
}
/**
* Retrieve the costs per day of everything charged to this wallet.
*
* @return float
*/
public function costsPerDay()
{
$costs = (float) 0;
foreach ($this->entitlements as $entitlement) {
$costs += $entitlement->costsPerDay();
}
return $costs;
}
/**
* 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
*
* @return Wallet Self
*/
public function credit(int $amount, string $description = ''): Wallet
{
$this->balance += $amount;
$this->save();
\App\Transaction::create(
[
'object_id' => $this->id,
'object_type' => \App\Wallet::class,
'type' => \App\Transaction::WALLET_CREDIT,
'amount' => $amount,
'description' => $description
]
);
return $this;
}
/**
* Deduct an amount of pecunia from this wallet's balance.
*
* @param int $amount The amount of pecunia to deduct (in cents).
* @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, array $eTIDs = []): Wallet
{
if ($amount == 0) {
return $this;
}
$this->balance -= $amount;
$this->save();
$transaction = \App\Transaction::create(
[
'object_id' => $this->id,
'object_type' => \App\Wallet::class,
'type' => \App\Transaction::WALLET_DEBIT,
- 'amount' => $amount
+ 'amount' => $amount * -1
]
);
\App\Transaction::whereIn('id', $eTIDs)->update(['transaction_id' => $transaction->id]);
return $this;
}
/**
* The discount assigned to the wallet.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function discount()
{
return $this->belongsTo('App\Discount', 'discount_id', 'id');
}
/**
* Entitlements billed to this wallet.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function entitlements()
{
return $this->hasMany('App\Entitlement');
}
/**
* 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;
}
/**
* 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);
// Prefer intl extension's number formatter
if (class_exists('NumberFormatter')) {
$nf = new \NumberFormatter($locale, \NumberFormatter::CURRENCY);
$result = $nf->formatCurrency($amount, $this->currency);
// Replace non-breaking space
return str_replace("\xC2\xA0", " ", $result);
}
return sprintf('%.2f %s', $amount, $this->currency);
}
/**
* 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('App\User', 'user_id', 'id');
}
/**
* Payments on this wallet.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function payments()
{
return $this->hasMany('App\Payment');
}
/**
* 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);
}
}
/**
* Any (additional) properties of this wallet.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function settings()
{
return $this->hasMany('App\WalletSetting');
}
/**
* Retrieve the transactions against this wallet.
*
* @return \Illuminate\Database\Eloquent\Builder Query builder
*/
public function transactions()
{
return \App\Transaction::where(
[
'object_id' => $this->id,
'object_type' => \App\Wallet::class
]
);
}
}
diff --git a/src/database/migrations/2021_02_19_100000_transaction_amount_fix.php b/src/database/migrations/2021_02_19_100000_transaction_amount_fix.php
new file mode 100644
index 00000000..19277aa2
--- /dev/null
+++ b/src/database/migrations/2021_02_19_100000_transaction_amount_fix.php
@@ -0,0 +1,38 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Support\Facades\DB;
+
+// phpcs:ignore
+class TransactionAmountFix extends Migration
+{
+ /**
+ * Run the migrations.
+ *
+ * @return void
+ */
+ public function up()
+ {
+ $negatives = [
+ \App\Transaction::WALLET_CHARGEBACK,
+ \App\Transaction::WALLET_DEBIT,
+ \App\Transaction::WALLET_PENALTY,
+ \App\Transaction::WALLET_REFUND,
+ ];
+
+ $query = "UPDATE transactions SET amount = amount * -1"
+ . " WHERE type IN (" . implode(',', array_fill(0, count($negatives), '?')) . ")";
+
+ DB::select($query, $negatives);
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ DB::select("UPDATE transactions SET amount = amount * -1 WHERE amount < 0");
+ }
+}
diff --git a/src/tests/Feature/Controller/Admin/WalletsTest.php b/src/tests/Feature/Controller/Admin/WalletsTest.php
index c949dbc9..5b8c4fb6 100644
--- a/src/tests/Feature/Controller/Admin/WalletsTest.php
+++ b/src/tests/Feature/Controller/Admin/WalletsTest.php
@@ -1,228 +1,228 @@
<?php
namespace Tests\Feature\Controller\Admin;
use App\Discount;
use App\Transaction;
use Tests\TestCase;
class WalletsTest extends TestCase
{
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
self::useAdminUrl();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
parent::tearDown();
}
/**
* Test fetching a wallet (GET /api/v4/wallets/:id)
*
* @group stripe
*/
public function testShow(): void
{
\config(['services.payment_provider' => 'stripe']);
$user = $this->getTestUser('john@kolab.org');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$wallet = $user->wallets()->first();
$wallet->discount_id = null;
$wallet->save();
// Make sure there's no stripe/mollie identifiers
$wallet->setSetting('stripe_id', null);
$wallet->setSetting('stripe_mandate_id', null);
$wallet->setSetting('mollie_id', null);
$wallet->setSetting('mollie_mandate_id', null);
// Non-admin user
$response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}");
$response->assertStatus(403);
// Admin user
$response = $this->actingAs($admin)->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->assertSame(0, $json['discount']);
$this->assertTrue(empty($json['description']));
$this->assertTrue(empty($json['discount_description']));
$this->assertTrue(!empty($json['provider']));
$this->assertTrue(empty($json['providerLink']));
$this->assertTrue(!empty($json['mandate']));
}
/**
* Test awarding/penalizing a wallet (POST /api/v4/wallets/:id/one-off)
*/
public function testOneOff(): void
{
$user = $this->getTestUser('john@kolab.org');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$wallet = $user->wallets()->first();
$balance = $wallet->balance;
Transaction::where('object_id', $wallet->id)
->whereIn('type', [Transaction::WALLET_AWARD, Transaction::WALLET_PENALTY])
->delete();
// Non-admin user
$response = $this->actingAs($user)->post("api/v4/wallets/{$wallet->id}/one-off", []);
$response->assertStatus(403);
// Admin user - invalid input
$post = ['amount' => 'aaaa'];
$response = $this->actingAs($admin)->post("api/v4/wallets/{$wallet->id}/one-off", $post);
$response->assertStatus(422);
$json = $response->json();
$this->assertSame('error', $json['status']);
$this->assertSame('The amount must be a number.', $json['errors']['amount'][0]);
$this->assertSame('The description field is required.', $json['errors']['description'][0]);
$this->assertCount(2, $json);
$this->assertCount(2, $json['errors']);
// Admin user - a valid bonus
$post = ['amount' => '50', 'description' => 'A bonus'];
$response = $this->actingAs($admin)->post("api/v4/wallets/{$wallet->id}/one-off", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame('The bonus has been added to the wallet successfully.', $json['message']);
$this->assertSame($balance += 5000, $json['balance']);
$this->assertSame($balance, $wallet->fresh()->balance);
$transaction = Transaction::where('object_id', $wallet->id)
->where('type', Transaction::WALLET_AWARD)->first();
$this->assertSame($post['description'], $transaction->description);
$this->assertSame(5000, $transaction->amount);
$this->assertSame($admin->email, $transaction->user_email);
// Admin user - a valid penalty
$post = ['amount' => '-40', 'description' => 'A penalty'];
$response = $this->actingAs($admin)->post("api/v4/wallets/{$wallet->id}/one-off", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame('The penalty has been added to the wallet successfully.', $json['message']);
$this->assertSame($balance -= 4000, $json['balance']);
$this->assertSame($balance, $wallet->fresh()->balance);
$transaction = Transaction::where('object_id', $wallet->id)
->where('type', Transaction::WALLET_PENALTY)->first();
$this->assertSame($post['description'], $transaction->description);
- $this->assertSame(4000, $transaction->amount);
+ $this->assertSame(-4000, $transaction->amount);
$this->assertSame($admin->email, $transaction->user_email);
}
/**
* Test fetching wallet transactions (GET /api/v4/wallets/:id/transactions)
*/
public function testTransactions(): void
{
// Note: Here we're testing only that the end-point works,
// and admin can get the transaction log, response details
// are tested in Feature/Controller/WalletsTest.php
$this->deleteTestUser('wallets-controller@kolabnow.com');
$user = $this->getTestUser('wallets-controller@kolabnow.com');
$wallet = $user->wallets()->first();
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
// Non-admin
$response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions");
$response->assertStatus(403);
// Create some sample transactions
$transactions = $this->createTestTransactions($wallet);
$transactions = array_reverse($transactions);
$pages = array_chunk($transactions, 10 /* page size*/);
// Get the 2nd page
$response = $this->actingAs($admin)->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->assertFalse($json['list'][$idx]['hasDetails']);
}
// The 'user' key is set only on the admin end-point
$this->assertSame('jeroen@jeroen.jeroen', $json['list'][1]['user']);
}
/**
* Test updating a wallet (PUT /api/v4/wallets/:id)
*/
public function testUpdate(): void
{
$user = $this->getTestUser('john@kolab.org');
$admin = $this->getTestUser('jeroen@jeroen.jeroen');
$wallet = $user->wallets()->first();
$discount = Discount::where('code', 'TEST')->first();
// Non-admin user
$response = $this->actingAs($user)->put("api/v4/wallets/{$wallet->id}", []);
$response->assertStatus(403);
// Admin user - setting a discount
$post = ['discount' => $discount->id];
$response = $this->actingAs($admin)->put("api/v4/wallets/{$wallet->id}", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame('User wallet updated successfully.', $json['message']);
$this->assertSame($wallet->id, $json['id']);
$this->assertSame($discount->discount, $json['discount']);
$this->assertSame($discount->id, $json['discount_id']);
$this->assertSame($discount->description, $json['discount_description']);
$this->assertSame($discount->id, $wallet->fresh()->discount->id);
// Admin user - removing a discount
$post = ['discount' => null];
$response = $this->actingAs($admin)->put("api/v4/wallets/{$wallet->id}", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertSame('User wallet updated successfully.', $json['message']);
$this->assertSame($wallet->id, $json['id']);
$this->assertSame(null, $json['discount_id']);
$this->assertTrue(empty($json['discount_description']));
$this->assertSame(null, $wallet->fresh()->discount);
}
}
diff --git a/src/tests/Feature/Controller/PaymentsMollieTest.php b/src/tests/Feature/Controller/PaymentsMollieTest.php
index 9aa36824..ef77477a 100644
--- a/src/tests/Feature/Controller/PaymentsMollieTest.php
+++ b/src/tests/Feature/Controller/PaymentsMollieTest.php
@@ -1,815 +1,815 @@
<?php
namespace Tests\Feature\Controller;
use App\Http\Controllers\API\V4\PaymentsController;
use App\Payment;
use App\Providers\PaymentProvider;
use App\Transaction;
use App\Wallet;
use App\WalletSetting;
use GuzzleHttp\Psr7\Response;
use Illuminate\Support\Facades\Bus;
use Tests\TestCase;
use Tests\BrowserAddonTrait;
use Tests\MollieMocksTrait;
class PaymentsMollieTest extends TestCase
{
use MollieMocksTrait;
use BrowserAddonTrait;
/**
* {@inheritDoc}
*/
public function setUp(): void
{
parent::setUp();
// All tests in this file use Mollie
\config(['services.payment_provider' => 'mollie']);
$john = $this->getTestUser('john@kolab.org');
$wallet = $john->wallets()->first();
Payment::where('wallet_id', $wallet->id)->delete();
Wallet::where('id', $wallet->id)->update(['balance' => 0]);
WalletSetting::where('wallet_id', $wallet->id)->delete();
$types = [
Transaction::WALLET_CREDIT,
Transaction::WALLET_REFUND,
Transaction::WALLET_CHARGEBACK,
];
Transaction::where('object_id', $wallet->id)->whereIn('type', $types)->delete();
}
/**
* {@inheritDoc}
*/
public function tearDown(): void
{
$john = $this->getTestUser('john@kolab.org');
$wallet = $john->wallets()->first();
Payment::where('wallet_id', $wallet->id)->delete();
Wallet::where('id', $wallet->id)->update(['balance' => 0]);
WalletSetting::where('wallet_id', $wallet->id)->delete();
$types = [
Transaction::WALLET_CREDIT,
Transaction::WALLET_REFUND,
Transaction::WALLET_CHARGEBACK,
];
Transaction::where('object_id', $wallet->id)->whereIn('type', $types)->delete();
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 = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF';
$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];
$response = $this->actingAs($user)->post("api/v4/payments/mandate", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertRegExp('|^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("Kolab Now 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');
$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 = intval(PaymentProvider::MIN_AMOUNT / 100) . ' CHF';
$this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']);
$post = ['amount' => '12.34'];
$response = $this->actingAs($user)->post("api/v4/payments", $post);
$response->assertStatus(200);
$json = $response->json();
$this->assertSame('success', $json['status']);
$this->assertRegExp('|^https://www.mollie.com|', $json['redirectUrl']);
$wallet = $user->wallets()->first();
$payments = Payment::where('wallet_id', $wallet->id)->get();
$this->assertCount(1, $payments);
$payment = $payments[0];
$this->assertSame(1234, $payment->amount);
$this->assertSame(\config('app.name') . ' 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('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);
// 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(2010, $payments[1]->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 (**** **** **** 6787)",
$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('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,
'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(-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(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(-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'])
->click('input[value="paid"]')
->click('button.form__button');
$this->stopBrowser();
}
}
diff --git a/src/tests/TestCaseTrait.php b/src/tests/TestCaseTrait.php
index c8bf36bf..e27166f4 100644
--- a/src/tests/TestCaseTrait.php
+++ b/src/tests/TestCaseTrait.php
@@ -1,244 +1,245 @@
<?php
namespace Tests;
use App\Domain;
use App\Group;
use App\Transaction;
use App\User;
use Carbon\Carbon;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\Queue;
use PHPUnit\Framework\Assert;
trait TestCaseTrait
{
/**
* Assert user entitlements state
*/
protected function assertUserEntitlements($user, $expected)
{
// Assert the user entitlements
$skus = $user->entitlements()->get()
->map(function ($ent) {
return $ent->sku->title;
})
->toArray();
sort($skus);
Assert::assertSame($expected, $skus);
}
/**
* Removes all beta entitlements from the database
*/
protected function clearBetaEntitlements(): void
{
$betas = \App\Sku::where('handler_class', 'like', 'App\\Handlers\\Beta\\%')
->orWhere('handler_class', 'App\Handlers\Beta')
->pluck('id')->all();
\App\Entitlement::whereIn('sku_id', $betas)->delete();
}
/**
* Creates the application.
*
* @return \Illuminate\Foundation\Application
*/
public function createApplication()
{
$app = require __DIR__ . '/../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
return $app;
}
/**
* Create a set of transaction log entries for a wallet
*/
protected function createTestTransactions($wallet)
{
$result = [];
$date = Carbon::now();
$debit = 0;
$entitlementTransactions = [];
foreach ($wallet->entitlements as $entitlement) {
if ($entitlement->cost) {
$debit += $entitlement->cost;
$entitlementTransactions[] = $entitlement->createTransaction(
Transaction::ENTITLEMENT_BILLED,
$entitlement->cost
);
}
}
$transaction = Transaction::create([
'user_email' => 'jeroen@jeroen.jeroen',
'object_id' => $wallet->id,
'object_type' => \App\Wallet::class,
'type' => Transaction::WALLET_DEBIT,
- 'amount' => $debit,
+ 'amount' => $debit * -1,
'description' => 'Payment',
]);
$result[] = $transaction;
Transaction::whereIn('id', $entitlementTransactions)->update(['transaction_id' => $transaction->id]);
$transaction = Transaction::create([
'user_email' => null,
'object_id' => $wallet->id,
'object_type' => \App\Wallet::class,
'type' => Transaction::WALLET_CREDIT,
'amount' => 2000,
'description' => 'Payment',
]);
$transaction->created_at = $date->next(Carbon::MONDAY);
$transaction->save();
$result[] = $transaction;
$types = [
Transaction::WALLET_AWARD,
Transaction::WALLET_PENALTY,
];
// The page size is 10, so we generate so many to have at least two pages
$loops = 10;
while ($loops-- > 0) {
+ $type = $types[count($result) % count($types)];
$transaction = Transaction::create([
'user_email' => 'jeroen.@jeroen.jeroen',
'object_id' => $wallet->id,
'object_type' => \App\Wallet::class,
- 'type' => $types[count($result) % count($types)],
- 'amount' => 11 * (count($result) + 1),
+ 'type' => $type,
+ 'amount' => 11 * (count($result) + 1) * ($type == Transaction::WALLET_PENALTY ? -1 : 1),
'description' => 'TRANS' . $loops,
]);
$transaction->created_at = $date->next(Carbon::MONDAY);
$transaction->save();
$result[] = $transaction;
}
return $result;
}
protected function deleteTestDomain($name)
{
Queue::fake();
$domain = Domain::withTrashed()->where('namespace', $name)->first();
if (!$domain) {
return;
}
$job = new \App\Jobs\Domain\DeleteJob($domain->id);
$job->handle();
$domain->forceDelete();
}
protected function deleteTestGroup($email)
{
Queue::fake();
$group = Group::withTrashed()->where('email', $email)->first();
if (!$group) {
return;
}
$job = new \App\Jobs\Group\DeleteJob($group->id);
$job->handle();
$group->forceDelete();
}
protected function deleteTestUser($email)
{
Queue::fake();
$user = User::withTrashed()->where('email', $email)->first();
if (!$user) {
return;
}
$job = new \App\Jobs\User\DeleteJob($user->id);
$job->handle();
$user->forceDelete();
}
/**
* Get Domain object by namespace, create it if needed.
* Skip LDAP jobs.
*/
protected function getTestDomain($name, $attrib = [])
{
// Disable jobs (i.e. skip LDAP oprations)
Queue::fake();
return Domain::firstOrCreate(['namespace' => $name], $attrib);
}
/**
* Get Group object by email, create it if needed.
* Skip LDAP jobs.
*/
protected function getTestGroup($email, $attrib = [])
{
// Disable jobs (i.e. skip LDAP oprations)
Queue::fake();
return Group::firstOrCreate(['email' => $email], $attrib);
}
/**
* Get User object by email, create it if needed.
* Skip LDAP jobs.
*/
protected function getTestUser($email, $attrib = [])
{
// Disable jobs (i.e. skip LDAP oprations)
Queue::fake();
$user = User::firstOrCreate(['email' => $email], $attrib);
if ($user->trashed()) {
// Note: we do not want to use user restore here
User::where('id', $user->id)->forceDelete();
$user = User::create(['email' => $email] + $attrib);
}
return $user;
}
/**
* Helper to access protected property of an object
*/
protected static function getObjectProperty($object, $property_name)
{
$reflection = new \ReflectionClass($object);
$property = $reflection->getProperty($property_name);
$property->setAccessible(true);
return $property->getValue($object);
}
/**
* Call protected/private method of a class.
*
* @param object $object Instantiated object that we will run method on.
* @param string $methodName Method name to call
* @param array $parameters Array of parameters to pass into method.
*
* @return mixed Method return.
*/
protected function invokeMethod($object, $methodName, array $parameters = array())
{
$reflection = new \ReflectionClass(get_class($object));
$method = $reflection->getMethod($methodName);
$method->setAccessible(true);
return $method->invokeArgs($object, $parameters);
}
}
diff --git a/src/tests/Unit/TransactionTest.php b/src/tests/Unit/TransactionTest.php
index 15c9976b..89898ba2 100644
--- a/src/tests/Unit/TransactionTest.php
+++ b/src/tests/Unit/TransactionTest.php
@@ -1,205 +1,205 @@
<?php
namespace Tests\Unit;
use App\Entitlement;
use App\Sku;
use App\Transaction;
use App\Wallet;
use Tests\TestCase;
class TransactionTest extends TestCase
{
/**
* Test transaction short and long labels
*/
public function testLabels(): void
{
// Prepare test environment
Transaction::where('amount', '<', 20)->delete();
$user = $this->getTestUser('jane@kolabnow.com');
$wallet = $user->wallets()->first();
// Create transactions
$transaction = Transaction::create([
'object_id' => $wallet->id,
'object_type' => Wallet::class,
'type' => Transaction::WALLET_PENALTY,
- 'amount' => 9,
+ 'amount' => -10,
'description' => "A test penalty"
]);
$transaction = Transaction::create([
'object_id' => $wallet->id,
'object_type' => Wallet::class,
'type' => Transaction::WALLET_DEBIT,
- 'amount' => 10
+ 'amount' => -9
]);
$transaction = Transaction::create([
'object_id' => $wallet->id,
'object_type' => Wallet::class,
'type' => Transaction::WALLET_CREDIT,
'amount' => 11
]);
$transaction = Transaction::create([
'object_id' => $wallet->id,
'object_type' => Wallet::class,
'type' => Transaction::WALLET_AWARD,
'amount' => 12,
'description' => "A test award"
]);
$sku = Sku::where('title', 'mailbox')->first();
$entitlement = Entitlement::where('sku_id', $sku->id)->first();
$transaction = Transaction::create([
'user_email' => 'test@test.com',
'object_id' => $entitlement->id,
'object_type' => Entitlement::class,
'type' => Transaction::ENTITLEMENT_CREATED,
'amount' => 13
]);
$sku = Sku::where('title', 'domain-hosting')->first();
$entitlement = Entitlement::where('sku_id', $sku->id)->first();
$transaction = Transaction::create([
'user_email' => 'test@test.com',
'object_id' => $entitlement->id,
'object_type' => Entitlement::class,
'type' => Transaction::ENTITLEMENT_BILLED,
'amount' => 14
]);
$sku = Sku::where('title', 'storage')->first();
$entitlement = Entitlement::where('sku_id', $sku->id)->first();
$transaction = Transaction::create([
'user_email' => 'test@test.com',
'object_id' => $entitlement->id,
'object_type' => Entitlement::class,
'type' => Transaction::ENTITLEMENT_DELETED,
'amount' => 15
]);
$transactions = Transaction::where('amount', '<', 20)->orderBy('amount')->get();
- $this->assertSame(9, $transactions[0]->amount);
+ $this->assertSame(-10, $transactions[0]->amount);
$this->assertSame(Transaction::WALLET_PENALTY, $transactions[0]->type);
$this->assertSame(
- "The balance of Default wallet was reduced by 0,09 CHF; A test penalty",
+ "The balance of Default wallet was reduced by 0,10 CHF; A test penalty",
$transactions[0]->toString()
);
$this->assertSame(
"Charge: A test penalty",
$transactions[0]->shortDescription()
);
- $this->assertSame(10, $transactions[1]->amount);
+ $this->assertSame(-9, $transactions[1]->amount);
$this->assertSame(Transaction::WALLET_DEBIT, $transactions[1]->type);
$this->assertSame(
- "0,10 CHF was deducted from the balance of Default wallet",
+ "0,09 CHF was deducted from the balance of Default wallet",
$transactions[1]->toString()
);
$this->assertSame(
"Deduction",
$transactions[1]->shortDescription()
);
$this->assertSame(11, $transactions[2]->amount);
$this->assertSame(Transaction::WALLET_CREDIT, $transactions[2]->type);
$this->assertSame(
"0,11 CHF was added to the balance of Default wallet",
$transactions[2]->toString()
);
$this->assertSame(
"Payment",
$transactions[2]->shortDescription()
);
$this->assertSame(12, $transactions[3]->amount);
$this->assertSame(Transaction::WALLET_AWARD, $transactions[3]->type);
$this->assertSame(
"Bonus of 0,12 CHF awarded to Default wallet; A test award",
$transactions[3]->toString()
);
$this->assertSame(
"Bonus: A test award",
$transactions[3]->shortDescription()
);
$ent = $transactions[4]->entitlement();
$this->assertSame(13, $transactions[4]->amount);
$this->assertSame(Transaction::ENTITLEMENT_CREATED, $transactions[4]->type);
$this->assertSame(
"test@test.com created mailbox for " . $ent->entitleableTitle(),
$transactions[4]->toString()
);
$this->assertSame(
"Added mailbox for " . $ent->entitleableTitle(),
$transactions[4]->shortDescription()
);
$ent = $transactions[5]->entitlement();
$this->assertSame(14, $transactions[5]->amount);
$this->assertSame(Transaction::ENTITLEMENT_BILLED, $transactions[5]->type);
$this->assertSame(
sprintf("%s for %s is billed at 0,14 CHF", $ent->sku->title, $ent->entitleableTitle()),
$transactions[5]->toString()
);
$this->assertSame(
sprintf("Billed %s for %s", $ent->sku->title, $ent->entitleableTitle()),
$transactions[5]->shortDescription()
);
$ent = $transactions[6]->entitlement();
$this->assertSame(15, $transactions[6]->amount);
$this->assertSame(Transaction::ENTITLEMENT_DELETED, $transactions[6]->type);
$this->assertSame(
sprintf("test@test.com deleted %s for %s", $ent->sku->title, $ent->entitleableTitle()),
$transactions[6]->toString()
);
$this->assertSame(
sprintf("Deleted %s for %s", $ent->sku->title, $ent->entitleableTitle()),
$transactions[6]->shortDescription()
);
}
/**
* Test that an exception is being thrown on invalid type
*/
public function testInvalidType(): void
{
$this->expectException(\Exception::class);
$transaction = Transaction::create(
[
'object_id' => 'fake-id',
'object_type' => Wallet::class,
'type' => 'invalid',
'amount' => 9
]
);
}
public function testEntitlementForWallet(): void
{
$transaction = \App\Transaction::where('object_type', \App\Wallet::class)
->whereIn('object_id', \App\Wallet::pluck('id'))->first();
$entitlement = $transaction->entitlement();
$this->assertNull($entitlement);
$this->assertNotNull($transaction->wallet());
}
public function testWalletForEntitlement(): void
{
$transaction = \App\Transaction::where('object_type', \App\Entitlement::class)
->whereIn('object_id', \App\Entitlement::pluck('id'))->first();
$wallet = $transaction->wallet();
$this->assertNull($wallet);
$this->assertNotNull($transaction->entitlement());
}
}

File Metadata

Mime Type
text/x-diff
Expires
Wed, Feb 4, 1:10 AM (9 h, 37 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
427441
Default Alt Text
(95 KB)

Event Timeline