401 lines
18 KiB
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();
|
|
});
|