$extra merged into the JSON snapshot * @param array $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, ?string $eventKey = null, ): SubscriptionRecord { $at ??= now(); $customer = $subscription->customer; $tax = TaxTreatment::for($customer); $agreedNet = $netCents; // What the till would take for this catalogue figure — the DOMESTIC // gross, for everybody. Not $tax->grossCents(), which is how this // customer's DOCUMENT is split: at a rate of zero that returns the net // figure, and comparing it against the money Stripe actually took // reported every reverse-charge sale charged at exactly the advertised // amount as a mismatch. The one flag whose job is "somebody must look at // this" was false for the whole healthy majority of EU business sales. // // TaxTreatment::chargedCents() is the single place a gross is formed, and // it is what the Stripe Price carries — so this is the figure the charge // can honestly be held against. $expectedGross = TaxTreatment::chargedCents($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 { // Nothing was charged at this event. The columns still state what the // terms are worth, split by this customer's own rate — a // reverse-charge contract owes no VAT and must not have any invented // for it here. $net = $agreedNet; $gross = $tax->grossCents($agreedNet); } $version = $subscription->planVersion; return SubscriptionRecord::create([ 'event' => $event, // What makes this event "the same one" if it arrives again. Unique // where set, so a duplicate delivery collides in the database // rather than in a check that two of them can both pass. 'event_key' => $eventKey, '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, // Null where no money moved at this event, and the flag with // it. Stamping the expected figure into the charged column // made a plan change — where the terms move and Stripe raises // its proration invoice afterwards — claim it had been paid // for, and then agree with itself. A row that answers "was // the right amount taken?" with "yes" when nothing was taken // is worse than one that says it does not know. 'charged_gross_cents' => $chargedGrossCents, 'matches_catalogue' => $chargedGrossCents === null ? null : $chargedGrossCents === $expectedGross, ], ], $extra), ]); } }