跳至主要內容
技術

訂單與金流:成團後用 Cashier 串接 Stripe 收款

訂單與金流:成團後用 Cashier 串接 Stripe 收款
PHP/Laravel 完全指南 第 9 / 15 篇

本篇是「PHP/Laravel 完全指南」系列的第 9 / 15 篇。你可以從系列總覽開始閱讀,也可以直接接著看本文。

成團了,然後呢?錢要怎麼收?這是每個電商類專案最讓人緊張的環節。金流處理不像一般的 CRUD,有幾個雷你得事先知道:

  • 信用卡號不能自己存——PCI DSS 合規不是鬧著玩的
  • 付款失敗要能重試——使用者的卡可能被拒、餘額不足
  • Webhook 要正確處理——Stripe 的回呼打來時,你的程式碼必須正確回應
  • 訂單狀態要明確流轉——從「建立 → 付款中 → 已付款 → 已完成」,每個轉換都要受控

任何一個環節出錯,輕則使用者體驗差,重則真的會出財務問題。

好在 Laravel Cashier 把 Stripe 整合這件事做得非常優雅。它幫你處理了客戶(Customer)建立、Checkout Session 流程、Webhook 驗證與分發、訂閱管理這些繁瑣的底層工作,讓你可以專注在自己的業務邏輯上。你不需要自己去讀 Stripe API 文件的每一頁——Cashier 已經幫你封裝好了最常用的操作。當然,理解底層原理還是很重要的,所以這一章我們會先搞懂金流處理的基本觀念,再一步步把 Cashier 接進來。

在「揪好買」裡,金流的觸發時機跟一般電商不一樣——不是使用者按下「購買」就收錢,而是等到成團確認後,才統一向所有跟團者收款。這代表我們需要一個清楚的訂單狀態機,還要處理「成團了但某個人付款失敗」的 edge case。這一章會完整走過這個流程。

金流處理的基本觀念:為什麼 Stripe 不讓你自己存信用卡號

讓我先講一個會讓你嚇出冷汗的事實:如果你自己在資料庫裡存信用卡號,你需要通過 PCI DSS(Payment Card Industry Data Security Standard)合規認證。這個認證要求包含——但不限於——專用的加密硬體、年度安全稽核、滲透測試、嚴格的存取控制。完整的 Level 1 合規認證每年的費用可以到幾十萬美金。

所以答案很簡單:不要自己存信用卡號。讓專業的支付處理商(Stripe、綠界 ECPay、藍新 NewebPay)來處理。

金流的核心概念是轉嫁責任。整個付款流程長這樣:

1. 使用者在你的網站按下「付款」
2. 你的伺服器向 Stripe 建立一個 Checkout Session
3. 使用者被導向到 Stripe 的付款頁面(hosted page)
4. 使用者在 Stripe 的頁面輸入信用卡資訊
5. Stripe 處理付款
6. Stripe 透過 webhook 通知你的伺服器「付款成功了」
7. 你的伺服器更新訂單狀態

注意:信用卡資訊從頭到尾都不經過你的伺服器。使用者是在 Stripe 的頁面上輸入的。你的伺服器只收到「付款成功」或「付款失敗」的通知——不會碰到任何卡號資訊。這就是 Stripe Checkout 的精髓。

跨服務對照

概念Stripe綠界 ECPay藍新 NewebPay
Hosted 付款頁Checkout Session付款頁(AIO)MPG 多功能收款
付款結果通知Webhook付款完成通知背景通知 NotifyURL
客戶端套件Stripe.jsN/AN/A
Laravel 套件Laravel Cashier自行串接 / 社群套件自行串接 / 社群套件

為什麼選 Stripe?(先講一個會卡死你的前提) 本章用 Stripe,純粹因為它有官方 Laravel 套件(Cashier)、文件最齊全、測試工具最好用,最適合「學金流概念」。但有件事我必須先講清楚,免得你照做到最後才發現收不到錢:Stripe 目前不支援台灣境內公司直接開戶收款(台灣不在它的支援國家清單內),真要收到台幣帳戶,得繞道去註冊美國公司、辦 EIN 那一整套——對一個只想做台灣團購平台的人來說,這成本不合理。所以請把這章當成「拿 Stripe 學概念」,真要在台灣上線收款,請改用綠界 ECPay 或藍新 NewebPay,它們才支援台灣公司行號跟在地金流(ATM、超商代碼、Line Pay)。好消息是:這裡學到的 webhook 驗證、訂單狀態機、DB transaction 邏輯,換成台灣金流幾乎照搬,所以這章不算白學——只是別把「學習情境」當成「能上線收錢」。

Laravel Cashier 與 Stripe 整合

Laravel Cashier 是 Laravel 官方維護的 Stripe 整合套件。它封裝了最常用的 Stripe 操作——建立 Customer、Checkout Session、處理 Webhook——讓你用優雅的 PHP 語法完成金流串接。

安裝

composer require laravel/cashier

執行 Migration

Cashier 的 migration 需要先發佈到 database/migrations/(Laravel 11+ 起 Cashier 不再自動載入 migration),再執行 migrate,才會在你的 users 表加上 Stripe 相關的欄位:

php artisan vendor:publish --tag="cashier-migrations"
php artisan migrate

這會新增以下欄位到 users 表:

欄位用途
stripe_id使用者在 Stripe 的 Customer ID
pm_type預設付款方式類型(visa, mastercard…)
pm_last_four信用卡末四碼
trial_ends_at試用期結束時間(訂閱制用)

設定 Billable Trait

在 User Model 上加入 Billable trait:

<?php

namespace App\Models;

use Laravel\Cashier\Billable;
use Illuminate\Foundation\Auth\User as Authenticatable;

class User extends Authenticatable
{
    use Billable;

    // ... 其他程式碼
}

Billable trait 賦予 User Model 一系列 Stripe 相關的方法——checkout()charge()subscription() 等等。加了這一行,你的 User 就能直接跟 Stripe 互動。

Stripe 帳號設定與測試模式

建立 Stripe 帳號

stripe.com 註冊帳號。不需要填信用卡,開發階段全程用測試模式。

在 Dashboard 的 Developers → API keys 取得你的測試金鑰:

  • Publishable keypk_test_...(前端用,可以公開)
  • Secret keysk_test_...(後端用,絕對不能公開)

設定 .env

STRIPE_KEY=pk_test_51ABC...
STRIPE_SECRET=sk_test_51ABC...
STRIPE_WEBHOOK_SECRET=whsec_...

千萬不要把 Secret key 提交到 Git。 .env 已經在 .gitignore 裡了,但還是提醒一下——這種 key 外流的事件每個月都在發生。

Stripe 測試卡號

Stripe 提供一整套測試用的卡號,讓你不用刷真的信用卡就能測試各種場景:

卡號行為
4242 4242 4242 4242付款成功
4000 0000 0000 3220需要 3D Secure 驗證
4000 0000 0000 0002付款被拒(卡片被拒絕)
4000 0000 0000 9995付款失敗(餘額不足)

到期日填任何未來的日期,CVC 填任意三碼數字。

Stripe CLI:本地測試 Webhook

Webhook 是 Stripe 打到你的伺服器的 HTTP 請求。但開發階段你的電腦通常在 NAT 後面,Stripe 打不到你。Stripe CLI 幫你解決這個問題:

# 安裝(macOS)
brew install stripe/stripe-cli/stripe

# 登入
stripe login

# 把 Stripe 的 webhook 事件轉發到你的本地伺服器
stripe listen --forward-to localhost:8000/stripe/webhook

執行後,CLI 會印出一個 whsec_... 的 webhook signing secret,把它填到 .envSTRIPE_WEBHOOK_SECRET

開另一個終端機觸發測試事件:

stripe trigger checkout.session.completed

你會看到 CLI 印出事件轉發的紀錄,而你的 Laravel 應用也會收到對應的 webhook。

訂單 Model 設計:Order 與 OrderItem

在接 Stripe 之前,先把訂單的資料結構搞定。揪好買的訂單跟一般電商略有不同:一個訂單對應一個使用者在一個團購裡的跟團記錄。

建立 Migration 與 Model

php artisan make:model Order -mf
php artisan make:model OrderItem -mf

Order Migration

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('orders', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->foreignId('group_buy_id')->constrained()->cascadeOnDelete();
            $table->integer('total');                         // 總金額(分)
            $table->string('status')->default('pending');     // pending, paid, failed, refunded
            $table->string('stripe_checkout_session_id')->nullable();
            $table->string('stripe_payment_intent_id')->nullable();
            $table->timestamp('paid_at')->nullable();
            $table->timestamps();

            $table->unique(['user_id', 'group_buy_id']);      // 一個使用者在一個團購只有一筆訂單
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('orders');
    }
};

OrderItem Migration

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('order_items', function (Blueprint $table) {
            $table->id();
            $table->foreignId('order_id')->constrained()->cascadeOnDelete();
            $table->string('product_name');
            $table->integer('quantity');
            $table->integer('unit_price');     // 單價(分)
            $table->integer('subtotal');       // 小計(分)= quantity * unit_price
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('order_items');
    }
};

OrderStatus Enum

<?php

// app/Enums/OrderStatus.php
namespace App\Enums;

enum OrderStatus: string
{
    case Pending = 'pending';
    case Paid = 'paid';
    case Failed = 'failed';
    case Refunded = 'refunded';
    case Shipping = 'shipping';
    case Completed = 'completed';

    public function label(): string
    {
        return match ($this) {
            self::Pending => '待付款',
            self::Paid => '已付款',
            self::Failed => '付款失敗',
            self::Refunded => '已退款',
            self::Shipping => '出貨中',
            self::Completed => '已完成',
        };
    }

    public function color(): string
    {
        return match ($this) {
            self::Pending => 'yellow',
            self::Paid => 'green',
            self::Failed => 'red',
            self::Refunded => 'gray',
            self::Shipping => 'blue',
            self::Completed => 'green',
        };
    }
}

Model 定義

<?php

// app/Models/Order.php
namespace App\Models;

use App\Enums\OrderStatus;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Order extends Model
{
    protected $fillable = [
        'user_id',
        'group_buy_id',
        'total',
        'status',
        'stripe_checkout_session_id',
        'stripe_payment_intent_id',
        'paid_at',
    ];

    protected function casts(): array
    {
        return [
            'status' => OrderStatus::class,
            'total' => 'integer',
            'paid_at' => 'datetime',
        ];
    }

    // ── 關聯 ──

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    public function groupBuy(): BelongsTo
    {
        return $this->belongsTo(GroupBuy::class);
    }

    public function items(): HasMany
    {
        return $this->hasMany(OrderItem::class);
    }
}
<?php

// app/Models/OrderItem.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class OrderItem extends Model
{
    protected $fillable = [
        'order_id',
        'product_name',
        'quantity',
        'unit_price',
        'subtotal',
    ];

    protected function casts(): array
    {
        return [
            'quantity' => 'integer',
            'unit_price' => 'integer',
            'subtotal' => 'integer',
        ];
    }

    public function order(): BelongsTo
    {
        return $this->belongsTo(Order::class);
    }
}

在 User Model 加上關聯:

// app/Models/User.php
public function orders(): HasMany
{
    return $this->hasMany(Order::class);
}

ER 關係圖

users ──────< orders >────── group_buys


                └──────< order_items
  • 一個 user 有多個 orders(一對多)
  • 一個 group_buy 有多個 orders(一對多)
  • 一個 order 有多個 order_items(一對多)

Database Transaction:確保資料一致性

建立訂單的時候,你需要同時做好幾件事:建立 Order、建立 OrderItem、扣減庫存(如果有的話)。這些動作必須全部成功全部失敗——不能出現「Order 建好了但 OrderItem 沒建」的殘缺狀態。

這就是 Database Transaction 的用途。

DB::transaction()

use Illuminate\Support\Facades\DB;

$order = DB::transaction(function () use ($user, $groupBuy, $quantity) {
    // 建立訂單
    $order = Order::create([
        'user_id' => $user->id,
        'group_buy_id' => $groupBuy->id,
        'total' => $groupBuy->price_per_unit * $quantity,
        'status' => 'pending',
    ]);

    // 建立訂單項目
    $order->items()->create([
        'product_name' => $groupBuy->product_name,
        'quantity' => $quantity,
        'unit_price' => $groupBuy->price_per_unit,
        'subtotal' => $groupBuy->price_per_unit * $quantity,
    ]);

    return $order;
});

如果 DB::transaction() 裡的任何一步拋出例外,所有資料庫操作都會被回滾(rollback)——就像什麼都沒發生過一樣。

失敗場景

想像這個情況:Order::create() 成功了,但 $order->items()->create() 因為某個驗證錯誤失敗了。如果沒有 Transaction:

  • 資料庫裡有一筆 Order,但沒有對應的 OrderItem
  • 使用者看到「訂單已建立」但內容是空的
  • 你需要手動清理殘餘資料

有了 Transaction,整個操作被回滾,資料庫維持乾淨。

跨框架對照

框架Transaction 語法
LaravelDB::transaction(fn () => ...)
Djangowith transaction.atomic():
Sequelizesequelize.transaction(async (t) => ...)
Prismaprisma.$transaction([...])

什麼時候該用 Transaction? 只要你在一個操作裡有兩個以上的資料庫寫入,而且它們必須同時成功或失敗,就該用 Transaction。訂單建立、轉帳、庫存異動——這些都是經典場景。

Stripe Checkout Session 流程

Checkout Session 是 Stripe 的 hosted 付款頁面。你在後端建立一個 Session,Stripe 回傳一個 URL,你把使用者導過去——Stripe 處理所有付款流程,完成後把使用者導回你的網站。

建立 Checkout Session

<?php

namespace App\Http\Controllers;

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

class CheckoutController extends Controller
{
    public function create(Order $order)
    {
        // 確認訂單屬於當前使用者,且狀態是 pending
        $this->authorize('pay', $order);

        $checkout = $order->user->checkout(
            // line items:顯示在 Stripe 付款頁上的項目
            $order->items->map(fn ($item) => [
                'price_data' => [
                    'currency' => 'twd',
                    'product_data' => [
                        'name' => $item->product_name,
                    ],
                    'unit_amount' => $item->unit_price,  // 以「分」為單位(TWD 不需要換算)
                ],
                'quantity' => $item->quantity,
            ])->toArray(),
            // session options
            [
                'success_url' => route('checkout.success', ['order' => $order->id]) . '?session_id={CHECKOUT_SESSION_ID}',
                'cancel_url' => route('checkout.cancel', ['order' => $order->id]),
                'metadata' => [
                    'order_id' => $order->id,    // 關鍵:在 webhook 裡用這個找回訂單
                ],
            ]
        );

        // 記錄 Session ID 到訂單
        $order->update([
            'stripe_checkout_session_id' => $checkout->id,
        ]);

        return redirect($checkout->url);
    }
}

這裡有幾個重點:

  1. unit_amount 的單位:Stripe 用的是貨幣的最小單位。對 USD 來說 1000 = $10.00(以 cent 為單位);對 TWD 來說 1000 就是 NT$1,000(因為台幣沒有「分」)。如果你的資料庫存的是台幣整數,直接給就好。
  2. metadata:自訂資料,Stripe 會在 webhook 事件裡原封不動回傳給你。我們放了 order_id,等收到 webhook 時就能快速找到對應的訂單。
  3. success_urlcancel_url:使用者付款成功/取消後被導回的網址。{CHECKOUT_SESSION_ID} 是 Stripe 的 placeholder,會被替換成真正的 Session ID。

成功與取消頁面

// routes/web.php
Route::middleware(['auth'])->group(function () {
    Route::get('/checkout/{order}', [CheckoutController::class, 'create'])->name('checkout.create');
    Route::get('/checkout/{order}/success', [CheckoutController::class, 'success'])->name('checkout.success');
    Route::get('/checkout/{order}/cancel', [CheckoutController::class, 'cancel'])->name('checkout.cancel');
});
// CheckoutController.php

public function success(Request $request, Order $order)
{
    // 注意:不要在這裡更新訂單狀態!
    // 這只是使用者看到的頁面,真正的狀態更新要靠 webhook。
    // 使用者可能直接複製這個 URL,不代表真的付款了。

    return view('checkout.success', [
        'order' => $order->load('items', 'groupBuy'),
    ]);
}

public function cancel(Request $request, Order $order)
{
    return view('checkout.cancel', [
        'order' => $order->load('groupBuy'),
    ]);
}

非常重要: 絕對不要在 success_url 的 callback 裡直接把訂單標記為已付款。使用者可以自己在瀏覽器裡輸入 success URL 而不需要真的付款。唯一可信的付款確認來源是 Stripe 的 webhook。

Webhook 處理:Stripe 回呼你的伺服器

Webhook 的概念很簡單:當 Stripe 那邊發生了某個事件(付款成功、付款失敗、退款完成),Stripe 會主動發一個 HTTP POST 請求到你事先設定好的 URL。你的伺服器接到這個請求,就能做對應的處理。

為什麼需要 Webhook?

你可能會想:「使用者付完款被導回 success URL,我在那裡處理不就好了?」

不行。有好幾個原因:

  1. 使用者可能關掉瀏覽器——付完款但沒被導回你的網站。
  2. Success URL 可以被偽造——任何人都能在瀏覽器裡輸入那個 URL。
  3. 延遲付款——某些付款方式(銀行轉帳、超商繳費)不是即時完成的。
  4. Stripe 保證投遞——如果你的伺服器暫時掛了,Stripe 會重試。

Cashier 的 Webhook 路由

Cashier 已經幫你註冊了一個 webhook 路由:

// 自動註冊在 POST /stripe/webhook

你需要在 Stripe Dashboard 裡設定 webhook endpoint,指向你的 https://your-domain.com/stripe/webhook

CSRF 豁免: Stripe 的 webhook 請求不會帶 CSRF token(因為是 Stripe 伺服器發的,不是瀏覽器)。Cashier 已經自動把 /stripe/webhook 排除在 CSRF 驗證之外,你不需要手動處理。

監聽 Checkout 完成事件

Cashier 會自動驗證 webhook 的簽名(確認是 Stripe 發的,不是惡意攻擊者偽造的),然後把事件分發出來。你只需要監聽對應的事件:

<?php

// app/Listeners/HandleCheckoutSessionCompleted.php
namespace App\Listeners;

use App\Enums\OrderStatus;
use App\Models\Order;
use Laravel\Cashier\Events\WebhookReceived;

class HandleCheckoutSessionCompleted
{
    public function handle(WebhookReceived $event): void
    {
        // 只處理 checkout.session.completed 事件
        if ($event->payload['type'] !== 'checkout.session.completed') {
            return;
        }

        $session = $event->payload['data']['object'];
        $orderId = $session['metadata']['order_id'] ?? null;

        if (! $orderId) {
            return;
        }

        $order = Order::find($orderId);

        if (! $order || $order->status !== OrderStatus::Pending) {
            return;
        }

        // 走狀態機,而不是直接 update —— 與前面定義的 markAsPaid() 一致
        $order->markAsPaid($session['payment_intent']);
    }
}

註冊 Listener

AppServiceProviderboot() 方法裡,或用 EventServiceProvider

// app/Providers/AppServiceProvider.php
use App\Listeners\HandleCheckoutSessionCompleted;
use Illuminate\Support\Facades\Event;
use Laravel\Cashier\Events\WebhookReceived;

public function boot(): void
{
    Event::listen(WebhookReceived::class, HandleCheckoutSessionCompleted::class);
}

Webhook 簽名驗證

Stripe 在每個 webhook 請求的 header 裡帶了一個簽名(Stripe-Signature)。Cashier 會用你 .env 裡的 STRIPE_WEBHOOK_SECRET 來驗證這個簽名,確保請求真的是 Stripe 發的。

如果驗證失敗(簽名不對),Cashier 會回傳 403,webhook 內容不會被處理。這是防止惡意攻擊者偽造 webhook 的關鍵安全機制——沒有這一層,任何人都能假裝 Stripe 打你的 webhook endpoint,偽造「付款成功」的通知。

訂單狀態機:從建立到完成

訂單在它的生命週期裡會經歷不同的狀態。重點是:不是每個狀態都能轉換到任何其他狀態。你不能把「已退款」的訂單變成「待付款」,也不能把「付款失敗」直接跳到「已完成」。

合法的狀態轉換

pending ──→ paid ──→ shipping ──→ completed
   │          │
   │          └──→ refunded

   └──→ failed ──→ pending(重新付款)

在 Order Model 上實作狀態轉換

<?php

// app/Models/Order.php 加入以下方法
use App\Enums\OrderStatus;

class Order extends Model
{
    // ... 前面的程式碼 ...

    // ── 狀態轉換 ──

    private const ALLOWED_TRANSITIONS = [
        'pending' => ['paid', 'failed'],
        'paid' => ['shipping', 'refunded'],
        'failed' => ['pending'],          // 重新付款
        'shipping' => ['completed'],
        'completed' => [],                // 終態
        'refunded' => [],                 // 終態
    ];

    public function transitionTo(OrderStatus $newStatus): void
    {
        $allowed = self::ALLOWED_TRANSITIONS[$this->status->value] ?? [];

        if (! in_array($newStatus->value, $allowed)) {
            throw new \InvalidArgumentException(
                "無法將訂單從「{$this->status->label()}」轉換為「{$newStatus->label()}」"
            );
        }

        $this->update(['status' => $newStatus]);
    }

    public function markAsPaid(string $paymentIntentId): void
    {
        $this->transitionTo(OrderStatus::Paid);

        $this->update([
            'stripe_payment_intent_id' => $paymentIntentId,
            'paid_at' => now(),
        ]);
    }

    public function markAsFailed(): void
    {
        $this->transitionTo(OrderStatus::Failed);
    }

    public function markAsShipping(): void
    {
        $this->transitionTo(OrderStatus::Shipping);
    }

    public function markAsCompleted(): void
    {
        $this->transitionTo(OrderStatus::Completed);
    }

    // ── 查詢 ──

    public function isPaid(): bool
    {
        return $this->status === OrderStatus::Paid;
    }

    public function canPay(): bool
    {
        return $this->status === OrderStatus::Pending
            || $this->status === OrderStatus::Failed;
    }
}

為什麼要搞這麼「囉嗦」的狀態機?因為不合法的狀態轉換是訂單系統裡最常見的 bug。你可能在某個 Controller 裡不小心把已退款的訂單又改成已付款,然後使用者收到錯誤的通知、財務報表數字對不上、客服接到一堆抱怨電話。把合法的轉換規則寫死在 Model 裡,任何不合法的操作都會直接拋例外——bug 在開發階段就被抓到,不會偷偷溜到正式環境。

實作:揪好買成團後收款流程

理論講完了,讓我們把所有東西串起來。揪好買的收款流程是這樣的:

1. 成團確認([上一章的邏輯](/blog/laravel-guide-group-buy-logic-session/))
2. 為每個跟團者建立 Order(DB::transaction)
3. 寄出付款連結給每個跟團者
4. 跟團者點連結 → 導向 Stripe Checkout
5. 付款完成 → Stripe 打 webhook → 更新訂單狀態
6. 所有人都付款了 → 團購狀態改為 completed

Step 1:成團後批次建立訂單

<?php

// app/Services/GroupBuyService.php
namespace App\Services;

use App\Enums\OrderStatus;
use App\Models\GroupBuy;
use App\Models\Order;
use Illuminate\Support\Facades\DB;

class GroupBuyService
{
    /**
     * 成團確認:為所有跟團者建立訂單
     */
    public function confirm(GroupBuy $groupBuy): void
    {
        // 防呆:已經確認過的不能再確認
        if ($groupBuy->status !== 'open') {
            throw new \RuntimeException('此團購已非開放狀態,無法確認成團');
        }

        // 防呆:人數不夠不能成團
        if ($groupBuy->participants()->count() < $groupBuy->min_participants) {
            throw new \RuntimeException('跟團人數未達最低門檻');
        }

        DB::transaction(function () use ($groupBuy) {
            // 更新團購狀態
            $groupBuy->update(['status' => 'confirmed']);

            // 為每個跟團者建立訂單
            foreach ($groupBuy->participants as $participant) {
                $quantity = $participant->pivot->quantity;

                $order = Order::create([
                    'user_id' => $participant->id,
                    'group_buy_id' => $groupBuy->id,
                    'total' => $groupBuy->price_per_unit * $quantity,
                    'status' => OrderStatus::Pending,
                ]);

                $order->items()->create([
                    'product_name' => $groupBuy->product_name,
                    'quantity' => $quantity,
                    'unit_price' => $groupBuy->price_per_unit,
                    'subtotal' => $groupBuy->price_per_unit * $quantity,
                ]);
            }
        });

        // 寄出付款通知(下一章用 Queue + Notification,詳見 /blog/laravel-guide-queues-events-notifications/)
        // event(new GroupBuyConfirmed($groupBuy));
    }
}

整個操作包在 DB::transaction() 裡——如果幫第五個人建立訂單的時候炸了,前四個人的訂單也會被回滾。不會出現「有些人有訂單有些人沒有」的混亂狀態。

Step 2:CheckoutController 完整實作

<?php

// app/Http/Controllers/CheckoutController.php
namespace App\Http\Controllers;

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

class CheckoutController extends Controller
{
    /**
     * 導向 Stripe Checkout 付款頁
     */
    public function create(Order $order)
    {
        $user = auth()->user();

        // 確認是自己的訂單
        if ($order->user_id !== $user->id) {
            abort(403, '這不是你的訂單');
        }

        // 確認訂單可以付款
        if (! $order->canPay()) {
            return redirect()->route('orders.show', $order)
                ->with('error', '此訂單目前無法付款');
        }

        $checkout = $user->checkout(
            $order->items->map(fn ($item) => [
                'price_data' => [
                    'currency' => 'twd',
                    'product_data' => [
                        'name' => $item->product_name,
                    ],
                    'unit_amount' => $item->unit_price,
                ],
                'quantity' => $item->quantity,
            ])->toArray(),
            [
                'success_url' => route('checkout.success', $order) . '?session_id={CHECKOUT_SESSION_ID}',
                'cancel_url' => route('checkout.cancel', $order),
                'metadata' => [
                    'order_id' => $order->id,
                ],
            ]
        );

        $order->update([
            'stripe_checkout_session_id' => $checkout->id,
        ]);

        return redirect($checkout->url);
    }

    /**
     * 付款成功頁面(僅顯示用,真正的狀態更新靠 webhook)
     */
    public function success(Request $request, Order $order)
    {
        return view('checkout.success', [
            'order' => $order->load('items', 'groupBuy'),
        ]);
    }

    /**
     * 使用者取消付款
     */
    public function cancel(Request $request, Order $order)
    {
        return view('checkout.cancel', [
            'order' => $order->load('groupBuy'),
        ]);
    }
}

Step 3:路由設定

// routes/web.php
use App\Http\Controllers\CheckoutController;

Route::middleware(['auth', 'verified'])->group(function () {
    // 訂單
    Route::get('/orders', [OrderController::class, 'index'])->name('orders.index');
    Route::get('/orders/{order}', [OrderController::class, 'show'])->name('orders.show');

    // Checkout
    Route::get('/checkout/{order}', [CheckoutController::class, 'create'])->name('checkout.create');
    Route::get('/checkout/{order}/success', [CheckoutController::class, 'success'])->name('checkout.success');
    Route::get('/checkout/{order}/cancel', [CheckoutController::class, 'cancel'])->name('checkout.cancel');
});

Step 4:訂單頁面 View

{{-- resources/views/orders/show.blade.php --}}
<div class="max-w-2xl mx-auto py-8">
  <h1 class="text-2xl font-bold mb-4">訂單 #{{ $order->id }}</h1>

  <div class="bg-white rounded-lg shadow p-6 mb-6">
    <div class="flex justify-between items-center mb-4">
      <span class="text-gray-600">團購</span>
      <span>{{ $order->groupBuy->title }}</span>
    </div>

    <div class="flex justify-between items-center mb-4">
      <span class="text-gray-600">狀態</span>
      <span
        class="px-2 py-1 rounded text-sm bg-{{ $order->status->color() }}-100 text-{{ $order->status->color() }}-800"
      >
        {{ $order->status->label() }}
      </span>
    </div>

    <hr class="my-4" />

    @foreach ($order->items as $item)
    <div class="flex justify-between items-center py-2">
      <span>{{ $item->product_name }} x {{ $item->quantity }}</span>
      <span>NT$ {{ number_format($item->subtotal) }}</span>
    </div>
    @endforeach

    <hr class="my-4" />

    <div class="flex justify-between items-center font-bold text-lg">
      <span>合計</span>
      <span>NT$ {{ number_format($order->total) }}</span>
    </div>
  </div>

  @if ($order->canPay())
  <a
    href="{{ route('checkout.create', $order) }}"
    class="block w-full text-center bg-indigo-600 text-white py-3 rounded-lg hover:bg-indigo-700 transition"
  >
    前往付款
  </a>
  @endif

  @if ($order->isPaid())
  <div class="text-center text-green-600 font-medium">
    已於 {{ $order->paid_at->format('Y/m/d H:i') }} 完成付款
  </div>
  @endif
</div>

Step 5:用 Stripe 測試模式跑完整流程

# 終端機 1:啟動 Laravel
php artisan serve

# 終端機 2:啟動 Stripe CLI 轉發 webhook
stripe listen --forward-to localhost:8000/stripe/webhook

# 終端機 3:用 Tinker 模擬成團
php artisan tinker
>>> $groupBuy = GroupBuy::first();
>>> app(GroupBuyService::class)->confirm($groupBuy);
>>> Order::where('group_buy_id', $groupBuy->id)->count();
// 應該等於跟團人數

然後用瀏覽器登入任一跟團者帳號,進入訂單頁面,點「前往付款」。你會被導到 Stripe 的付款頁面——輸入測試卡號 4242 4242 4242 4242,填任意到期日和 CVC,按下付款。

付款成功後:

  1. 瀏覽器被導回 success 頁面
  2. Stripe CLI 顯示 checkout.session.completed 事件被轉發
  3. 訂單狀態從 pending 變成 paid
# 在 Tinker 裡確認
>>> Order::first()->fresh()->status
// App\Enums\OrderStatus::Paid

處理「某個人付款失敗」

團購場景的特殊挑戰:10 個人跟團,9 個付款成功,1 個付款失敗。怎麼辦?

幾種策略:

// 策略一:給期限,逾期自動取消
// 可以用 Laravel Scheduler(第十二章會教)
// 每小時檢查一次,超過 48 小時沒付款的訂單標記為 failed

// 策略二:允許部分付款完成,照常出貨
// 適合數量彈性的團購

// 策略三:所有人都付款才出貨
// 適合需要精確數量的團購

揪好買採用策略一——給 48 小時的付款期限,逾期自動標記為失敗,並釋放名額。具體的排程實作會在第十二章搭配 Scheduler 來做。

小結:讓 Stripe 處理金流,你專注在產品

這一章我們走過了金流整合的完整流程:

  • PCI DSS 合規——不要自己存信用卡號,讓 Stripe 處理
  • Laravel Cashier——composer require laravel/cashier,加 Billable trait,搞定
  • Stripe 測試模式——測試卡號 + Stripe CLI,完全在本地測試金流
  • 訂單設計——Order + OrderItem,用 Enum 管理狀態,用 DB Transaction 確保一致性
  • Checkout Session——建立 Session → 導向 Stripe → 使用者付款 → 回到你的網站
  • Webhook——Stripe 打你的伺服器通知付款結果,Cashier 驗證簽名確保安全
  • 狀態機——明確定義合法的狀態轉換,避免不合法的操作

最關鍵的心法是:不要在 success URL 的 callback 裡更新訂單狀態。唯一可信的付款確認來源是 webhook。這不是 Laravel 的規矩,而是所有金流整合的通用原則——不管你用的是 Stripe、ECPay 還是任何其他支付服務。

下一章我們要處理成團後的一連串後續動作——寄確認信、推播通知、更新統計。如果這些全部塞在同一個 HTTP request 裡,使用者要等十秒才看到回應。Queue 與 Event 讓你把這些耗時任務丟到背景去跑,使用者按下按鈕的瞬間就看到回應。

留言討論

esc
輸入關鍵字搜尋文章...
查看收藏 →