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 $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(); });