142 lines
5.9 KiB
PHP
142 lines
5.9 KiB
PHP
<?php
|
|
|
|
namespace App\Actions;
|
|
|
|
use App\Models\Order;
|
|
use App\Models\Subscription;
|
|
use App\Models\SubscriptionRecord;
|
|
use App\Services\Billing\TaxTreatment;
|
|
use Illuminate\Support\Carbon;
|
|
|
|
/**
|
|
* Writes one row into the proof register.
|
|
*
|
|
* Every commercial event goes through here so that the flat columns are filled
|
|
* the same way each time — an evidence table where the amount means one thing
|
|
* in one row and another in the next is not evidence.
|
|
*
|
|
* Amounts are stated three ways on purpose. Net is what the catalogue and the
|
|
* contract deal in; gross is what was actually charged; the tax between them
|
|
* depends on the customer and on the day, and recomputing it later from a rate
|
|
* that has since changed would produce a different answer to the one on the
|
|
* invoice.
|
|
*/
|
|
class RecordCommercialEvent
|
|
{
|
|
/**
|
|
* @param array<string, mixed> $extra merged into the JSON snapshot
|
|
* @param array<string, string|null> $stripe event / invoice / subscription ids
|
|
*/
|
|
public function __invoke(
|
|
string $event,
|
|
Subscription $subscription,
|
|
int $netCents,
|
|
?Carbon $at = null,
|
|
array $extra = [],
|
|
array $stripe = [],
|
|
?int $chargedGrossCents = null,
|
|
?Order $order = null,
|
|
): SubscriptionRecord {
|
|
$at ??= now();
|
|
$customer = $subscription->customer;
|
|
$tax = TaxTreatment::for($customer);
|
|
|
|
$agreedNet = $netCents;
|
|
$expectedGross = $tax->grossCents($agreedNet);
|
|
|
|
// The flat columns describe the TRANSACTION, not the contract: gross is
|
|
// what was taken from the customer, and net and tax are that gross
|
|
// split by the rate that applied on the day.
|
|
//
|
|
// Split, not subtracted from the agreed price. A discount lowers the
|
|
// taxable amount; it does not create negative VAT, and recording
|
|
// 14900 charged against a 179,00 contract as "minus 30,00 tax" would
|
|
// make the register wrong in exactly the case someone audits.
|
|
if ($chargedGrossCents !== null) {
|
|
$gross = $chargedGrossCents;
|
|
$net = (int) round($gross / (1 + $tax->rate));
|
|
} else {
|
|
$net = $agreedNet;
|
|
$gross = $expectedGross;
|
|
}
|
|
|
|
$version = $subscription->planVersion;
|
|
|
|
return SubscriptionRecord::create([
|
|
'event' => $event,
|
|
|
|
'customer_id' => $subscription->customer_id,
|
|
'subscription_id' => $subscription->id,
|
|
// The order this event belongs to — a booked module has its own,
|
|
// and pointing it at the original plan purchase would file every
|
|
// add-on under the wrong transaction.
|
|
'order_id' => $order?->id ?? $subscription->order_id,
|
|
'plan_version_id' => $subscription->plan_version_id,
|
|
|
|
// Copied rather than joined: these have to still answer the question
|
|
// after the customer, the plan or the version have gone.
|
|
'customer_name' => $customer?->name,
|
|
'plan_key' => $subscription->plan,
|
|
'plan_version' => $version?->version,
|
|
'term' => $subscription->term,
|
|
|
|
'net_cents' => $net,
|
|
'tax_cents' => $gross - $net,
|
|
'gross_cents' => $gross,
|
|
'currency' => $subscription->currency,
|
|
'tax_rate' => round($tax->rate * 100, 2),
|
|
'reverse_charge' => $tax->reverseCharge,
|
|
|
|
'stripe_event_id' => $stripe['event'] ?? null,
|
|
'stripe_invoice_id' => $stripe['invoice'] ?? null,
|
|
'stripe_subscription_id' => $stripe['subscription'] ?? null,
|
|
|
|
'occurred_at' => $at,
|
|
|
|
'snapshot_version' => SubscriptionRecord::SNAPSHOT_VERSION,
|
|
// The long tail: everything nobody has thought to ask about yet.
|
|
// The flat columns above are what gets queried.
|
|
'snapshot' => array_merge([
|
|
'subscription' => $subscription->only(Subscription::FROZEN),
|
|
'plan' => [
|
|
'key' => $subscription->plan,
|
|
'version' => $version?->version,
|
|
'version_id' => $subscription->plan_version_id,
|
|
'family' => $version?->family?->name,
|
|
'capabilities' => $version?->capabilities(),
|
|
],
|
|
'period' => [
|
|
'start' => $subscription->current_period_start?->toIso8601String(),
|
|
'end' => $subscription->current_period_end?->toIso8601String(),
|
|
],
|
|
'addons' => $subscription->addons()->active()->get()
|
|
->map(fn ($addon) => [
|
|
'key' => $addon->addon_key,
|
|
'price_cents' => $addon->price_cents,
|
|
'quantity' => $addon->quantity,
|
|
'booked_at' => $addon->booked_at?->toIso8601String(),
|
|
])->all(),
|
|
'customer' => [
|
|
'name' => $customer?->name,
|
|
'email' => $customer?->email,
|
|
'vat_id' => $customer?->vat_id,
|
|
],
|
|
// Kept even when they agree. A charge that differs from the
|
|
// catalogue plus tax is exactly the thing someone will ask
|
|
// about later, and reconciling it silently would erase the
|
|
// question along with the answer.
|
|
'amounts' => [
|
|
// What was agreed, beside what was actually taken. The
|
|
// flat columns state the transaction; this states whether
|
|
// it came out at the contract price, which is the question
|
|
// someone asks a year later.
|
|
'agreed_net_cents' => $agreedNet,
|
|
'expected_gross_cents' => $expectedGross,
|
|
'charged_gross_cents' => $gross,
|
|
'matches_catalogue' => $gross === $expectedGross,
|
|
],
|
|
], $extra),
|
|
]);
|
|
}
|
|
}
|