Page MenuHomePhorge

D5012.1775217759.diff
No OneTemporary

Authored By
Unknown
Size
54 KB
Referenced Files
None
Subscribers
None

D5012.1775217759.diff

diff --git a/src/app/Console/Commands/Scalpel/ReferralProgram/CreateCommand.php b/src/app/Console/Commands/Scalpel/ReferralProgram/CreateCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/Scalpel/ReferralProgram/CreateCommand.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace App\Console\Commands\Scalpel\ReferralProgram;
+
+use App\Console\ObjectCreateCommand;
+
+class CreateCommand extends ObjectCreateCommand
+{
+ protected $hidden = true;
+
+ protected $commandPrefix = 'scalpel';
+ protected $objectClass = \App\ReferralProgram::class;
+ protected $objectName = 'referral-program';
+ protected $objectTitle = null;
+}
diff --git a/src/app/Console/Commands/Scalpel/ReferralProgram/DeleteCommand.php b/src/app/Console/Commands/Scalpel/ReferralProgram/DeleteCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/Scalpel/ReferralProgram/DeleteCommand.php
@@ -0,0 +1,16 @@
+<?php
+
+namespace App\Console\Commands\Scalpel\ReferralProgram;
+
+use App\Console\ObjectDeleteCommand;
+
+class DeleteCommand extends ObjectDeleteCommand
+{
+ protected $dangerous = true;
+ protected $hidden = true;
+
+ protected $commandPrefix = 'scalpel';
+ protected $objectClass = \App\ReferralProgram::class;
+ protected $objectName = 'referral-program';
+ protected $objectTitle = null;
+}
diff --git a/src/app/Console/Commands/Scalpel/ReferralProgram/ReadCommand.php b/src/app/Console/Commands/Scalpel/ReferralProgram/ReadCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/Scalpel/ReferralProgram/ReadCommand.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace App\Console\Commands\Scalpel\ReferralProgram;
+
+use App\Console\ObjectReadCommand;
+
+class ReadCommand extends ObjectReadCommand
+{
+ protected $hidden = true;
+
+ protected $commandPrefix = 'scalpel';
+ protected $objectClass = \App\ReferralProgram::class;
+ protected $objectName = 'referral-program';
+ protected $objectTitle = null;
+}
diff --git a/src/app/Console/Commands/Scalpel/ReferralProgram/UpdateCommand.php b/src/app/Console/Commands/Scalpel/ReferralProgram/UpdateCommand.php
new file mode 100644
--- /dev/null
+++ b/src/app/Console/Commands/Scalpel/ReferralProgram/UpdateCommand.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace App\Console\Commands\Scalpel\ReferralProgram;
+
+use App\Console\ObjectUpdateCommand;
+
+class UpdateCommand extends ObjectUpdateCommand
+{
+ protected $hidden = true;
+
+ protected $commandPrefix = 'scalpel';
+ protected $objectClass = \App\ReferralProgram::class;
+ protected $objectName = 'referral-program';
+ protected $objectTitle = null;
+}
diff --git a/src/app/Console/ObjectCreateCommand.php b/src/app/Console/ObjectCreateCommand.php
--- a/src/app/Console/ObjectCreateCommand.php
+++ b/src/app/Console/ObjectCreateCommand.php
@@ -80,6 +80,7 @@
$this->info($object->{$object->getKeyName()});
} catch (\Exception $e) {
$this->error("Object could not be created.");
+ $this->line($e->getMessage());
return 1;
}
}
diff --git a/src/app/Http/Controllers/API/SignupController.php b/src/app/Http/Controllers/API/SignupController.php
--- a/src/app/Http/Controllers/API/SignupController.php
+++ b/src/app/Http/Controllers/API/SignupController.php
@@ -8,9 +8,11 @@
use App\Domain;
use App\Plan;
use App\Providers\PaymentProvider;
+use App\ReferralCode;
use App\Rules\SignupExternalEmail;
use App\Rules\SignupToken as SignupTokenRule;
use App\Rules\Password;
+use App\Rules\ReferralCode as ReferralCodeRule;
use App\Rules\UserEmailDomain;
use App\Rules\UserEmailLocal;
use App\SignupCode;
@@ -98,6 +100,7 @@
$rules['token'] = ['required', 'string', new SignupTokenRule($plan)];
} else {
$rules['email'] = ['required', 'string', new SignupExternalEmail()];
+ $rules['referral'] = ['nullable', 'string', new ReferralCodeRule()];
}
// Check required fields, validate input
@@ -114,6 +117,7 @@
'last_name' => $request->last_name,
'plan' => $plan->title,
'voucher' => $request->voucher,
+ 'referral' => $request->referral,
]);
$response = [
@@ -427,6 +431,24 @@
$request->code->save();
}
+ // Referral program
+ if (
+ $request->code
+ && $request->code->referral
+ && ($code = ReferralCode::find($request->code->referral))
+ && $code->program->active
+ ) {
+ // Keep the code-to-user relation
+ $code->referrals()->create(['user_id' => $user->id]);
+
+ // Use discount assigned to the referral program
+ if (!$request->discount && $code->program->discount && $code->program->discount->active) {
+ $wallet = $user->wallets()->first();
+ $wallet->discount()->associate($code->program->discount);
+ $wallet->save();
+ }
+ }
+
// Bump up counter on the signup token
if (!empty($request->settings['signup_token'])) {
\App\SignupToken::where('id', $request->settings['signup_token'])->increment('counter');
diff --git a/src/app/Http/Controllers/API/V4/WalletsController.php b/src/app/Http/Controllers/API/V4/WalletsController.php
--- a/src/app/Http/Controllers/API/V4/WalletsController.php
+++ b/src/app/Http/Controllers/API/V4/WalletsController.php
@@ -3,12 +3,16 @@
namespace App\Http\Controllers\API\V4;
use App\Payment;
+use App\ReferralCode;
+use App\ReferralProgram;
use App\Transaction;
use App\Wallet;
use App\Http\Controllers\ResourceController;
use App\Providers\PaymentProvider;
use Carbon\Carbon;
+use Illuminate\Database\Query\JoinClause;
use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
/**
* API\WalletsController
@@ -154,6 +158,68 @@
]);
}
+ /**
+ * Fetch active referral programs list.
+ *
+ * @param string $id Wallet identifier
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function referralPrograms($id)
+ {
+ $wallet = Wallet::find($id);
+
+ if (empty($wallet) || !$this->checkTenant($wallet->owner)) {
+ return $this->errorResponse(404);
+ }
+
+ // Only owner (or admin) has access to the wallet
+ if (!$this->guard()->user()->canRead($wallet)) {
+ return $this->errorResponse(403);
+ }
+
+ $raw_count = DB::raw('(select count(*) from referrals where referrals.code = code) as refcount');
+ $codes = ReferralCode::where('user_id', $wallet->user_id)->select('code', 'program_id', $raw_count);
+
+ $result = ReferralProgram::withObjectTenantContext($wallet->owner)
+ ->where('active', true)
+ ->leftJoinSub($codes, 'codes', function (JoinClause $join) {
+ $join->on('referral_programs.id', '=', 'codes.program_id');
+ })
+ ->select('id', 'name', 'description', 'tenant_id', 'codes.code', 'codes.refcount')
+ ->get()
+ ->map(function ($program) use ($wallet) {
+ if (empty($program->code)) {
+ // Register/Generate a code for the user if it does not exist yet
+ $code = $program->codes()->create(['user_id' => $wallet->user_id]);
+
+ $program->code = $code->code;
+ }
+
+ $code = new ReferralCode();
+ $code->code = $program->code;
+ $code->program = $program; // @phpstan-ignore-line
+
+ $entry = [
+ 'id' => $program->id,
+ 'name' => $program->name,
+ 'description' => $program->description,
+ 'refcount' => $program->refcount ?? 0,
+ 'url' => $code->signupUrl(),
+ 'qrCode' => $code->qrCode(true),
+ ];
+ return $entry;
+ });
+
+ return response()->json([
+ 'status' => 'success',
+ 'list' => $result,
+ 'count' => count($result),
+ 'hasMore' => false,
+ 'page' => 1,
+ ]);
+ }
+
/**
* Fetch wallet transactions.
*
diff --git a/src/app/Observers/ReferralCodeObserver.php b/src/app/Observers/ReferralCodeObserver.php
new file mode 100644
--- /dev/null
+++ b/src/app/Observers/ReferralCodeObserver.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace App\Observers;
+
+use App\ReferralCode;
+
+class ReferralCodeObserver
+{
+ /**
+ * Handle the "creating" event.
+ *
+ * Ensure that the code entry is created with a random code.
+ *
+ * @param ReferralCode $code The code being created.
+ *
+ * @return void
+ */
+ public function creating(ReferralCode $code): void
+ {
+ if (empty($code->code)) {
+ while (true) {
+ $code->code = ReferralCode::generateCode();
+ if (!ReferralCode::find($code->code)) {
+ break;
+ }
+ }
+ }
+ }
+}
diff --git a/src/app/Payment.php b/src/app/Payment.php
--- a/src/app/Payment.php
+++ b/src/app/Payment.php
@@ -138,6 +138,8 @@
$owner->status |= User::STATUS_ACTIVE;
$owner->save();
}
+
+ ReferralProgram::accounting($owner);
}
}
diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php
--- a/src/app/Providers/AppServiceProvider.php
+++ b/src/app/Providers/AppServiceProvider.php
@@ -50,6 +50,7 @@
\App\Meet\Room::observe(\App\Observers\Meet\RoomObserver::class);
\App\PackageSku::observe(\App\Observers\PackageSkuObserver::class);
\App\PlanPackage::observe(\App\Observers\PlanPackageObserver::class);
+ \App\ReferralCode::observe(\App\Observers\ReferralCodeObserver::class);
\App\Resource::observe(\App\Observers\ResourceObserver::class);
\App\ResourceSetting::observe(\App\Observers\ResourceSettingObserver::class);
\App\SharedFolder::observe(\App\Observers\SharedFolderObserver::class);
diff --git a/src/app/Referral.php b/src/app/Referral.php
new file mode 100644
--- /dev/null
+++ b/src/app/Referral.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace App;
+
+use Illuminate\Database\Eloquent\Model;
+
+/**
+ * The eloquent definition of a Referral (code-to-referree relation).
+ *
+ * @property string $code Referral code
+ * @property int $id Record identifier
+ * @property ?\Carbon\Carbon $redeemed_at When the award got applied
+ * @property int $user_id User identifier
+ */
+class Referral extends Model
+{
+ /** @var array<int, string> The attributes that are mass assignable */
+ protected $fillable = [
+ 'user_id',
+ 'code',
+ ];
+
+ /** @var array<string, string> The attributes that should be cast */
+ protected $casts = [
+ 'created_at' => 'datetime:Y-m-d H:i:s',
+ 'redeemed_at' => 'datetime:Y-m-d H:i:s',
+ ];
+
+ /** @var bool Indicates if the model should be timestamped. */
+ public $timestamps = false;
+
+ /**
+ * The code this referral is assigned to
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function code()
+ {
+ return $this->belongsTo(ReferralCode::class, 'code', 'code');
+ }
+
+ /**
+ * The user this referral is assigned to (referree)
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function user()
+ {
+ return $this->belongsTo(User::class, 'user_id');
+ }
+}
diff --git a/src/app/ReferralCode.php b/src/app/ReferralCode.php
new file mode 100644
--- /dev/null
+++ b/src/app/ReferralCode.php
@@ -0,0 +1,111 @@
+<?php
+
+namespace App;
+
+use BaconQrCode;
+use Illuminate\Database\Eloquent\Model;
+
+/**
+ * The eloquent definition of a ReferralCode.
+ *
+ * @property string $code Referral code
+ * @property int $program_id Referral program identifier
+ * @property int $user_id User identifier
+ */
+class ReferralCode extends Model
+{
+ public const CODE_LENGTH = 8;
+
+ /** @var bool Indicates if the IDs are auto-incrementing */
+ public $incrementing = false;
+
+ /** @var string The primary key associated with the table */
+ protected $primaryKey = 'code';
+
+ /** @var string The "type" of the auto-incrementing ID */
+ protected $keyType = 'string';
+
+ /** @var array<int, string> The attributes that are mass assignable */
+ protected $fillable = [
+ // 'code',
+ 'program_id',
+ 'user_id',
+ ];
+
+ /** @var array<string, string> The attributes that should be cast */
+ protected $casts = [
+ 'created_at' => 'datetime:Y-m-d H:i:s',
+ ];
+
+ /** @var bool Indicates if the model should be timestamped. */
+ public $timestamps = false;
+
+
+ /**
+ * Generate a random code.
+ *
+ * @return string
+ */
+ public static function generateCode(): string
+ {
+ $code_length = env('REFERRAL_CODE_LENGTH', self::CODE_LENGTH);
+
+ return \App\Utils::randStr($code_length);
+ }
+
+ /**
+ * The referral code owner (user)
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function owner()
+ {
+ return $this->belongsTo(User::class, 'user_id');
+ }
+
+ /**
+ * The referral program
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function program()
+ {
+ return $this->belongsTo(ReferralProgram::class, 'program_id');
+ }
+
+ /**
+ * The referrals using this code.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\HasMany
+ */
+ public function referrals()
+ {
+ return $this->hasMany(Referral::class, 'code', 'code');
+ }
+
+ /**
+ * The signup URL as a QR code svg image
+ */
+ public function qrCode($as_url = false): string
+ {
+ $renderer_style = new BaconQrCode\Renderer\RendererStyle\RendererStyle(300, 1);
+ $renderer_image = new BaconQrCode\Renderer\Image\SvgImageBackEnd();
+ $renderer = new BaconQrCode\Renderer\ImageRenderer($renderer_style, $renderer_image);
+ $writer = new BaconQrCode\Writer($renderer);
+ $svg = $writer->writeString($this->signupUrl());
+
+ if ($as_url) {
+ return 'data:image/svg+xml;base64,' . base64_encode($svg);
+ }
+
+ return $svg;
+ }
+
+ /**
+ * The signup URL.
+ */
+ public function signupUrl(): string
+ {
+ return \App\Utils::serviceUrl("signup/referral/{$this->code}", $this->program->tenant_id);
+ }
+}
diff --git a/src/app/ReferralProgram.php b/src/app/ReferralProgram.php
new file mode 100644
--- /dev/null
+++ b/src/app/ReferralProgram.php
@@ -0,0 +1,125 @@
+<?php
+
+namespace App;
+
+use App\Traits\BelongsToTenantTrait;
+use Illuminate\Database\Eloquent\Model;
+use Spatie\Translatable\HasTranslations;
+
+/**
+ * The eloquent definition of a ReferralProgram.
+ *
+ * @property int $award_amount Award amount (in cents) - to apply to the referrer's wallet
+ * @property int $award_percent Award percent - to apply to the referrer's wallet
+ * @property bool $active Program state
+ * @property string $description Program description
+ * @property ?string $discount_id Discount identifier - to apply to the created account
+ * @property int $id Program identifier
+ * @property string $name Program name
+ * @property int $payments_threshold Sum of payments (in cents) at which the award is applied
+ * @property ?int $tenant_id Tenant identifier
+ */
+class ReferralProgram extends Model
+{
+ use BelongsToTenantTrait;
+ use HasTranslations;
+
+ /** @var array<int, string> The attributes that are mass assignable */
+ protected $fillable = [
+ 'award_amount',
+ 'award_percent',
+ 'active',
+ 'description',
+ 'discount_id',
+ 'name',
+ 'payments_threshold',
+ 'tenant_id',
+ ];
+
+ /** @var array<string, string> The attributes that should be cast */
+ protected $casts = [
+ 'created_at' => 'datetime:Y-m-d H:i:s',
+ 'updated_at' => 'datetime:Y-m-d H:i:s',
+ 'active' => 'boolean',
+ 'award_amount' => 'integer',
+ 'award_percent' => 'integer',
+ 'payments_threshold' => 'integer',
+ ];
+
+ /** @var array<int, string> Translatable properties */
+ public $translatable = [
+ 'name',
+ 'description',
+ ];
+
+ /**
+ * Check wallet state and award the referrer if applicable.
+ *
+ * @param User $user A wallet owner (to who's any wallet a payment has been made)
+ */
+ public static function accounting(User $user): void
+ {
+ $referral = Referral::where('user_id', $user->id)->first();
+
+ // Note: For now we bail out if redeemed_at is set, but it may change
+ // in the future. We can use this timestamp to store time of the previous
+ // award operation, but it does not have to be the last one.
+ if (!$referral || $referral->redeemed_at) {
+ return;
+ }
+
+ $code = $referral->code()->first();
+ $program = $code->program;
+ $owner = $code->owner;
+
+ if (!$owner) {
+ // The code owner is soft-deleted, "close" the referral
+ $referral->redeemed_at = $referral->created_at;
+ $referral->save();
+ return;
+ }
+
+ // For now we support only one mode where award_amount is applied once after reaching payments_threshold
+ // TODO: award_percent handling and/or other future modes
+
+ if ($program->payments_threshold > 0) {
+ $payments_amount = Payment::whereIn('wallet_id', $user->wallets()->select('id'))
+ ->where('status', Payment::STATUS_PAID)
+ ->sum('credit_amount');
+
+ if ($payments_amount < $program->payments_threshold) {
+ return;
+ }
+ }
+
+ $referrer_wallet = $owner->wallets()->first();
+
+ // Note: We could insert the referree email in the description, but I think we should not
+ $description = 'Referral program award (' . $program->name . ')';
+
+ $referrer_wallet->award($program->award_amount, $description);
+
+ $referral->redeemed_at = \now();
+ $referral->save();
+ }
+
+ /**
+ * The referral codes that use this program.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\HasMany
+ */
+ public function codes()
+ {
+ return $this->hasMany(ReferralCode::class, 'program_id');
+ }
+
+ /**
+ * The discount assigned to the program.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function discount()
+ {
+ return $this->belongsTo(Discount::class, 'discount_id');
+ }
+}
diff --git a/src/app/Rules/ReferralCode.php b/src/app/Rules/ReferralCode.php
new file mode 100644
--- /dev/null
+++ b/src/app/Rules/ReferralCode.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace App\Rules;
+
+use Illuminate\Contracts\Validation\Rule;
+
+class ReferralCode implements Rule
+{
+ private $message;
+
+ /**
+ * Determine if the validation rule passes.
+ *
+ * @param string $attribute Attribute name
+ * @param mixed $code Referral code
+ */
+ public function passes($attribute, $code): bool
+ {
+ // Check the max length, according to the database column length
+ if (!is_string($code) || strlen($code) > 16) {
+ $this->message = \trans('validation.referralcodeinvalid');
+ return false;
+ }
+
+ $exists = \App\ReferralCode::where('code', $code)
+ ->join('referral_programs', 'referral_programs.id', '=', 'referral_codes.program_id')
+ ->withEnvTenantContext()
+ ->where('active', true)
+ ->exists();
+
+ if (!$exists) {
+ $this->message = \trans('validation.referralcodeinvalid');
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Get the validation error message.
+ */
+ public function message(): ?string
+ {
+ return $this->message;
+ }
+}
diff --git a/src/app/SignupCode.php b/src/app/SignupCode.php
--- a/src/app/SignupCode.php
+++ b/src/app/SignupCode.php
@@ -22,6 +22,7 @@
* @property ?string $last_name Lastname
* @property ?string $local_part Email local part
* @property ?string $plan Plan title
+ * @property ?string $referral Referral code
* @property string $short_code Short validation code
* @property \Carbon\Carbon $updated_at The update timestamp
* @property string $submit_ip_address IP address the final signup submit request came from
@@ -59,8 +60,9 @@
'first_name',
'last_name',
'plan',
+ 'referral',
'short_code',
- 'voucher'
+ 'voucher',
];
/** @var array<string, string> The attributes that should be cast */
diff --git a/src/database/migrations/2024_11_07_100000_create_referrals_tables.php b/src/database/migrations/2024_11_07_100000_create_referrals_tables.php
new file mode 100644
--- /dev/null
+++ b/src/database/migrations/2024_11_07_100000_create_referrals_tables.php
@@ -0,0 +1,96 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+ /**
+ * Run the migrations.
+ */
+ public function up(): void
+ {
+ Schema::create(
+ 'referral_programs',
+ function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->bigInteger('tenant_id')->unsigned()->nullable();
+ $table->boolean('active')->default(false);
+ $table->text('name');
+ $table->text('description');
+ $table->integer('award_amount')->default(0);
+ $table->integer('award_percent')->default(0);
+ $table->integer('payments_threshold')->default(0);
+ $table->string('discount_id', 36)->nullable();
+ $table->timestamps();
+
+ $table->foreign('tenant_id')->references('id')->on('tenants')
+ ->onDelete('set null')->onUpdate('cascade');
+ $table->foreign('discount_id')->references('id')->on('discounts')
+ ->onDelete('set null')->onUpdate('cascade');
+ }
+ );
+
+ Schema::create(
+ 'referral_codes',
+ function (Blueprint $table) {
+ $table->string('code', 16)->primary();
+ $table->bigInteger('user_id');
+ $table->bigInteger('program_id')->unsigned();
+ $table->timestamp('created_at')->useCurrent();
+
+ $table->unique(['program_id', 'user_id']);
+ $table->index('user_id');
+
+ $table->foreign('user_id')->references('id')->on('users')
+ ->onDelete('cascade')->onUpdate('cascade');
+ $table->foreign('program_id')->references('id')->on('referral_programs')
+ ->onDelete('cascade')->onUpdate('cascade');
+ }
+ );
+
+ Schema::create(
+ 'referrals',
+ function (Blueprint $table) {
+ $table->bigIncrements('id');
+ $table->string('code', 16);
+ $table->bigInteger('user_id');
+ $table->timestamp('created_at')->useCurrent();
+ $table->timestamp('redeemed_at')->nullable();
+
+ $table->unique(['user_id', 'code']);
+ $table->index('code');
+
+ $table->foreign('user_id')->references('id')->on('users')
+ ->onDelete('cascade')->onUpdate('cascade');
+ $table->foreign('code')->references('code')->on('referral_codes')
+ ->onDelete('cascade')->onUpdate('cascade');
+ }
+ );
+
+ Schema::table(
+ 'signup_codes',
+ function (Blueprint $table) {
+ $table->string('referral', 16)->nullable();
+ }
+ );
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table(
+ 'signup_codes',
+ function (Blueprint $table) {
+ $table->dropColumn('referral');
+ }
+ );
+
+ Schema::dropIfExists('referrals');
+ Schema::dropIfExists('referral_codes');
+ Schema::dropIfExists('referral_programs');
+ }
+};
diff --git a/src/package-lock.json b/src/package-lock.json
--- a/src/package-lock.json
+++ b/src/package-lock.json
@@ -21,7 +21,7 @@
"frappe-charts": "^1.5.8",
"laravel-mix": "^6.0.43",
"linkify-string": "^4.0.0",
- "mediasoup-client": "^3.6.51",
+ "mediasoup-client": "^3.7.8",
"postcss": "^8.4.12",
"resolve-url-loader": "^5.0.0",
"sass": "^1.50.1",
@@ -3816,9 +3816,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001625",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001625.tgz",
- "integrity": "sha512-4KE9N2gcRH+HQhpeiRZXd+1niLB/XNLAhSy4z7fI8EzcbcPoAqjNInxVHTiTwWfTIV4w096XG8OtCOCQQKPv3w==",
+ "version": "1.0.30001680",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001680.tgz",
+ "integrity": "sha512-rPQy70G6AGUMnbwS1z6Xg+RkHYPAi18ihs47GH0jcxIG7wArmPgY3XbS2sRdBbxJljp3thdT8BIqv9ccCypiPA==",
"dev": true,
"funding": [
{
diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php
--- a/src/resources/lang/en/ui.php
+++ b/src/resources/lang/en/ui.php
@@ -585,6 +585,10 @@
'receipts' => "Receipts",
'receipts-hint' => "Here you can download receipts (in PDF format) for payments in specified period. Select the period and press the Download button.",
'receipts-none' => "There are no receipts for payments in this account. Please, note that you can download receipts after the month ends.",
+ 'refprogram-refcount' => "Accounts",
+ 'refprogram-url' => "Signup URL",
+ 'refprograms' => "Referral Programs",
+ 'refprograms-none' => "There are no active referral programs at this moment.",
'title' => "Account balance",
'top-up' => "Top up your wallet",
'transactions' => "Transactions",
diff --git a/src/resources/lang/en/validation.php b/src/resources/lang/en/validation.php
--- a/src/resources/lang/en/validation.php
+++ b/src/resources/lang/en/validation.php
@@ -174,6 +174,7 @@
'password-policy-min-len-error' => 'Minimum password length cannot be less than :min.',
'password-policy-max-len-error' => 'Maximum password length cannot be more than :max.',
'password-policy-last-error' => 'The minimum value for last N passwords is :last.',
+ 'referralcodeinvalid' => 'The referral program code is invalid.',
'signuptokeninvalid' => 'The signup token is invalid.',
/*
diff --git a/src/resources/themes/app.scss b/src/resources/themes/app.scss
--- a/src/resources/themes/app.scss
+++ b/src/resources/themes/app.scss
@@ -114,13 +114,21 @@
font-weight: bold;
}
+.empty-list-body {
+ background-color: #f8f8f8;
+ color: grey;
+ text-align: center;
+ height: 8em;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+}
+
tfoot.table-fake-body {
td {
- background-color: #f8f8f8;
- color: grey;
- text-align: center;
- vertical-align: middle;
- height: 8em;
+ @extend .empty-list-body;
+
+ display: table-cell;
border: 0;
}
diff --git a/src/resources/vue/Signup.vue b/src/resources/vue/Signup.vue
--- a/src/resources/vue/Signup.vue
+++ b/src/resources/vue/Signup.vue
@@ -175,6 +175,7 @@
phone: 'mobile-retro'
},
plans: [],
+ referral: '',
token: '',
voucher: ''
}
@@ -216,6 +217,10 @@
// Voucher (discount) code
this.voucher = params[1]
this.displayForm(0)
+ } else if (params.length === 2 && params[0] === 'referral') {
+ // Referral code
+ this.referral = params[1]
+ this.displayForm(0)
} else if (params.length === 1 && /^([A-Z0-9]+)-([a-zA-Z0-9]+)$/.test(params[0])) {
// Verification code provided, auto-submit Step 2
this.short_code = RegExp.$1
@@ -278,7 +283,7 @@
submitStep1() {
this.$root.clearFormValidation($('#step1 form'))
- const post = this.$root.pick(this, ['email', 'last_name', 'first_name', 'plan', 'token', 'voucher'])
+ const post = this.$root.pick(this, ['email', 'last_name', 'first_name', 'plan', 'token', 'voucher', 'referral'])
axios.post('/api/auth/signup/init', post)
.then(response => {
diff --git a/src/resources/vue/Wallet.vue b/src/resources/vue/Wallet.vue
--- a/src/resources/vue/Wallet.vue
+++ b/src/resources/vue/Wallet.vue
@@ -59,6 +59,11 @@
<payment-log v-if="walletId && loadPayments" class="card-text" :wallet-id="walletId"></payment-log>
</div>
</div>
+ <div class="tab-pane" id="refprograms" role="tabpanel" aria-labelledby="tab-refprograms">
+ <div class="card-body">
+ <referral-programs v-if="walletId && loadReferrals" class="card-text" :wallet-id="walletId"></referral-programs>
+ </div>
+ </div>
</div>
<modal-dialog id="payment-dialog" ref="paymentDialog" :title="paymentDialogTitle" @click="payment" :buttons="dialogButtons">
@@ -142,6 +147,7 @@
import TransactionLog from './Widgets/TransactionLog'
import PaymentLog from './Widgets/PaymentLog'
import ReceiptList from './Widgets/ReceiptList'
+ import ReferralPrograms from './Widgets/ReferralPrograms'
import { paymentCheckout } from '../js/utils'
import { library } from '@fortawesome/fontawesome-svg-core'
@@ -159,7 +165,8 @@
ModalDialog,
TransactionLog,
PaymentLog,
- ReceiptList
+ ReceiptList,
+ ReferralPrograms
},
data() {
return {
@@ -172,6 +179,7 @@
loadTransactions: false,
loadPayments: false,
loadReceipts: true,
+ loadReferrals: false,
showPendingPayments: false,
wallet: {},
walletId: null,
@@ -200,7 +208,7 @@
return [ button ]
},
tabs() {
- let tabs = [ 'wallet.receipts', 'wallet.history' ]
+ let tabs = [ 'wallet.receipts', 'wallet.history', 'wallet.refprograms' ]
if (this.showPendingPayments) {
tabs.push('wallet.pending-payments')
}
@@ -233,6 +241,7 @@
this.$refs.tabs.clickHandler('history', () => { this.loadTransactions = true })
this.$refs.tabs.clickHandler('payments', () => { this.loadPayments = true })
this.$refs.tabs.clickHandler('receipts', () => { this.loadReceipts = true })
+ this.$refs.tabs.clickHandler('refprograms', () => { this.loadReferrals = true })
},
methods: {
loadMandate() {
diff --git a/src/resources/vue/Widgets/ReferralPrograms.vue b/src/resources/vue/Widgets/ReferralPrograms.vue
new file mode 100644
--- /dev/null
+++ b/src/resources/vue/Widgets/ReferralPrograms.vue
@@ -0,0 +1,61 @@
+<template>
+ <div id="referral-programs">
+ <ul v-if="programs.length" class="list-group list-group-flush">
+ <li v-for="program in programs" :id="'ref' + program.id" :key="program.id" class="list-group-item ps-0 pe-0 d-flex">
+ <div class="me-3">
+ <img :src="program.qrCode" :title="program.url" style="width: 100px" />
+ </div>
+ <div>
+ <p class="fw-bold mb-2 name">{{ program.name }}</p>
+ <p v-if="program.description" class="mb-1 description">{{ program.description }}</p>
+ <p class="m-0 text-secondary lh-1 info">
+ <small class="text-nowrap">
+ {{ $t('wallet.refprogram-url') }}: {{ program.url }}
+ <btn class="btn-link p-1" :icon="['far', 'clipboard']" :title="$t('btn.copy')" @click="copyUrl(program.url)"></btn>
+ </small>
+ <small class="d-block">
+ {{ $t('wallet.refprogram-refcount') }}: {{ program.refcount }}
+ </small>
+ </p>
+ </div>
+ </li>
+ </ul>
+ <div v-else class="empty-list-body">{{ $t('wallet.refprograms-none') }}</div>
+ </div>
+</template>
+
+<script>
+ import { library } from '@fortawesome/fontawesome-svg-core'
+
+ library.add(
+ require('@fortawesome/free-regular-svg-icons/faClipboard').definition,
+ )
+
+ export default {
+ props: {
+ walletId: { type: String, default: null }
+ },
+ data() {
+ return {
+ programs: []
+ }
+ },
+ mounted() {
+ if (!this.walletId) {
+ return
+ }
+
+ const loader = $('#referral-programs')
+
+ axios.get('/api/v4/wallets/' + this.walletId + '/referral-programs', { loader })
+ .then(response => {
+ this.programs = response.data.list
+ })
+ },
+ methods: {
+ copyUrl(url) {
+ navigator.clipboard.writeText(url);
+ }
+ }
+ }
+</script>
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -170,6 +170,7 @@
Route::get('wallets/{id}/transactions', [API\V4\WalletsController::class, 'transactions']);
Route::get('wallets/{id}/receipts', [API\V4\WalletsController::class, 'receipts']);
Route::get('wallets/{id}/receipts/{receipt}', [API\V4\WalletsController::class, 'receiptDownload']);
+ Route::get('wallets/{id}/referral-programs', [API\V4\WalletsController::class, 'referralPrograms']);
Route::get('password-policy', [API\PasswordPolicyController::class, 'index']);
Route::post('password-reset/code', [API\PasswordResetController::class, 'codeCreate']);
diff --git a/src/tests/Browser/Pages/Wallet.php b/src/tests/Browser/Pages/Wallet.php
--- a/src/tests/Browser/Pages/Wallet.php
+++ b/src/tests/Browser/Pages/Wallet.php
@@ -44,6 +44,7 @@
'@nav' => 'ul.nav-tabs',
'@history-tab' => '#history',
'@receipts-tab' => '#receipts',
+ '@refprograms-tab' => '#refprograms',
'@payments-tab' => '#payments',
];
}
diff --git a/src/tests/Browser/SignupTest.php b/src/tests/Browser/SignupTest.php
--- a/src/tests/Browser/SignupTest.php
+++ b/src/tests/Browser/SignupTest.php
@@ -5,6 +5,7 @@
use App\Discount;
use App\Domain;
use App\Plan;
+use App\ReferralProgram;
use App\SignupCode;
use App\SignupInvitation;
use App\SignupToken;
@@ -34,6 +35,7 @@
Plan::whereNot('mode', Plan::MODE_EMAIL)->update(['mode' => Plan::MODE_EMAIL]);
SignupToken::truncate();
+ ReferralProgram::query()->delete();
}
/**
@@ -49,6 +51,7 @@
Plan::whereNot('mode', Plan::MODE_EMAIL)->update(['mode' => Plan::MODE_EMAIL]);
Discount::where('discount', 100)->update(['code' => null]);
SignupToken::truncate();
+ ReferralProgram::query()->delete();
parent::tearDown();
}
@@ -834,6 +837,56 @@
$this->assertSame($discount->id, $user->wallets()->first()->discount_id);
}
+ /**
+ * Test signup with a referral code
+ */
+ public function testSignupReferralCode(): void
+ {
+ $referrer = $this->getTestUser('john@kolab.org');
+ $program = ReferralProgram::create([
+ 'name' => "Test Referral",
+ 'description' => "Test Referral Description",
+ 'active' => true,
+ ]);
+ $referral_code = $program->codes()->create(['user_id' => $referrer->id]);
+
+ $this->browse(function (Browser $browser) use ($referral_code) {
+ $browser->visit('/signup/referral/' . $referral_code->code)
+ ->onWithoutAssert(new Signup())
+ ->waitUntilMissing('.app-loader')
+ ->waitFor('@step0')
+ ->click('.plan-individual button')
+ ->whenAvailable('@step1', function (Browser $browser) {
+ $browser->type('#signup_first_name', 'Test')
+ ->type('#signup_last_name', 'User')
+ ->type('#signup_email', 'BrowserSignupTestUser1@kolab.org')
+ ->click('[type=submit]');
+ })
+ ->whenAvailable('@step2', function (Browser $browser) {
+ $code = SignupCode::orderBy('created_at', 'desc')->first();
+ $browser->type('#signup_short_code', $code->short_code)
+ ->click('[type=submit]');
+ })
+ ->whenAvailable('@step3', function (Browser $browser) {
+ $browser->type('#signup_login', 'signuptestdusk')
+ ->type('#signup_password', '123456789')
+ ->type('#signup_password_confirmation', '123456789')
+ ->click('[type=submit]');
+ })
+ ->waitUntilMissing('@step3')
+ ->waitUntilMissing('.app-loader')
+ ->on(new Dashboard())
+ ->assertUser('signuptestdusk@' . \config('app.domain'))
+ // Logout the user
+ ->within(new Menu(), function ($browser) {
+ $browser->clickMenuItem('logout');
+ });
+ });
+
+ $user = $this->getTestUser('signuptestdusk@' . \config('app.domain'));
+ $this->assertSame(1, $referral_code->referrals()->where('user_id', $user->id)->count());
+ }
+
/**
* Test signup via invitation link
*/
diff --git a/src/tests/Browser/WalletTest.php b/src/tests/Browser/WalletTest.php
--- a/src/tests/Browser/WalletTest.php
+++ b/src/tests/Browser/WalletTest.php
@@ -3,6 +3,7 @@
namespace Tests\Browser;
use App\Payment;
+use App\ReferralProgram;
use App\Transaction;
use App\Wallet;
use Carbon\Carbon;
@@ -25,6 +26,7 @@
$john = $this->getTestUser('john@kolab.org');
Wallet::where('user_id', $john->id)->update(['balance' => -1234, 'currency' => 'CHF']);
+ ReferralProgram::query()->delete();
}
/**
@@ -36,6 +38,7 @@
$john = $this->getTestUser('john@kolab.org');
Wallet::where('user_id', $john->id)->update(['balance' => 0]);
+ ReferralProgram::query()->delete();
parent::tearDown();
}
@@ -177,6 +180,62 @@
});
}
+ /**
+ * Test Referral Programs tab
+ */
+ public function testReferralPrograms(): void
+ {
+ $user = $this->getTestUser('wallets-controller@kolabnow.com', ['password' => 'simple123']);
+ $wallet = $user->wallets()->first();
+
+ // Log out 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 Referral Programs tab content when there's no programs available
+ $this->browse(function (Browser $browser) {
+ $browser->on(new Dashboard())
+ ->click('@links .link-wallet')
+ ->on(new WalletPage())
+ ->assertSeeIn('@nav #tab-refprograms', 'Referral Programs')
+ ->click('@nav #tab-refprograms')
+ ->whenAvailable('@refprograms-tab', function (Browser $browser) {
+ $browser->waitUntilMissing('.app-loader')
+ ->assertSeeIn('div', 'There are no active referral programs at this moment.')
+ ->assertMissing('ul');
+ });
+ });
+
+ // Create sample program
+ $program = ReferralProgram::create([
+ 'name' => "Test Referral",
+ 'description' => "Test Referral Description",
+ 'active' => true,
+ ]);
+
+ // Assert Referral Programs tab with programs available
+ $this->browse(function (Browser $browser) use ($program, $user) {
+ $browser->refresh()
+ ->on(new WalletPage())
+ ->click('@nav #tab-refprograms')
+ ->whenAvailable('@refprograms-tab', function (Browser $browser) use ($program, $user) {
+ $code = $program->codes()->where('user_id', $user->id)->first();
+
+ $browser->waitFor('ul')
+ ->assertElementsCount('ul > li', 1)
+ ->assertVisible('li:nth-child(1) img')
+ ->assertSeeIn('li:nth-child(1) p.name', $program->name)
+ ->assertSeeIn('li:nth-child(1) p.description', $program->description)
+ ->assertSeeIn('li:nth-child(1) p.info', 'Signup URL: ' . $code->signupUrl())
+ ->assertSeeIn('li:nth-child(1) p.info', 'Accounts: 0');
+ });
+ });
+ }
+
/**
* Test History tab
*/
diff --git a/src/tests/Feature/Controller/SignupTest.php b/src/tests/Feature/Controller/SignupTest.php
--- a/src/tests/Feature/Controller/SignupTest.php
+++ b/src/tests/Feature/Controller/SignupTest.php
@@ -8,6 +8,7 @@
use App\IP4Net;
use App\Plan;
use App\Package;
+use App\ReferralProgram;
use App\SignupCode;
use App\SignupInvitation as SI;
use App\SignupToken;
@@ -46,6 +47,7 @@
Plan::where('title', 'test')->delete();
IP4Net::where('net_number', inet_pton('127.0.0.0'))->delete();
VatRate::query()->delete();
+ ReferralProgram::query()->delete();
}
/**
@@ -68,6 +70,7 @@
Plan::where('title', 'test')->delete();
IP4Net::where('net_number', inet_pton('127.0.0.0'))->delete();
VatRate::query()->delete();
+ ReferralProgram::query()->delete();
parent::tearDown();
}
@@ -982,6 +985,67 @@
$this->assertSame(1, $plan->signupTokens()->first()->counter);
}
+ /**
+ * Test signup vith a referral program
+ */
+ public function testSignupWithReferralCode(): void
+ {
+ Queue::fake();
+
+ $referrer = $this->getTestUser('john@kolab.org');
+ $discount = Discount::where('code', 'TEST')->first();
+ $program = ReferralProgram::create([
+ 'name' => "Test Referral",
+ 'description' => "Test Referral Description",
+ 'active' => true,
+ 'discount_id' => $discount->id,
+ ]);
+ $referral_code = $program->codes()->create(['user_id' => $referrer->id]);
+
+ $post = [
+ 'referral' => 'abc',
+ 'first_name' => 'Signup',
+ 'last_name' => 'User',
+ 'email' => 'test@domain.ltd',
+ 'login' => 'test-inv',
+ 'domain' => 'kolabnow.com',
+ 'password' => 'testtest',
+ 'password_confirmation' => 'testtest',
+ ];
+
+ // Test invalid referral code
+ $response = $this->post('/api/auth/signup/init', $post);
+ $response->assertStatus(422);
+
+ $json = $response->json();
+
+ $this->assertSame('error', $json['status']);
+ $this->assertSame(['referral' => ["The referral program code is invalid."]], $json['errors']);
+
+ // Test valid code
+ $post['referral'] = $referral_code->code;
+ $response = $this->post('/api/auth/signup/init', $post);
+
+ $json = $response->json();
+
+ $signup_code = SignupCode::find($json['code']);
+ $post['code'] = $signup_code->code;
+ $post['short_code'] = $signup_code->short_code;
+
+ // Test final signup request
+ $response = $this->post('/api/auth/signup', $post);
+ $json = $response->json();
+
+ $response->assertStatus(200);
+ $this->assertSame('success', $json['status']);
+ $this->assertNotEmpty($json['access_token']);
+
+ // Check the reference to the code and discount
+ $user = User::where('email', $json['email'])->first();
+ $this->assertSame(1, $referral_code->referrals()->where('user_id', $user->id)->count());
+ $this->assertSame($discount->id, $user->wallets()->first()->discount_id);
+ }
+
/**
* Test signup validation (POST /signup/validate)
*/
diff --git a/src/tests/Feature/Controller/WalletsTest.php b/src/tests/Feature/Controller/WalletsTest.php
--- a/src/tests/Feature/Controller/WalletsTest.php
+++ b/src/tests/Feature/Controller/WalletsTest.php
@@ -4,6 +4,7 @@
use App\Http\Controllers\API\V4\WalletsController;
use App\Payment;
+use App\ReferralProgram;
use App\Transaction;
use Carbon\Carbon;
use Tests\TestCase;
@@ -18,6 +19,7 @@
parent::setUp();
$this->deleteTestUser('wallets-controller@kolabnow.com');
+ ReferralProgram::query()->delete();
}
/**
@@ -26,6 +28,7 @@
public function tearDown(): void
{
$this->deleteTestUser('wallets-controller@kolabnow.com');
+ ReferralProgram::query()->delete();
parent::tearDown();
}
@@ -200,6 +203,92 @@
$this->assertSame(false, $json['hasMore']);
}
+ /**
+ * Test fetching list of referral programs (GET /api/v4/wallets/<id>/referral-programs)
+ */
+ public function testReferralPrograms(): void
+ {
+ $user = $this->getTestUser('wallets-controller@kolabnow.com');
+ $john = $this->getTestUser('john@kolab.org');
+ $wallet = $user->wallets()->first();
+
+ // Unauth access not allowed
+ $this->get("api/v4/wallets/{$wallet->id}/referral-programs")->assertStatus(401);
+ $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/referral-programs")->assertStatus(403);
+
+ // Empty list expected
+ $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/referral-programs");
+ $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 test program
+ $program = ReferralProgram::create([
+ 'name' => "Test Referral",
+ 'description' => "Test Referral Description",
+ 'active' => false,
+ ]);
+
+ // Empty list expected, no active program
+ $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/referral-programs")
+ ->assertStatus(200)
+ ->assertJsonFragment(['list' => []]);
+
+ // Activate the program
+ $program->active = true;
+ $program->save();
+
+ $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/referral-programs");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(5, $json);
+ $this->assertSame('success', $json['status']);
+ $this->assertSame(1, $json['page']);
+ $this->assertSame(1, $json['count']);
+ $this->assertSame(false, $json['hasMore']);
+ $this->assertCount(1, $json['list']);
+ $this->assertSame($program->id, $json['list'][0]['id']);
+ $this->assertSame($program->name, $json['list'][0]['name']);
+ $this->assertSame($program->description, $json['list'][0]['description']);
+ $this->assertCount(1, $program->codes);
+ $code = $program->codes->first();
+ $this->assertStringContainsString("/signup/referral/{$code->code}", $json['list'][0]['url']);
+ $this->assertSame(0, $json['list'][0]['refcount']);
+
+ // Add some referrals
+ $john = $this->getTestUser('john@kolab.org');
+ $jack = $this->getTestUser('jack@kolab.org');
+ $code->referrals()->createMany([
+ ['user_id' => $john->id],
+ ['user_id' => $jack->id],
+ ]);
+
+ $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/referral-programs");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertCount(5, $json);
+ $this->assertSame('success', $json['status']);
+ $this->assertSame(1, $json['page']);
+ $this->assertSame(1, $json['count']);
+ $this->assertSame(false, $json['hasMore']);
+ $this->assertCount(1, $json['list']);
+ $this->assertSame($program->id, $json['list'][0]['id']);
+ $this->assertCount(1, $program->codes->fresh());
+ $this->assertStringContainsString("/signup/referral/{$code->code}", $json['list'][0]['url']);
+ $this->assertSame(2, $json['list'][0]['refcount']);
+ }
+
/**
* Test fetching a wallet (GET /api/v4/wallets/:id)
*/
diff --git a/src/tests/Feature/ReferralProgramTest.php b/src/tests/Feature/ReferralProgramTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/ReferralProgramTest.php
@@ -0,0 +1,141 @@
+<?php
+
+namespace Tests\Feature;
+
+use App\Payment;
+use App\ReferralProgram;
+use App\Transaction;
+use Carbon\Carbon;
+use Illuminate\Support\Facades\Queue;
+use Tests\TestCase;
+
+class ReferralProgramTest extends TestCase
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->deleteTestUser('referrer@kolabnow.com');
+ $this->deleteTestUser('referree@kolabnow.com');
+ ReferralProgram::query()->delete();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $this->deleteTestUser('referrer@kolabnow.com');
+ $this->deleteTestUser('referree@kolabnow.com');
+ ReferralProgram::query()->delete();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Tests for ReferralProgram::accounting() method
+ */
+ public function testAccountingWithAwardAmount(): void
+ {
+ Queue::fake();
+
+ $referrer = $this->getTestUser('referrer@kolabnow.com');
+ $referree = $this->getTestUser('referree@kolabnow.com');
+
+ $referrer_wallet = $referrer->wallets()->first();
+ $referree_wallet = $referree->wallets()->first();
+
+ $program = ReferralProgram::create([
+ 'name' => "Test Referral",
+ 'description' => "Test Referral Description",
+ 'active' => true,
+ 'award_amount' => 1000,
+ 'award_percent' => 0,
+ 'payments_threshold' => 1000,
+ ]);
+
+ $code = $program->codes()->create(['user_id' => $referrer->id]);
+ $referral = $code->referrals()->create(['user_id' => $referree->id]);
+
+ // No payments yet
+ ReferralProgram::accounting($referree);
+
+ $referrer_wallet->refresh();
+ $this->assertSame(0, $referrer_wallet->balance);
+
+ Carbon::setTestNow(Carbon::createFromDate(2024, 02, 02));
+
+ // A single payment below the threshold
+ Payment::createFromArray([
+ 'id' => 'test-payment1',
+ 'amount' => 700,
+ 'currency' => $referree_wallet->currency,
+ 'currency_amount' => 700,
+ 'type' => Payment::TYPE_ONEOFF,
+ 'wallet_id' => $referree_wallet->id,
+ 'status' => Payment::STATUS_PAID,
+ ]);
+
+ ReferralProgram::accounting($referree);
+
+ $referrer_wallet->refresh();
+ $this->assertSame(0, $referrer_wallet->balance);
+
+ // Two payments equal with the threshold
+ $payment = Payment::createFromArray([
+ 'id' => 'test-payment2',
+ 'amount' => 300,
+ 'currency' => $referree_wallet->currency,
+ 'currency_amount' => 300,
+ 'type' => Payment::TYPE_ONEOFF,
+ 'wallet_id' => $referree_wallet->id,
+ 'status' => Payment::STATUS_PAID,
+ ]);
+
+ ReferralProgram::accounting($referree);
+
+ $referrer_wallet->refresh();
+ $referral->refresh();
+ $redeemed_at = now()->format('Y-m-d H:i:s');
+ $transaction = $referrer_wallet->transactions()->first();
+ $this->assertSame($balance = 1000, $referrer_wallet->balance);
+ $this->assertSame($redeemed_at, $referral->redeemed_at->format('Y-m-d H:i:s'));
+ $this->assertSame('Referral program award (' . $program->name . ')', $transaction->description);
+ $this->assertSame(Transaction::WALLET_AWARD, $transaction->type);
+
+ // Award redeemed already
+ Carbon::setTestNow(Carbon::createFromDate(2024, 02, 04));
+ ReferralProgram::accounting($referree);
+
+ $referrer_wallet->refresh();
+ $referral->refresh();
+ $this->assertSame($balance, $referrer_wallet->balance);
+ $this->assertSame($redeemed_at, $referral->redeemed_at->format('Y-m-d H:i:s'));
+
+ // Test that Payment::credit() invokes ReferralProgram::accounting()
+ $referral->redeemed_at = null;
+ $referral->save();
+
+ $payment->credit('TEST');
+
+ $referrer_wallet->refresh();
+ $referral->refresh();
+ $this->assertSame($balance += 1000, $referrer_wallet->balance);
+ $this->assertNotNull($referral->redeemed_at);
+
+ // Test "closing" referrals if referrer is soft-deleted
+ $referrer->delete();
+ $referral->redeemed_at = null;
+ $referral->save();
+
+ ReferralProgram::accounting($referree);
+
+ $referrer_wallet->refresh();
+ $referral->refresh();
+ $this->assertSame($balance, $referrer_wallet->balance);
+ $this->assertSame($referral->redeemed_at->format('Y-m-d H:i:s'), $referral->created_at->format('Y-m-d H:i:s'));
+ }
+}

File Metadata

Mime Type
text/plain
Expires
Fri, Apr 3, 12:02 PM (11 h, 30 m ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18823944
Default Alt Text
D5012.1775217759.diff (54 KB)

Event Timeline