CluPilotCloud/tests/Feature/Billing/ReverseChargePriceTest.php

401 lines
18 KiB
PHP

<?php
use App\Actions\BookAddon;
use App\Models\Customer;
use App\Models\Invoice;
use App\Models\PlanPrice;
use App\Models\StripeAddonPrice;
use App\Models\StripePlanPrice;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use App\Models\User;
use App\Services\Billing\PlanPrices;
use App\Services\Billing\TaxTreatment;
use App\Services\Stripe\FakeStripeClient;
use App\Services\Stripe\StripeClient;
use App\Services\Tax\FakeVatIdVerifier;
use App\Services\Tax\VatIdVerifier;
use App\Support\CompanyProfile;
use App\Support\Settings;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\Queue;
/**
* The price on the website is the price charged — and for the one customer who is
* charged no VAT, that is the net.
*
* A domestic customer pays the gross whether they are a private person or a
* business, and that is right: the VAT is on their invoice and an Austrian
* business reclaims it as input tax, so it passes straight through them. A
* business in another member state with a VERIFIED VAT id gets reverse charge —
* rate nought, no VAT line, the note on the document — and there is nothing on
* their invoice to reclaim. Charging them the gross was therefore a flat 20 %
* surcharge on exactly the customers who read their invoices, and their document
* then stated the whole of it as net at 0 %, so they self-accounted their own VAT
* on a base a fifth too large.
*
* The fix is that a Stripe Price CAN ask who is buying: there are two per sellable
* thing, and the checkout picks by TaxTreatment. Everything here is about those
* two Prices — who gets which, what happens when a customer's status changes after
* they have bought, and that the sync which mints them stays safe to re-run.
*/
beforeEach(function () {
$this->stripe = new FakeStripeClient;
app()->instance(StripeClient::class, $this->stripe);
// The catalogue mirrored into Stripe: two Prices per priced row and per
// module interval. Nothing here mints a Price on demand, deliberately — see
// App\Services\Billing\PlanPrices — so a checkout has to find one waiting.
app(Kernel::class)->call('stripe:sync-catalogue');
CompanyProfile::put([
'name' => 'CluPilot Cloud e.U.', 'address' => 'Dreherstraße 66/1/8',
'postcode' => '1110', 'city' => 'Wien', 'vat_id' => 'ATU00000000',
]);
});
/**
* Somebody who can reach the checkout, and the customer record the treatment is
* read from.
*
* @param array<string, mixed> $attributes
* @return array{0: User, 1: Customer}
*/
function buyerWith(array $attributes): array
{
$customer = Customer::factory()->create($attributes);
$user = User::factory()->create([
'email' => $customer->email,
'email_verified_at' => now(),
]);
return [$user, $customer];
}
/** A business in another member state whose number the register knows. */
function verifiedEuBusiness(): array
{
$verifier = new FakeVatIdVerifier;
$verifier->registered('NL123456789B01', 'Berger B.V.');
app()->instance(VatIdVerifier::class, $verifier);
return buyerWith([
'customer_type' => Customer::TYPE_BUSINESS,
'vat_id' => 'NL123456789B01',
'vat_id_verified_at' => now(),
'vat_id_verified_value' => 'NL123456789B01',
]);
}
/** What the Price a checkout was opened on actually charges. */
function chargedByCheckout(FakeStripeClient $stripe, int $index = 0): int
{
return (int) $stripe->prices[$stripe->checkouts[$index]['price']]['amount'];
}
/** The Stripe Price the catalogue sells a package on to a customer treated so. */
function packagePrice(string $plan, TaxTreatment $treatment, string $term = Subscription::TERM_MONTHLY): string
{
$row = PlanPrice::query()
->whereHas('version.family', fn ($query) => $query->where('key', $plan))
->where('term', $term)
->sole();
return (string) app(PlanPrices::class)->liveFor($row, $treatment);
}
it('charges a domestic consumer and a domestic business the same gross, to the cent', function () {
// The owner's rule, and it is right for both of them: the business pays the
// gross, the VAT is on the invoice, they reclaim it as input tax.
[$consumer] = buyerWith(['customer_type' => Customer::TYPE_CONSUMER]);
[$business] = buyerWith([
'customer_type' => Customer::TYPE_BUSINESS,
// Austrian, and verified. Reverse charge is an INTRA-EU rule and this is
// our own member state, so it changes nothing at all: Austria B2B 20 %.
'vat_id' => 'ATU12345678',
'vat_id_verified_at' => now(),
'vat_id_verified_value' => 'ATU12345678',
]);
$this->actingAs($consumer)->post(route('checkout.start'), ['plan' => 'team', 'terms_accepted' => '1'])
->assertRedirect();
$this->actingAs($business)->post(route('checkout.start'), ['plan' => 'team', 'terms_accepted' => '1'])
->assertRedirect();
// 179,00 € plus 20 %, which is the figure on the website.
expect(chargedByCheckout($this->stripe, 0))->toBe(21480)
->and(chargedByCheckout($this->stripe, 1))->toBe(21480)
->and($this->stripe->checkouts[0]['price'])
->toBe($this->stripe->checkouts[1]['price'])
->and($this->stripe->checkouts[0]['price'])
->toBe(packagePrice('team', TaxTreatment::domestic()));
});
it('charges a verified business in another member state the bare net, and invoices exactly that', function () {
Queue::fake();
[$user] = verifiedEuBusiness();
$this->actingAs($user)->post(route('checkout.start'), ['plan' => 'team', 'terms_accepted' => '1'])
->assertRedirect();
// The bare net: no VAT is owed to us, so there is none in the price either.
// A different Stripe Price from the domestic one, on the same Product.
expect(chargedByCheckout($this->stripe))->toBe(17900)
->and($this->stripe->checkouts[0]['price'])
->toBe(packagePrice('team', TaxTreatment::reverseCharge()))
->and($this->stripe->checkouts[0]['price'])
->not->toBe(packagePrice('team', TaxTreatment::domestic()));
// And what Stripe took is what the document says. This is the half that made
// the overcharge unlawful as well as expensive: 214,80 € taken, and a document
// stating all of it as net at 0 %, so the customer self-accounted their VAT on
// a fifth too much.
$this->postJson(route('webhooks.stripe'), [
'id' => 'evt_rc', 'type' => 'checkout.session.completed',
'data' => ['object' => [
'id' => 'cs_rc', 'payment_status' => 'paid', 'subscription' => 'sub_rc',
'customer_details' => ['email' => $user->email, 'name' => 'Berger B.V.'],
'amount_total' => 17900, 'currency' => 'eur',
'metadata' => ['plan' => 'team', 'datacenter' => 'fsn'],
]],
])->assertOk();
$invoice = Invoice::query()->sole();
expect($invoice->gross_cents)->toBe(17900)
->and($invoice->net_cents)->toBe(17900)
->and($invoice->tax_cents)->toBe(0)
// The note that makes a zero-rated document lawful.
->and($invoice->snapshot['meta']['closing'])->toBe(__('invoice.reverse_charge'));
// The register holds the charge against what this customer should have been
// charged, which is the net — so a correct reverse-charge sale is no longer
// reported as a mismatch, and an overcharged one would be.
$record = SubscriptionRecord::query()->where('event', SubscriptionRecord::EVENT_PURCHASE)->sole();
expect($record->snapshot['amounts']['expected_gross_cents'])->toBe(17900)
->and($record->snapshot['amounts']['charged_gross_cents'])->toBe(17900)
->and($record->snapshot['amounts']['matches_catalogue'])->toBeTrue()
->and($record->reverse_charge)->toBeTrue();
});
it('charges an unverified EU business the gross, because an unchecked number is no discount', function () {
// The number looks exactly like the verified one. Nobody has asked the
// register about it, and a self-declared string must never be able to zero a
// tax — otherwise typing "NL…" is a twenty percent discount.
[$user] = buyerWith([
'customer_type' => Customer::TYPE_BUSINESS,
'vat_id' => 'NL123456789B01',
]);
$this->actingAs($user)->post(route('checkout.start'), ['plan' => 'team', 'terms_accepted' => '1'])
->assertRedirect();
expect(chargedByCheckout($this->stripe))->toBe(21480)
->and($this->stripe->checkouts[0]['price'])->toBe(packagePrice('team', TaxTreatment::domestic()));
});
it('charges the setup fee under the same rule as the package', function () {
// 99,00 € net, which is the 118,80 € the public price sheet quotes at 20 %.
Settings::set('company.setup_fee_cents', 9900);
[$domestic] = buyerWith(['customer_type' => Customer::TYPE_CONSUMER]);
[$business] = verifiedEuBusiness();
$this->actingAs($domestic)->post(route('checkout.start'), ['plan' => 'team', 'terms_accepted' => '1']);
$this->actingAs($business)->post(route('checkout.start'), ['plan' => 'team', 'terms_accepted' => '1']);
// A fee is a supply like the package it sets up, so it cannot be taxed
// differently from it on the same invoice — and the webhook is told how much
// of the total was the fee, so the figure has to be this customer's own.
expect($this->stripe->checkouts[0]['one_off']['amount_cents'])->toBe(11880)
->and($this->stripe->checkouts[0]['metadata']['setup_fee_cents'])->toBe('11880')
->and($this->stripe->checkouts[1]['one_off']['amount_cents'])->toBe(9900)
->and($this->stripe->checkouts[1]['metadata']['setup_fee_cents'])->toBe('9900');
});
it('bills a booked module on the net price too, for the customer who owes no VAT', function () {
[, $customer] = verifiedEuBusiness();
$contract = Subscription::factory()->plan('team')->create([
'customer_id' => $customer->id,
'stripe_subscription_id' => 'sub_rc_mod',
'stripe_item_id' => 'si_rc_plan',
]);
app(BookAddon::class)($contract, 'priority_support'); // 29,00 net / month
$item = collect($this->stripe->itemsOn('sub_rc_mod'))->sole();
// 29,00 € and not 34,80 €. A module taxed differently from the package beside
// it would be one invoice at two rates.
expect((int) $this->stripe->prices[$item['price']]['amount'])->toBe(2900)
->and(StripeAddonPrice::query()
->where('stripe_price_id', $item['price'])
->value('reverse_charge'))->toBeTruthy();
});
it('moves a contract onto the net price when the VAT id is verified after the sale, without prorating', function () {
// Bought as an ordinary business: the number was on the record, nobody had
// checked it, so the gross was charged and that was correct at the time.
$verifier = new FakeVatIdVerifier;
$verifier->registered('NL123456789B01', 'Berger B.V.');
app()->instance(VatIdVerifier::class, $verifier);
[, $customer] = buyerWith([
'customer_type' => Customer::TYPE_BUSINESS,
'vat_id' => 'NL123456789B01',
]);
$contract = Subscription::factory()->plan('team')->create([
'customer_id' => $customer->id,
'stripe_subscription_id' => 'sub_late',
'stripe_item_id' => 'si_late',
'stripe_price_id' => packagePrice('team', TaxTreatment::domestic()),
]);
// Nothing to do while the number is unchecked, and a run that moved them here
// would be a run that trusted a string somebody typed.
$this->artisan('stripe:reprice-subscriptions')->assertSuccessful();
expect($this->stripe->priceChanges)->toBeEmpty();
// A week later the register answers, and from that moment they owe no VAT.
$this->artisan('clupilot:verify-vat-ids')->assertSuccessful();
expect($customer->fresh()->hasVerifiedVatId())->toBeTrue();
$this->artisan('stripe:reprice-subscriptions')->assertSuccessful();
expect($this->stripe->priceChanges)->toHaveCount(1)
->and($this->stripe->priceChanges[0]['price'])->toBe(packagePrice('team', TaxTreatment::reverseCharge()))
// NEVER prorated. The term they are in is paid for, and charging or
// crediting the difference for days already served would be a bill
// nobody agreed to.
->and($this->stripe->priceChanges[0]['proration'])->toBe(StripeClient::PRORATE_NONE)
// And the contract records which of the two Prices it is now billed on,
// which is what makes a second run free.
->and($contract->fresh()->stripe_price_id)
->toBe(packagePrice('team', TaxTreatment::reverseCharge()))
->and(StripePlanPrice::query()
->where('stripe_price_id', $contract->fresh()->stripe_price_id)
->value('reverse_charge'))->toBeTruthy();
// Converged: the second run finds nothing left to move.
$this->artisan('stripe:reprice-subscriptions')->assertSuccessful();
expect($this->stripe->priceChanges)->toHaveCount(1);
// The contract's own frozen figure is untouched by all of it. It is the
// catalogue's NET amount, every pro-rata sum reads it, and reinterpreting it
// would corrupt every plan change ever computed.
expect($contract->fresh()->price_cents)->toBe(17900);
});
it('moves a contract back onto the gross price when a registration lapses', function () {
[, $customer] = verifiedEuBusiness();
$contract = Subscription::factory()->plan('team')->create([
'customer_id' => $customer->id,
'stripe_subscription_id' => 'sub_lapse',
'stripe_item_id' => 'si_lapse',
'stripe_price_id' => packagePrice('team', TaxTreatment::reverseCharge()),
]);
$this->artisan('stripe:reprice-subscriptions')->assertSuccessful();
expect($this->stripe->priceChanges)->toBeEmpty();
// The company is wound up, or the registration is withdrawn. From that moment
// we are the ones liable for the VAT nobody collected, so the customer goes
// back onto the domestic Price.
$verifier = new FakeVatIdVerifier;
$verifier->notRegistered('NL123456789B01');
app()->instance(VatIdVerifier::class, $verifier);
$this->artisan('clupilot:verify-vat-ids')->assertSuccessful();
expect($customer->fresh()->hasVerifiedVatId())->toBeFalse();
$this->artisan('stripe:reprice-subscriptions')->assertSuccessful();
expect($this->stripe->priceChanges)->toHaveCount(1)
->and($this->stripe->priceChanges[0]['price'])->toBe(packagePrice('team', TaxTreatment::domestic()))
->and($this->stripe->priceChanges[0]['proration'])->toBe(StripeClient::PRORATE_NONE)
->and($contract->fresh()->stripe_price_id)->toBe(packagePrice('team', TaxTreatment::domestic()));
});
it('creates no second Price for a figure it already has, however often it is run', function () {
$before = count($this->stripe->prices);
// A Stripe Price cannot be edited or deleted, so a run that minted duplicates
// would leave two live Prices for one row at one figure for ever — and nothing
// could tell afterwards which of them a customer is billed on.
$this->artisan('stripe:sync-catalogue')
->expectsOutputToContain('already in step')
->assertSuccessful();
$this->artisan('stripe:sync-catalogue')->assertSuccessful();
expect($this->stripe->prices)->toHaveCount($before)
// Two rows per priced catalogue row, both live, one per treatment.
->and(StripePlanPrice::query()->count())->toBe(16)
->and(StripePlanPrice::query()->whereNull('archived_at')->count())->toBe(16)
->and(StripePlanPrice::query()->where('reverse_charge', true)->count())->toBe(8)
// And no Price at one figure for one row twice, which is the thing the
// register's unique key exists to make impossible.
->and(StripePlanPrice::query()->distinct()->count('stripe_price_id'))->toBe(16);
});
it('leaves the net Price alone when the VAT rate moves, and puts a Price back on sale rather than minting a second', function () {
$domesticBefore = packagePrice('team', TaxTreatment::domestic());
$netPrice = packagePrice('team', TaxTreatment::reverseCharge());
$count = count($this->stripe->prices);
$modules = count(array_merge(array_keys((array) config('provisioning.addons')), ['storage']));
// The rate moves. Only the GROSS half of each pair is wrong: the net owes
// nothing to the rate, so a sweep that did not know the difference would
// withdraw every reverse-charge Price the first time an operator touched the
// Finance page.
Settings::set('company.tax_rate', 10.0);
$this->artisan('stripe:sync-catalogue')->assertSuccessful();
expect(packagePrice('team', TaxTreatment::domestic()))->not->toBe($domesticBefore)
->and($this->stripe->archived)->toContain($domesticBefore)
->and(packagePrice('team', TaxTreatment::reverseCharge()))->toBe($netPrice)
->and($this->stripe->archived)->not->toContain($netPrice);
// And back again. A Stripe Price cannot be edited, so the one for the figure
// we have returned to is still there — brought back on sale rather than
// duplicated, AT STRIPE as well as here. Only in our own table it would leave
// the catalogue pointing at an inactive Price, and the failure would be a
// customer meeting a refused checkout.
Settings::set('company.tax_rate', 20.0);
$this->artisan('stripe:sync-catalogue')->assertSuccessful();
expect(packagePrice('team', TaxTreatment::domestic()))->toBe($domesticBefore)
->and($this->stripe->activated)->toContain($domesticBefore)
->and($this->stripe->archived)->not->toContain($domesticBefore)
// What the rate change minted and nothing more: one 10 % gross Price for
// each of the eight priced rows and for each module on each interval. No
// duplicate of anything that existed before, and nothing new on the net
// side at all.
->and($this->stripe->prices)->toHaveCount($count + 8 + $modules * 2);
});
it('refuses the sale rather than overcharging a business the catalogue has no net price for', function () {
// The state a deploy leaves behind until stripe:sync-catalogue has run: the
// domestic Price exists and the net one does not. There IS a fallback here and
// it must not be taken — putting them on the domestic Price takes a fifth more
// than they owe, which is the whole defect. A refused checkout is loud, and one
// command cures it.
StripePlanPrice::query()->where('reverse_charge', true)->delete();
[$user] = verifiedEuBusiness();
$this->actingAs($user)->post(route('checkout.start'), ['plan' => 'team', 'terms_accepted' => '1'])
->assertSessionHasErrors('plan');
expect($this->stripe->checkouts)->toBeEmpty();
});