<?php

use App\Actions\BookAddon;
use App\Models\Customer;
use App\Models\Invoice;
use App\Models\StripeAddonPrice;
use App\Models\StripePlanPrice;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use App\Models\User;
use App\Services\Billing\PlanCatalogue;
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.
 *
 * The version ON SALE, not the family's only priced row: the ladder switch left
 * every family with a closed predecessor beside its current version, and both
 * are mirrored into Stripe — a checkout that began before the switch is still
 * owed the Price it was quoted. Asking the family for "its" monthly row would
 * now find two and throw.
 */
function packagePrice(string $plan, TaxTreatment $treatment, string $term = Subscription::TERM_MONTHLY): string
{
    $row = app(PlanCatalogue::class)->currentVersion($plan)->priceFor($term);

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

    // 79,00 € plus 20 %, which is the figure on the website.
    expect(chargedByCheckout($this->stripe, 0))->toBe(9480)
        ->and(chargedByCheckout($this->stripe, 1))->toBe(9480)
        ->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(7900)
        ->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: 94,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' => 7900, 'currency' => 'eur',
            'metadata' => ['plan' => 'team', 'datacenter' => 'fsn'],
        ]],
    ])->assertOk();

    $invoice = Invoice::query()->sole();

    expect($invoice->gross_cents)->toBe(7900)
        ->and($invoice->net_cents)->toBe(7900)
        ->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(7900)
        ->and($record->snapshot['amounts']['charged_gross_cents'])->toBe(7900)
        ->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(9480)
        ->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(7900);
});

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

    // Twelve priced rows: three SELLABLE families (Start, Team, Business) on
    // six versions — the ladder switch left a closed predecessor beside each
    // one it replaced — at two terms each. A closed version keeps its Prices
    // because a checkout quoted on it is still owed them. Enterprise and the
    // internal package are `internal = true` and stripe:sync-catalogue skips
    // them entirely since Task 12 — a package nobody is ever charged through
    // Stripe gets no Stripe Price, because one cannot be deleted once minted.
    expect($this->stripe->prices)->toHaveCount($before)
        // Two rows per priced catalogue row, both live, one per treatment.
        ->and(StripePlanPrice::query()->count())->toBe(24)
        ->and(StripePlanPrice::query()->whereNull('archived_at')->count())->toBe(24)
        ->and(StripePlanPrice::query()->where('reverse_charge', true)->count())->toBe(12)
        // 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(24);
});

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 twelve SELLABLE priced rows (Enterprise and the internal
        // package are skipped, Task 12) 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 + 12 + $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();
});
