PAYATHON 2026

Laravel Spark 如何实现「All My Teams」统一计费?

支付小周

结论

Laravel Spark 通常不原生支持「多个团队共享一个订阅」这种层级计费模式。

Spark 的基本逻辑是一个可计费实体对应一份订阅。可计费实体可以是用户,也可以是团队,但 Spark 不会自动把多个团队组合成更高一级的计费对象。具体来说:

  • 个人计费时,订阅归属于用户。
  • 团队计费时,订阅归属于某个团队,成员通过该团队获得服务权限。
  • 如果需要「All My Teams」计费,则要额外建立父级实体,例如 Organization、Account 或 Tenant,由这个实体持有订阅,其下关联多个团队。

团队计费也不等于为团队中的每个用户分别创建订阅。更常见的做法是由团队持有一份订阅,再根据团队成员关系和套餐限制决定成员能否使用服务。

不同代际和版本的 Spark 在扩展方式上差异较大,不能假定某个具体版本一定提供可直接配置的 Organization 或多团队聚合功能。不过,从数据模型和计费架构来看,这类需求已经有比较成熟的通用实现方式。

推荐的数据模型

不要直接把管理员用户设为所有团队的计费主体。更稳妥的设计是增加一个独立的组织或客户账户:

Organization
├── Subscription
├── Billing administrators
├── Team A
│   ├── User 1
│   └── User 2
├── Team B
│   ├── User 3
│   └── User 4
└── Team C
    └── User 5

对应关系可以设计为:

organizations
    id
    name
    owner_id

teams
    id
    organization_id
    name

organization_user
    organization_id
    user_id
    role

team_user
    team_id
    user_id
    role

订阅、支付方式、账单地址和 Stripe Customer 等计费信息都归属于 Organization,而不是某个用户或单个团队。

这样设计的原因很直接:管理员可能离职、被替换或失去管理权限,但组织及其订阅仍需继续存在。

整体实现步骤

1. 创建父级计费实体

使用 Organization 作为多个团队的共同父级:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Laravel\Cashier\Billable;

class Organization extends Model
{
    use Billable;

    protected $fillable = [
        'name',
        'owner_id',
    ];

    public function teams()
    {
        return $this->hasMany(Team::class);
    }

    public function owner()
    {
        return $this->belongsTo(User::class, 'owner_id');
    }

    public function members()
    {
        return $this->belongsToMany(User::class)
            ->withPivot('role')
            ->withTimestamps();
    }
}

这段代码展示的是一种通用设计,即让 Organization 成为 Laravel Cashier 的可计费模型。它能否直接接入 Spark 的账单界面,要看当前 Spark 版本及其对可计费模型的配置能力。

2. 让团队归属于组织

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Team extends Model
{
    protected $fillable = [
        'organization_id',
        'name',
    ];

    public function organization()
    {
        return $this->belongsTo(Organization::class);
    }

    public function users()
    {
        return $this->belongsToMany(User::class)
            ->withTimestamps();
    }
}

创建团队时,应通过当前组织上下文建立关系,不要直接接受未经校验的 organization_id:

$team = $organization->teams()->create([
    'name' => $validated['name'],
]);

3. 在父级实体上创建订阅

订阅应创建在组织上:

$organization
    ->newSubscription('default', $priceId)
    ->create($paymentMethod);

权限检查也应沿着「用户 → 团队 → 组织 → 订阅」的关系进行:

$hasAccess = $user->teams()
    ->whereHas('organization', function ($query) {
        $query->whereHas('subscriptions', function ($subscriptionQuery) {
            $subscriptionQuery
                ->where('type', 'default')
                ->where('stripe_status', 'active');
        });
    })
    ->exists();

实际应用中,最好把这类判断封装到 Policy、Gate 或领域服务中,避免在控制器和视图里重复查询订阅状态。例如:

public function organizationHasAccess(
    User $user,
    Organization $organization
): bool {
    return $organization->members()
        ->whereKey($user->getKey())
        ->exists()
        && $organization->subscribed('default');
}

套餐数量如何计算

统一订阅只解决了「谁付款」的问题,还要明确具体按什么收费。常见方案有以下三种。

按组织固定收费

一个组织购买一个套餐,套餐允许创建一定数量的团队和成员:

if ($organization->teams()->count() >= $plan->team_limit) {
    throw ValidationException::withMessages([
        'team' => '当前套餐允许创建的团队数量已达上限。',
    ]);
}

这种模式实现简单,账单也容易理解。

按成员席位收费

将所有团队中的有效成员合并去重,然后把人数作为订阅数量:

$seatCount = User::query()
    ->whereHas('teams', function ($query) use ($organization) {
        $query->where('organization_id', $organization->id);
    })
    ->distinct()
    ->count('users.id');

$organization
    ->subscription('default')
    ->updateQuantity(max(1, $seatCount));

同一个用户可能加入同一组织下的多个团队,通常只能计算一个席位,否则会造成重复收费。

按团队数量收费

订阅数量等于组织下的有效团队数:

$teamCount = $organization->teams()
    ->where('billing_status', 'active')
    ->count();

$organization
    ->subscription('default')
    ->updateQuantity(max(1, $teamCount));

这种方式适合代理商、门店、项目空间或分支机构等场景。

如果费用同时取决于团队数和成员数,可以使用多个订阅项目,也可以交给 Stripe 的分级价格或用量计费等能力处理。具体能采用哪种方式,取决于支付平台配置和项目使用的 Cashier 版本。

Spark 在其中承担什么角色

Spark 仍可用于订阅购买、支付方式管理和发票展示等通用账单功能,但以下业务逻辑需要应用自行处理:

  • 组织、团队和成员之间的层级关系;
  • 哪些人可以管理组织账单;
  • 用户通过哪个组织获得服务权限;
  • 如何计算团队数或席位数;
  • 成员加入、退出或跨团队移动时,如何更新订阅数量;
  • 套餐降级后,如何处理超额的团队或成员。

如果当前 Spark 版本只能围绕 User 或既有的 Team 模型提供账单门户,可能需要自定义账单页面,直接通过 Laravel Cashier 管理 Organization 的订阅。不要为了保留 Spark 的默认界面,把组织订阅强行挂到某个管理员用户身上。

是否有现成方案

这不是 Spark 中可以直接开启的标准模式,也没有适用于所有项目的通用「Team of Teams」开关。成熟的是整体架构模式,而不是某个固定代码包:

  1. 使用 Organization 或 Account 作为账单主体。
  2. 把团队作为该账单主体下的资源。
  3. 通过角色系统区分账单管理员、团队管理员和普通成员。
  4. 根据成员数、团队数或固定套餐同步订阅数量。
  5. 使用统一的授权服务判断用户能否继承组织的订阅权益。

第三方多租户或权限组件可以协助处理组织隔离、当前租户和角色权限,但通常不会替项目确定计费规则。计费主体、席位去重、套餐限制和降级行为仍需按业务要求实现。

需要提前决定的边界条件

实现前应先明确以下规则,否则后期很容易出现账单与权限状态不一致的问题:

  • 一个团队是否只能属于一个组织;
  • 一个用户能否属于多个组织;
  • 同一用户加入多个团队时,是否只占一个席位;
  • 已受邀但尚未接受邀请的用户是否计费;
  • 停用、暂停或只读成员是否计费;
  • 删除团队或移除成员后,是立即减少订阅数量,还是在下个账期生效;
  • 订阅欠费或取消后,所有团队是立即停用,还是进入宽限期;
  • 转移组织所有者是否会影响支付客户和历史发票;
  • 并发添加成员时,如何避免席位数量同步错误。

更合适的方案不是修改 Spark 的「团队计费」概念,而是增加更高一级的 Organization 计费模型。Spark 或 Cashier 负责支付生命周期,应用自身的领域模型负责多团队归属、席位计算和授权。这样既能实现统一订阅,也便于后续维护。

备注:内容仅供参考。