Laravel で「有効期限つき」ポイントシステムをつくる

こんにちは。フリーランス・コンサルタント&エンジニアの 九保すこひ です。

さてさて、これは個人的な話なのですが、お買い物のとき「ポイントカードつくりますか❓」と聞かれても、「ポイントがあることで判断が鈍る場合」は断るようにしています。

(ビビリなので「ポイントあるからムリして買わなきゃ!」ってなりたくないわけです😂)

とは言っても、ポイントをうまく使うとお得な買い物ができますし、お店側からすると客の行動パターンを知るためにとても有効だったりしますよね。

ということで、今回はこのポイント・システムをLaravelでつくってみたいのですが、どこでもあるような「貯めて&使って」だけのものでは面白くないと感じましたので、とある機能も追加することにしました。

それは・・・・・・

期間限定ポイント

です。

これは、楽天でも採用されているのですが、「ある期間だけ使えるポイント」のことです。

えっ、じゃあ❗❓」と思った方は勘がいいですね。

例えば、複数の期間限定ポイントがある場合、楽天では以下のようになっています。

(引用)
複数の期間限定ポイントを保有している場合は、有効期限の近い期間限定ポイントから優先して使われます。

つまり、「ユーザーが一番損をしないパターン」ということですね。

ということで、今回はLaravelで「期間限定ポイント + 永久ポイント」が入り混じったポイント・システムをつくってみたいと思います。

ぜひ何かの参考になりましたら嬉しいです。😊✨

「湿度が上がると、
髪の毛クリックリ…😫」

開発環境: Laravel 8.x、Vue 3、Bootstrap 5

やりたいこと

今回実現したいのは、「期間限定ポイント」と「永久ポイント」が入り混じった状態なので、以下のストーリーでポイント操作し、うまく処理できるかチェックします。

  1. 7日後に有効期限が切れる「期間限定ポイント」300ポイントをゲット
  2. 10日後に有効期限が切れる「期間限定ポイント」500ポイントをゲット
  3. いつでも使える「永久ポイント」700ポイントをゲット
  4. 500ポイントを使用
  5. 保持ポイントは?
  6. (15日後が経過)保持ポイントは?

つまり、保有ポイントの動きは次のとおりです。

  1. 300 P
  2. 300 + 500 = 800 P
  3. 300 + 500 + 700 = 1,500 P
  4. 1,500 – 500 = 1,000 P(期間限定: 300 P, 永久ポイント: 700 P)
  5. 保持ポイントは 1,000 P
  6. (15日後が経過)保持ポイントは 1,000 P – 300 P = 700 P(永久ポイントのみ)

これを実現するポイント・システムをつくっていきます❗

前提として

今回もログイン機能のインストール&テストユーザーがusersテーブルに登録されていることが前提です。

もしまだの方は以下を参考にしてみてください。

📝 参考ページ

モデル&マイグレーションをつくる

では、まずはデータベース周りからつくっていきます。
以下のコマンドを実行してください。

php artisan make:model Point -m

するとファイルが2つ作成されるので中身をそれぞれ次のように変更します。

database/migrations/****_**_**_******_create_points_table.php

// 省略

    public function up()
    {
        Schema::create('points', function (Blueprint $table) {
            $table->id();
            $table->unsignedBigInteger('user_id')->comment('ユーザーID');
            $table->unsignedInteger('points_added')->default(0)->comment('取得したポイント');
            $table->unsignedInteger('points_used')->default(0)->comment('利用済みポイント');
            $table->dateTime('expired_at')->nullable()->comment('有効期限');
            $table->timestamps();

            $table->foreign('user_id')->references('id')->on('users');
        });
    }

// 省略

なお、今回のやりかたは「1レコードずつで完結するパターン」にしています。
つまり、以下のようにまずポイントを獲得した時に1行レコードを用意し、もし使用されたら、右側の利用済みポイントを書き換えていくという流れです。

テストデータをつくる

では、「やりたいこと」で紹介したストーリーをたどるようにテストデータをつくっていきます。

※ 今回はテストですので1人のユーザーだけテストデータをつくります。
※ また、ポイントを使う部分は実際にテストしますので、「ポイントを使った」データは省きます。

以下のコマンドを実行してください。

php artisan make:seed PointSeeder

するとファイルが作成されるので、中身を以下のように変更します。

database/seeders/PointSeeder.php

<?php

namespace Database\Seeders;

use App\Models\Point;
use Illuminate\Database\Seeder;

class PointSeeder extends Seeder
{
    // 省略

    public function run()
    {
        $point_actions = [
            ['points' => 300, 'expiration' => now()->addDays(7)],
            ['points' => 500, 'expiration' => now()->addDays(10)],
            ['points' => 700, 'expiration' => null],
        ];

        foreach ($point_actions as $point_action) {

            $point = new Point();
            $point->user_id = 1; // テストなので固定
            $point->points_added = $point_action['points'];
            $point->expired_at = $point_action['expiration'];
            $point->save();

        }
    }
}

そして、モデルです。
モデルは、今回作ったPointと、すでにあるUserモデルを変更します。

app/Models/Point.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Point extends Model
{
    use HasFactory;

    protected $appends = [
        'points_left'
    ];

    // Accessor
    public function getPointsLeftAttribute() { // 残り利用可能ポイント

        return intval($this->points_added) - intval($this->points_used);

    }

    // Scope
    public function scopeWhereValid($query, $user_id) {

        $query->where('user_id', $user_id)
            ->whereColumn('points_added', '!=', 'points_used') // フィールドで比較
            ->where(function($query){ // 有効期限内、もしくは永久ポイント

                $query->whereNull('expired_at')
                    ->orWhere('expired_at', '>=', now());

            })
            ->orderByRaw('ISNULL(expired_at), expired_at ASC'); // Null は最後に並べる;

    }
}

この中では「スコープ」を作っていますが、これは後ほどコントローラーの部分でご紹介します。お楽しみに😊✨

app/Models/User.php

// 省略

class User extends Authenticatable
{
    // 省略

    public function getValidPointsAttribute() {

        $points = Point::whereValid($this->id)->get();
        $total_points_added = $points->sum('points_added');
        $total_points_used = $points->sum('points_used');

        return $total_points_added - $total_points_used;

    }
}

この中では「現在有効なポイント(自由に使えるポイント)」を取得するAccessorを追加しました。

例えば、$user->valid_points;というカンジで使います。

次にSeederファイルを作成しただけでは実行できませんので、Laravel側へ登録します。

<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    // 省略

    public function run()
    {
        // 省略

        $this->call(PointSeeder::class); // 👈 ここを追加しました
    }
}

では、この状態でデータベースを再構築してみましょう。
以下のコマンドを実行してください。

php artisan migrate:fresh --seed

※ なお、最近ショートカットを作ったので私の環境では「pam:fs」で上のコマンドが実行できます。コレ便利ですよ🎐。参考はこちら

すると実際のテーブルは以下のようになります。

コントローラーをつくる

では、ここからは「フォームに使用したいポイントを入力して送信する」という部分を作っていきましょう。

以下のコマンドを実行してください。

php artisan make:controller PointController

すると、コントローラー・ファイルが作成されるので中身を以下のように変更してください。

<?php

namespace App\Http\Controllers;

use App\Models\Point;
use Illuminate\Http\Request;

class PointController extends Controller
{
    public function create(Request $request) {

        $user = $request->user();

        return view('point.create')->with([
            'user' => $user
        ]);

    }

    public function store(Request $request) {

        // バリデーションは省略しています

        $user_id = auth()->id();
        $using_points = intval($request->using_points);
        $valid_points = Point::whereValid($user_id)->get();

        foreach($valid_points as $valid_point) {

            if($valid_point->points_left < $using_points) { // 残り有効ポイントより大きい場合

                $using_points -= $valid_point->points_left;
                $valid_point->points_used = $valid_point->points_added;
                $valid_point->save();

            } else { // 残り有効ポイントの範囲内

                $valid_point->points_used += $using_points;
                $valid_point->save();
                break;

            }

        }

        return ['result' => true];

    }
}

この中で重要なのは、ポイントを使用する「データの並び順」です。

つまり、今回は「有効期限があるポイント」を扱うため、ユーザーが損をしないよう、有効期限の近い期間限定ポイントから優先して消費していかないといけません。

このため、$valid_pointsの中身は今回のデータでは以下のようになっています。

  1. 7日後に期限が来るポイント(一番先に期限が終わる)
  2. 10日後に期限がくるポイント(二番先に期限が終わる)
  3. 期限なし(いつでも使える)

そして、これを実現するために、モデルPointにつくったスコープscopeWhereValid() 内の以下の部分が必要になります。

->orderByRaw('ISNULL(expired_at), expired_at ASC')

これは何をしているかというと、通常Nullが含まれているフィールドで並べ替えると、

  1. 先に Null
  2. それ以外はその後

という並びになるため、今回のケースでいうと先に永久ポイントを使うことになってしまいます。

これではユーザーが「いやいや、期限付きから使えよ… 😥」となってしまうでしょう。

そのため、先ほどのISNULLを使って、

  1. 先に Null 以外のもの
  2. Null のもの

という並びにしています。

ビューをつくる

続いてビュー(HTML部分)をつくります。

resources/views/point/create.blade.php

<html>
<head>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div id="app" class="p-4">
    <div class="card" style="width:18rem;">
        <div class="card-body">
            <h5 class="card-title">ポイントを使用する</h5>
            <p class="card-text">現在有効なポイントは、<strong>{{ $user->valid_points }}ポイント</strong>です。</p>
            <div class="input-group mb-3">
                <input type="number" class="form-control" min="1" max="{{ $user->valid_points }}" v-model="usingPoints">
                <span class="input-group-text">P</span>
            </div>
            <div class="text-end">
                <button type="button" class="btn btn-outline-primary" @click="onSubmit">送信する</button>
            </div>
        </div>
    </div>
</div>
<script src="https://unpkg.com/vue@3.0.11/dist/vue.global.prod.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.21.1/axios.min.js"></script>
<script>

    Vue.createApp({
        data() {
            return {
                usingPoints: ''
            }
        },
        methods: {
            onSubmit() {

                const url = '{{ route('point.store') }}';
                const params = {
                    using_points: this.usingPoints
                };
                axios.post(url, params)
                    .then(response => {

                        if(response.data.result === true) {

                            location.reload();

                        }

                    })
                    .catch(error => {

                        // ここでエラー処理

                    });

            }
        }
    }).mount('#app');

</script>
</body>
</html>

この中では以下のように有効なポイントを表示し、使用したいポイントを入力して送信するフォームをつくっています。

Bootstrap 5を使ってみました。「text-right」が「text-end」に変わっていてちょっと戸惑いました(笑)

ルートをつくる

では、最後にルートをセットしてポイント用のURLをつくっていきます。

use App\Http\Controllers\PointController;

// 省略

Route::prefix('point')->middleware('auth')->group(function(){

    Route::get('create', [PointController::class, 'create'])->name('point.create');
    Route::post('/', [PointController::class, 'store'])->name('point.store');

});

なお、今回のコードはログインしていることを前提として書かれています。
そのため、ミドルウェアauthをつけました。

テストしてみる

では、実際にテストしてみましょう❗

まずは作業に入る前にデータベースを確認しておきましょう。

ポイントは全く使用されておらず、現在合計1,500ポイントを持っています。

では、ログインします。

そして「http://*******/point/create」へアクセスしてください。

すると、フォームが表示されるので、例のストーリーどおり500を入力して送信します。

すると・・・・・・

ページがリロードされて表示が変わりました。
1,500 - 500 なのでこの表示で正しいです😊✨

では、データベースはどうなっているでしょうか。

はい❗

300 + 200 = 500 ポイントが使用されたことになってます。

ただし、ここで重要なのが「順番」です。

何度も言いますが、有効期限が近いものからポイントを使わないといけないので、expired_atが「若い」順にポイントを消費していかないといけません。

なので、上の画像で合っていますね👍

では、最後に「15日経過した後」に現在の1,000ポイントがどうなっているか確認してみましょう。

・・・とはいえ2週間以上待っていられないので、「あるテクニック」を使います。

実は日付パッケージCarbonには「あたかも今が、●年●月●日である」かのように日付をセットする機能があるんですね。これはテスト向けに用意してくれているものです。

実際にやってみましょう。

app/Providers/AppServiceProvider.php

<?php

namespace App\Providers;

use Carbon\Carbon;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    // 省略

    public function boot()
    {
        // 15日後に移動「したものとして」実行
        $target_dt = today()->addDays(15);
        Carbon::setTestNow($target_dt);
    }
}

今回は、Laravel全てに影響するようにAppServiceProviderに書きましたが、実行したい場所の直前に書くだけでも有効になります。

では、これで先ほどの1,000ポイントがどうなるか、リロードしてみてみましょう。

はい❗
想定通り700ポイントになりました。

つまり、有効期限が迫っていた300ポイントが消滅してしまったことになります。

全て成功です✨😊👍

企業様へのご提案

今回の機能を使えば以下のようなメリットがあります。

  • 期間限定ポイントを使うことで、「もう少しでポイントがなくなっちゃうからお金足してでも何か買おう」というインセンティブになる。
  • 「もうすぐ期間限定ポイントがなくなります」通知メールを送信すると、実質は販促になる上に、「重要なメール」として送信しやすい。
  • (季節などの)キャンペーンと絡めてポイント付与しやすい。
  • 実際には使われないポイントも発生するので、「総額●●●●円分のポイントをプレゼント!」と銘打っていても、それよりも費用は少なく済む可能性が高い。
  • 逆に「永久ポイント」の価値が高まるため、ここぞというときのために永久ポイントの付与を活用できる

もしこういった機能をご希望でしたら、いつでもお問い合わせからお気軽にご相談ください。どうぞよろしくお願いいたします。m(_ _)m

開発のご依頼お待ちしております
開発のご依頼はこちらから: お問い合わせ
どうぞよろしくお願いいたします! by 九保すこひ

おわりに

ということで、今回はLaravelで「期間限定ポイント」機能を作ってみました。

実は今回のアイデアは、ロケットニュースさんの記事を読んだときに思いつきました。

📝 参考ページ: 【注意喚起】Gmailのメール自動振り分け機能で7000円の大損をした実話 / いつのまにか存在する「プロモーション」タブにご注意!

このページを見たとき、

  1. そういえば、楽天ポイントを山ほど失ってしまったことあるな…
  2. あれっていつまでも使えないポイントがあるんだったっけ……
  3. そっか、期間限定ポイント機能だ!

となりました。

何気なく見てただけだったんですけど、こういうことって結構あるので、これからもいろいろと興味を持っていきたいです。

ぜひ皆さんもチャレンジしてみてくださいね。

ではでは〜❗

「ポイントは貯めずに、
全部使うタイプです👍」

このエントリーをはてなブックマークに追加       follow us in feedly