53 lines
1.9 KiB
PHP
53 lines
1.9 KiB
PHP
<?php
|
|
|
|
namespace App\Actions;
|
|
|
|
use App\Models\Order;
|
|
use App\Models\Subscription;
|
|
|
|
/**
|
|
* Opens the contract a paid order bought.
|
|
*
|
|
* This is the moment the catalogue stops applying. Everything the customer is
|
|
* owed — price, quotas, seats, the hardware behind the plan — is copied onto
|
|
* the subscription here and never read from the catalogue again. Provisioning
|
|
* sizes the machine from this row, so an operator editing a plan afterwards
|
|
* cannot reach a customer who has already paid.
|
|
*
|
|
* `price_cents` is the catalogue's NET price, which is what PlanChange prorates
|
|
* against — deliberately not `Order::amount_cents`, which holds the GROSS total
|
|
* Stripe actually charged. The two can legitimately differ (VAT, and later
|
|
* coupons), and reconciling them is not this action's job: the proof register
|
|
* records what was charged per event, and Stripe's invoice is the authority for
|
|
* the amount. Copying a gross total into this net field would silently corrupt
|
|
* every pro-rata calculation that reads it.
|
|
*/
|
|
class OpenSubscription
|
|
{
|
|
public function __invoke(Order $order, string $term = Subscription::TERM_MONTHLY): Subscription
|
|
{
|
|
// A retried webhook must not open a second contract for one purchase.
|
|
$existing = Subscription::query()->where('order_id', $order->id)->first();
|
|
|
|
if ($existing !== null) {
|
|
return $existing;
|
|
}
|
|
|
|
$start = now();
|
|
|
|
return Subscription::create(array_merge(
|
|
Subscription::snapshotFrom($order->plan, $term),
|
|
[
|
|
'customer_id' => $order->customer_id,
|
|
'order_id' => $order->id,
|
|
'started_at' => $start,
|
|
'current_period_start' => $start,
|
|
'current_period_end' => $term === Subscription::TERM_YEARLY
|
|
? $start->copy()->addYear()
|
|
: $start->copy()->addMonth(),
|
|
'status' => 'active',
|
|
],
|
|
));
|
|
}
|
|
}
|