<?php

use App\Models\PlanFamily;
use App\Models\PlanPrice;
use App\Models\PlanVersion;
use App\Models\StripeAddonPrice;
use App\Models\StripePendingEvent;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use App\Services\Billing\PlanCatalogue;
use App\Services\Billing\TaxTreatment;
use App\Services\Stripe\FakeStripeClient;
use App\Services\Stripe\StripeClient;
use App\Support\Settings;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Queue;

/**
 * Stripe owns the billing cycle from here: retries, dunning, off-session SCA
 * and invoice numbering are theirs, and we learn about all of it by webhook.
 * Our side owns capability, because Stripe does not know how big the VM is.
 */
function fakeStripe(): FakeStripeClient
{
    $fake = new FakeStripeClient;
    app()->instance(StripeClient::class, $fake);

    return $fake;
}

/** A contract Stripe will send events about. */
function stripeContract(string $stripeId = 'sub_1'): Subscription
{
    return Subscription::factory()->plan('team')->create([
        'stripe_subscription_id' => $stripeId,
        'current_period_start' => now()->subMonth(),
        'current_period_end' => now(),
    ]);
}

/**
 * The plan half of the mirror — modules live in the same lists.
 *
 * Two Prices per priced row, not one: the domestic gross everybody who is charged
 * VAT pays, and the bare net a verified business in another member state owes.
 * `$treatment` picks one of the two where a test is about a figure rather than
 * about how many objects there are.
 */
function planPrices(FakeStripeClient $stripe, ?string $treatment = null): Collection
{
    return collect($stripe->prices)
        ->filter(fn ($price) => isset($price['metadata']['plan_family']))
        ->filter(fn ($price) => $treatment === null || $price['metadata']['tax_treatment'] === $treatment);
}

function planProducts(FakeStripeClient $stripe): Collection
{
    return collect($stripe->products)->filter(fn ($product) => isset($product['metadata']['plan_family']));
}

/** The module half: a Price per module, term and treatment, so a booking has one to bill on. */
function modulePrices(FakeStripeClient $stripe): Collection
{
    return collect($stripe->prices)->filter(fn ($price) => isset($price['metadata']['addon']));
}

it('mirrors the catalogue into Stripe, once', function () {
    $stripe = fakeStripe();

    $this->artisan('stripe:sync-catalogue')->assertSuccessful();

    // A product per family, and TWO prices per priced row of a published
    // version: the domestic gross and the bare net a reverse-charge business is
    // charged. Both are minted up front, because the Price has to exist before
    // the checkout that needs it.
    // Drei Familien, nicht fünf: Enterprise und das interne Testpaket tragen
    // `internal = true` und werden von stripe:sync-catalogue seit Aufgabe 12
    // übersprungen — ein Paket, das nie über Stripe abgerechnet wird, bekommt
    // keinen Stripe-Preis, weil sich ein einmal angelegter nicht löschen lässt.
    // Zwölf ausgepreiste Zeilen, weil die Umschaltung auf die neue Leiter neben
    // jede der drei verkauften eine beendete Vorgängerfassung gestellt hat —
    // und deren Preise bleiben stehen, denn ein Checkout, der auf ihr eröffnet
    // wurde, hat sie gesehen und bezahlt.
    expect(planProducts($stripe))->toHaveCount(3)
        ->and(planPrices($stripe))->toHaveCount(24)
        ->and(planPrices($stripe, 'domestic'))->toHaveCount(12)
        ->and(planPrices($stripe, 'reverse_charge'))->toHaveCount(12)
        ->and(PlanFamily::query()->where('internal', false)->whereNull('stripe_product_id')->count())->toBe(0)
        // The catalogue row still points at the DOMESTIC Price, which is the
        // ordinary sale; the net one is only ever read out of the register.
        ->and(PlanPrice::query()
            ->whereHas('version.family', fn ($q) => $q->where('internal', false))
            ->whereNull('stripe_price_id')->count())->toBe(0)
        // The two internal families get neither: no product, and no price on
        // either of their rows.
        ->and(PlanFamily::query()->where('internal', true)->whereNotNull('stripe_product_id')->count())->toBe(0)
        ->and(PlanPrice::query()
            ->whereHas('version.family', fn ($q) => $q->where('internal', true))
            ->whereNotNull('stripe_price_id')->count())->toBe(0);

    // And every module we sell, on both terms: a Stripe Price carries its own
    // interval, so a module on a yearly contract needs a yearly one. Without
    // these a booked module could never become an item on the subscription,
    // which is why it was charged in the month it was booked and never again.
    // Times two again, for the same reason as the packages.
    $modules = array_merge(array_keys((array) config('provisioning.addons')), ['storage']);
    $storageYear = StripeAddonPrice::query()->where('addon_key', 'storage')->where('interval', 'year');

    // A yearly module Price is twelve months of the NET figure, grossed once —
    // which is what a yearly customer is actually charged for it. The
    // reverse-charge Price beside it charges that net figure and nothing on top.
    expect(modulePrices($stripe))->toHaveCount(count($modules) * 4)
        ->and((clone $storageYear)->where('reverse_charge', false)->value('amount_cents'))
        ->toBe(TaxTreatment::advertisedCents(12 * (int) config('provisioning.storage_addon.price_cents')))
        ->and((clone $storageYear)->where('reverse_charge', true)->value('amount_cents'))
        ->toBe(12 * (int) config('provisioning.storage_addon.price_cents'))
        ->and((clone $storageYear)->where('reverse_charge', false)->value('net_cents'))
        ->toBe(12 * (int) config('provisioning.storage_addon.price_cents'));

    // A Stripe Price cannot be edited, so a second run that minted duplicates
    // would leave two live prices for one plan and no way to tell them apart.
    $this->artisan('stripe:sync-catalogue')->assertSuccessful();

    expect(planPrices($stripe))->toHaveCount(24)
        ->and(modulePrices($stripe))->toHaveCount(count($modules) * 4)
        ->and($stripe->products)->toHaveCount(3 + count($modules));
});

it('does not mirror an internal family into Stripe, product or price, dry run or real', function () {
    $stripe = fakeStripe();

    // Der Befund, der zu dieser Aufgabe führte: der Trockenlauf listete vier
    // Preise für "test v2", ein Paket, das nie über Stripe abgerechnet wird —
    // es wird von Hand vergeben, nie gekauft. Weder das interne Testpaket noch
    // Enterprise (auf Anfrage vergeben, `internal = true` seit derselben
    // Migration) dürfen hier auftauchen.
    $this->artisan('stripe:sync-catalogue', ['--dry-run' => true])
        ->doesntExpectOutputToContain('test v')
        ->doesntExpectOutputToContain('enterprise')
        ->assertSuccessful();

    $this->artisan('stripe:sync-catalogue')->assertSuccessful();

    $internalFamilies = PlanFamily::query()->where('internal', true)->get();

    expect($internalFamilies)->toHaveCount(2)
        ->and($internalFamilies->pluck('stripe_product_id')->filter())->toBeEmpty()
        ->and(PlanPrice::query()
            ->whereHas('version.family', fn ($q) => $q->where('internal', true))
            ->whereNotNull('stripe_price_id')->count())->toBe(0)
        ->and(planProducts($stripe)->pluck('metadata.plan_family'))
        ->not->toContain('test')->not->toContain('enterprise');
});

it('keeps a family\'s existing Stripe ids once it is switched from sellable to internal, and mints nothing new for it', function () {
    $stripe = fakeStripe();
    $this->artisan('stripe:sync-catalogue')->assertSuccessful();

    $family = PlanFamily::query()->where('key', 'start')->sole();
    $productIdBefore = $family->stripe_product_id;
    $priceIdsBefore = PlanPrice::query()
        ->whereHas('version', fn ($q) => $q->where('plan_family_id', $family->id))
        ->orderBy('id')->pluck('stripe_price_id', 'id');

    expect($productIdBefore)->not->toBeNull()
        ->and($priceIdsBefore->filter())->not->toBeEmpty();

    // Genau der Fall von Enterprise: verkäuflich, bereits gespiegelt, und dann
    // vom Betreiber auf intern umgestellt. Was einmal angelegt wurde, bleibt
    // verknüpft — es kommt nur nichts Neues mehr hinzu.
    $family->update(['internal' => true]);

    // Die Steuer ändert sich noch dazu: ohne die Sperre in
    // stripe:sync-catalogue wäre das genau der Auslöser, der einen
    // Bruttopreis für erneuerungsbedürftig hält und einen neuen anlegt — an
    // einem Paket, das niemand mehr bezahlt.
    Settings::set('company.tax_rate', 10.0);

    $this->artisan('stripe:sync-catalogue')->assertSuccessful();

    $family->refresh();

    expect($family->stripe_product_id)->toBe($productIdBefore)
        ->and(PlanPrice::query()
            ->whereHas('version', fn ($q) => $q->where('plan_family_id', $family->id))
            ->orderBy('id')->pluck('stripe_price_id', 'id')->all())
        ->toBe($priceIdsBefore->all())
        // Nicht das gesamte $stripe->archived — die Rate hat sich für den
        // ganzen Katalog geändert, und Team und Business (weiterhin
        // verkäuflich) ARCHIVIEREN zu Recht ihre alten Bruttopreise. Was hier
        // zählt, ist einzig, dass KEINER der Preise dieser (jetzt internen)
        // Familie darunter ist.
        ->and(array_intersect($priceIdsBefore->filter()->all(), $stripe->archived))->toBe([]);
});

it('gives each price its own recurring interval', function () {
    $stripe = fakeStripe();
    $this->artisan('stripe:sync-catalogue');

    $intervals = planPrices($stripe, 'domestic')->groupBy('interval')->map->count();

    // Monthly and yearly cannot share a Price, because the interval belongs to
    // the Price itself.
    expect($intervals['month'])->toBe(6)
        ->and($intervals['year'])->toBe(6);

    // Die Fassung im Verkauf, ausdrücklich benannt: seit der Umschaltung auf die
    // neue Leiter gibt es zwei monatliche Team-Preise bei Stripe, und „der
    // erste, den die Sammlung hergibt" wäre eine Zusicherung über die
    // Reihenfolge einer Liste.
    $live = (string) app(PlanCatalogue::class)->currentVersion('team')->version;

    // The DOMESTIC GROSS, which is the figure on the website and the one taken
    // from the card of everybody who is charged VAT. The catalogue row stays net.
    $team = planPrices($stripe, 'domestic')
        ->first(fn ($p) => $p['metadata']['plan_family'] === 'team'
            && $p['interval'] === 'month'
            && $p['metadata']['plan_version'] === $live);
    expect($team['amount'])->toBe(9480)
        ->and($team['amount'])->toBe(TaxTreatment::advertisedCents(7900))
        ->and($team['metadata']['plan_version'])->toBe('2');

    // And the bare net beside it, on the same Product, for the business that owes
    // no VAT at all. One Price for everybody was the overcharge: 94,80 € taken
    // for a 79,00 € package from exactly the customers who read their invoices.
    $reverse = planPrices($stripe, 'reverse_charge')
        ->first(fn ($p) => $p['metadata']['plan_family'] === 'team'
            && $p['interval'] === 'month'
            && $p['metadata']['plan_version'] === $live);
    expect($reverse['amount'])->toBe(7900)
        ->and($reverse['product'])->toBe($team['product']);
});

it('does not put an unpublished draft in the price list', function () {
    $stripe = fakeStripe();
    $family = PlanFamily::query()->where('key', 'team')->sole();

    app(PlanCatalogue::class)->draft($family, [
        'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192,
        'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000,
        'features' => [],
    ], ['monthly' => 19900, 'yearly' => 238800]);

    $this->artisan('stripe:sync-catalogue');

    // A draft has promised nothing; a Price for it would be a price list entry
    // for something that may never exist.
    expect(planPrices($stripe))->toHaveCount(24)
        ->and(planPrices($stripe)->pluck('amount'))->not->toContain(19900);
});

it('does not mint a second object when a run is interrupted before the id is stored', function () {
    $stripe = fakeStripe();

    $this->artisan('stripe:sync-catalogue');

    // A crash between Stripe creating the objects and us recording their ids.
    PlanFamily::query()->update(['stripe_product_id' => null]);
    PlanPrice::query()->update(['stripe_price_id' => null]);

    $this->artisan('stripe:sync-catalogue')->assertSuccessful();

    // Stripe replays the original answer for a repeated idempotency key, so the
    // catalogue reconnects to what is already there instead of duplicating it —
    // and a Price cannot be deleted afterwards to tidy up.
    expect(planProducts($stripe))->toHaveCount(3)
        ->and(planPrices($stripe))->toHaveCount(24)
        ->and(PlanPrice::query()
            ->whereHas('version.family', fn ($q) => $q->where('internal', false))
            ->whereNull('stripe_price_id')->count())->toBe(0);
});

it('does not revive a contract Stripe already ended', function () {
    $subscription = stripeContract();

    $this->postJson(route('webhooks.stripe'), [
        'id' => 'evt_end', 'type' => 'customer.subscription.deleted',
        'data' => ['object' => ['id' => 'sub_1', 'status' => 'canceled', 'ended_at' => now()->timestamp]],
    ])->assertOk();

    // Stripe does not guarantee delivery order: the final invoice can land
    // after the deletion.
    $this->postJson(route('webhooks.stripe'), [
        'id' => 'evt_late', 'type' => 'invoice.paid',
        'data' => ['object' => [
            'id' => 'in_late', 'subscription' => 'sub_1', 'amount_paid' => 21480,
            'billing_reason' => 'subscription_cycle',
            'period_start' => now()->timestamp, 'period_end' => now()->addMonth()->timestamp,
        ]],
    ])->assertOk();

    $subscription->refresh();

    // The payment is on record; the departed customer does not get their
    // service back, and the cancellation is not recorded a second time.
    expect($subscription->status)->toBe('cancelled')
        ->and($subscription->cancelled_at)->not->toBeNull()
        ->and(SubscriptionRecord::query()->where('event', 'renewal')->count())->toBe(1)
        ->and(SubscriptionRecord::query()->where('event', 'cancellation')->count())->toBe(1);
});

it('does not put a plan that has never been published in Stripe at all', function () {
    $stripe = fakeStripe();
    $family = PlanFamily::query()->create(['key' => 'agency', 'name' => 'Agentur', 'tier' => 5]);
    app(PlanCatalogue::class)->draft($family, [
        'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192,
        'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000,
        'features' => [],
    ], ['monthly' => 24900, 'yearly' => 298800]);

    $this->artisan('stripe:sync-catalogue');

    // Not even the Product: it would be a price-list entry for something that
    // has promised nothing to anyone.
    expect(planProducts($stripe))->toHaveCount(3)
        ->and($family->fresh()->stripe_product_id)->toBeNull();
});

it('does not record the checkout\'s own invoice as a renewal', function () {
    stripeContract();

    $this->postJson(route('webhooks.stripe'), [
        'id' => 'evt_first', 'type' => 'invoice.paid',
        'data' => ['object' => [
            'id' => 'in_first', 'subscription' => 'sub_1', 'amount_paid' => 21480,
            // Stripe sends this for the invoice created during checkout.
            'billing_reason' => 'subscription_create',
            'period_start' => now()->timestamp, 'period_end' => now()->addMonth()->timestamp,
        ]],
    ])->assertOk()->assertJson(['applied' => false]);

    // The purchase is already in the register; counting it again here would
    // double every customer's first payment.
    expect(SubscriptionRecord::query()->where('event', 'renewal')->count())->toBe(0);
});

it('does not move the term on for a proration or a manual charge', function () {
    $subscription = stripeContract();
    $originalEnd = $subscription->current_period_end;

    $this->postJson(route('webhooks.stripe'), [
        'id' => 'evt_prorata', 'type' => 'invoice.paid',
        'data' => ['object' => [
            'id' => 'in_prorata', 'subscription' => 'sub_1', 'amount_paid' => 4200,
            'billing_reason' => 'subscription_update',
            'period_start' => now()->timestamp, 'period_end' => now()->addMonth()->timestamp,
        ]],
    ])->assertOk();

    // Money received, so it is on record — but a top-up does not start a new
    // month, and pushing the period forward on one would give it away.
    $record = SubscriptionRecord::query()->where('event', 'invoice_paid')->sole();

    expect($record->gross_cents)->toBe(4200)
        ->and($record->snapshot['billing_reason'])->toBe('subscription_update')
        ->and(SubscriptionRecord::query()->where('event', 'renewal')->count())->toBe(0)
        ->and($subscription->fresh()->current_period_end->timestamp)->toBe($originalEnd->timestamp);
});

it('refuses an older event delivered behind a newer one', function () {
    $subscription = stripeContract();
    $secondTermEnd = now()->addMonths(2);

    // Renewal N+1 arrives first.
    $this->postJson(route('webhooks.stripe'), [
        'id' => 'evt_n2', 'type' => 'invoice.paid', 'created' => now()->timestamp,
        'data' => ['object' => [
            'id' => 'in_n2', 'subscription' => 'sub_1', 'amount_paid' => 21480,
            'billing_reason' => 'subscription_cycle',
            'period_start' => now()->addMonth()->timestamp, 'period_end' => $secondTermEnd->timestamp,
        ]],
    ])->assertOk();

    // Renewal N turns up late, and a failed attempt from before that.
    $this->postJson(route('webhooks.stripe'), [
        'id' => 'evt_n1', 'type' => 'invoice.paid', 'created' => now()->subHour()->timestamp,
        'data' => ['object' => [
            'id' => 'in_n1', 'subscription' => 'sub_1', 'amount_paid' => 21480,
            'billing_reason' => 'subscription_cycle',
            'period_start' => now()->timestamp, 'period_end' => now()->addMonth()->timestamp,
        ]],
    ])->assertOk();

    $this->postJson(route('webhooks.stripe'), [
        'id' => 'evt_old_fail', 'type' => 'invoice.payment_failed', 'created' => now()->subHours(2)->timestamp,
        'data' => ['object' => ['id' => 'in_old', 'subscription' => 'sub_1', 'attempt_count' => 1]],
    ])->assertOk();

    $subscription->refresh();

    // The term is not shortened, and a customer who has since paid is not
    // flipped back to overdue.
    expect($subscription->current_period_end->timestamp)->toBe($secondTermEnd->timestamp)
        ->and($subscription->stripe_status)->toBe('active')
        // Both payments are still on record: a late arrival is recorded, it
        // just does not get to rewrite the running picture.
        ->and(SubscriptionRecord::query()->where('event', 'renewal')->count())->toBe(2)
        ->and(SubscriptionRecord::query()->where('event', 'payment_failed')->count())->toBe(1);
});

it('lets a payment that went through outrank a failure from the same second', function () {
    $subscription = stripeContract();
    $second = now();

    // Stripe timestamps have one-second resolution, so a failed attempt and
    // the retry that succeeded routinely share one.
    $this->postJson(route('webhooks.stripe'), [
        'id' => 'evt_ok', 'type' => 'invoice.paid', 'created' => $second->timestamp,
        'data' => ['object' => [
            'id' => 'in_ok', 'subscription' => 'sub_1', 'amount_paid' => 21480,
            'billing_reason' => 'subscription_cycle',
            'period_start' => $second->timestamp, 'period_end' => $second->copy()->addMonth()->timestamp,
        ]],
    ])->assertOk();

    $this->postJson(route('webhooks.stripe'), [
        'id' => 'evt_nok', 'type' => 'invoice.payment_failed', 'created' => $second->timestamp,
        'data' => ['object' => ['id' => 'in_ok', 'subscription' => 'sub_1', 'attempt_count' => 1]],
    ])->assertOk();

    // The customer has paid; a same-second failure must not say otherwise.
    expect($subscription->fresh()->stripe_status)->toBe('active')
        ->and(SubscriptionRecord::query()->where('event', 'payment_failed')->count())->toBe(1);
});

it('holds a billing event that arrives before the contract exists, and applies it after', function () {
    Queue::fake();

    // The cancellation overtakes the checkout — Stripe does not deliver in
    // order, and a checkout takes a moment to become a contract.
    $this->postJson(route('webhooks.stripe'), [
        'id' => 'evt_early', 'type' => 'customer.subscription.deleted', 'created' => now()->timestamp,
        'data' => ['object' => ['id' => 'sub_early', 'status' => 'canceled', 'ended_at' => now()->timestamp]],
    ])->assertOk()->assertJson(['applied' => false]);

    expect(StripePendingEvent::query()->count())->toBe(1);

    $this->postJson(route('webhooks.stripe'), [
        'id' => 'evt_checkout', 'type' => 'checkout.session.completed',
        'data' => ['object' => [
            'id' => 'cs_early', 'payment_status' => 'paid', 'subscription' => 'sub_early',
            'customer_details' => ['email' => 'schnell@example.com'],
            'amount_total' => 21480, 'currency' => 'eur',
            'metadata' => ['plan' => 'team', 'datacenter' => 'fsn'],
        ]],
    ])->assertOk();

    // Answering 2xx and forgetting it would have left us serving someone who
    // had already left.
    $subscription = Subscription::query()->sole();

    expect($subscription->status)->toBe('cancelled')
        ->and(SubscriptionRecord::query()->where('event', 'cancellation')->count())->toBe(1)
        // The holding area is a holding area, not a second event log.
        ->and(StripePendingEvent::query()->count())->toBe(0);
});

it('holds nothing for a contract that is right there', function () {
    stripeContract();

    // Deliberately skipped: the checkout's own invoice.
    $this->postJson(route('webhooks.stripe'), [
        'id' => 'evt_c', 'type' => 'invoice.paid', 'created' => now()->timestamp,
        'data' => ['object' => [
            'id' => 'in_c', 'subscription' => 'sub_1', 'amount_paid' => 21480,
            'billing_reason' => 'subscription_create',
        ]],
    ])->assertOk();

    // Already recorded: a redelivered renewal.
    $renewal = [
        'id' => 'evt_r', 'type' => 'invoice.paid', 'created' => now()->timestamp,
        'data' => ['object' => [
            'id' => 'in_r', 'subscription' => 'sub_1', 'amount_paid' => 21480,
            'billing_reason' => 'subscription_cycle',
            'period_start' => now()->timestamp, 'period_end' => now()->addMonth()->timestamp,
        ]],
    ];
    $this->postJson(route('webhooks.stripe'), $renewal)->assertOk();
    $this->postJson(route('webhooks.stripe'), $renewal)->assertOk();

    // The holding area waits for contracts that do not exist yet. Ordinary
    // traffic must not silt it up with rows nothing will ever come back for.
    expect(StripePendingEvent::query()->count())->toBe(0)
        ->and(SubscriptionRecord::query()->where('event', 'renewal')->count())->toBe(1);
});

it('holds an event only once however often Stripe redelivers it', function () {
    $event = [
        'id' => 'evt_repeat', 'type' => 'invoice.paid', 'created' => now()->timestamp,
        'data' => ['object' => [
            'id' => 'in_repeat', 'subscription' => 'sub_nothere', 'amount_paid' => 21480,
            'billing_reason' => 'subscription_cycle',
        ]],
    ];

    $this->postJson(route('webhooks.stripe'), $event)->assertOk();
    $this->postJson(route('webhooks.stripe'), $event)->assertOk();

    expect(StripePendingEvent::query()->count())->toBe(1);
});

it('creates nothing when Stripe is not connected', function () {
    $stripe = fakeStripe();
    $stripe->configured = false;

    $this->artisan('stripe:sync-catalogue')->assertFailed();

    expect($stripe->products)->toBeEmpty()
        ->and(PlanFamily::query()->whereNotNull('stripe_product_id')->count())->toBe(0);
});

it('moves the period on when Stripe says a renewal was paid', function () {
    $subscription = stripeContract();
    $end = now()->addMonth();

    $this->postJson(route('webhooks.stripe'), [
        'id' => 'evt_paid', 'type' => 'invoice.paid',
        'data' => ['object' => [
            'id' => 'in_100', 'subscription' => 'sub_1', 'amount_paid' => 21480,
            'billing_reason' => 'subscription_cycle',
            'period_start' => now()->timestamp, 'period_end' => $end->timestamp,
        ]],
    ])->assertOk()->assertJson(['applied' => true]);

    $subscription->refresh();

    expect($subscription->current_period_end->timestamp)->toBe($end->timestamp)
        ->and($subscription->stripe_status)->toBe('active');

    $record = SubscriptionRecord::query()->where('event', 'renewal')->sole();

    // The invoice is the authority for the amount; the contract for the terms.
    expect($record->gross_cents)->toBe(21480)
        ->and($record->stripe_invoice_id)->toBe('in_100')
        ->and($record->plan_key)->toBe('team');
});

it('records one renewal however many times Stripe delivers the invoice', function () {
    stripeContract();

    $event = [
        'id' => 'evt_dup', 'type' => 'invoice.paid',
        'data' => ['object' => [
            'id' => 'in_dup', 'subscription' => 'sub_1', 'amount_paid' => 21480,
            'billing_reason' => 'subscription_cycle',
            'period_start' => now()->timestamp, 'period_end' => now()->addMonth()->timestamp,
        ]],
    ];

    $this->postJson(route('webhooks.stripe'), $event)->assertOk();
    $this->postJson(route('webhooks.stripe'), $event)->assertOk()->assertJson(['applied' => false]);

    expect(SubscriptionRecord::query()->where('event', 'renewal')->count())->toBe(1);
});

it('records a failed payment without cutting the customer off', function () {
    $subscription = stripeContract();

    $this->postJson(route('webhooks.stripe'), [
        'id' => 'evt_fail', 'type' => 'invoice.payment_failed',
        'data' => ['object' => ['id' => 'in_fail', 'subscription' => 'sub_1', 'attempt_count' => 1]],
    ])->assertOk();

    $subscription->refresh();

    // Stripe runs the dunning schedule. Cutting someone off on the first
    // failed attempt would punish an expired card as a refusal to pay.
    expect($subscription->stripe_status)->toBe('past_due')
        ->and($subscription->status)->toBe('active')
        ->and(SubscriptionRecord::query()->where('event', 'payment_failed')->sole()->stripe_invoice_id)
        ->toBe('in_fail');
});

it('ends the contract, and records it, when Stripe ends the subscription', function () {
    $subscription = stripeContract();
    $endedAt = now()->addDay();

    $this->postJson(route('webhooks.stripe'), [
        'id' => 'evt_del', 'type' => 'customer.subscription.deleted',
        'data' => ['object' => ['id' => 'sub_1', 'status' => 'canceled', 'ended_at' => $endedAt->timestamp]],
    ])->assertOk()->assertJson(['applied' => true]);

    $subscription->refresh();

    expect($subscription->status)->toBe('cancelled')
        ->and($subscription->cancelled_at->timestamp)->toBe($endedAt->timestamp);

    $record = SubscriptionRecord::query()->where('event', 'cancellation')->sole();
    expect($record->plan_key)->toBe('team');

    // A retry must not record the ending twice.
    $this->postJson(route('webhooks.stripe'), [
        'id' => 'evt_del', 'type' => 'customer.subscription.deleted',
        'data' => ['object' => ['id' => 'sub_1', 'status' => 'canceled', 'ended_at' => $endedAt->timestamp]],
    ])->assertOk()->assertJson(['applied' => false]);

    expect(SubscriptionRecord::query()->where('event', 'cancellation')->count())->toBe(1);
});

it('copies Stripe\'s status without handing it the keys', function () {
    $subscription = stripeContract();

    $this->postJson(route('webhooks.stripe'), [
        'id' => 'evt_upd', 'type' => 'customer.subscription.updated',
        'data' => ['object' => [
            'id' => 'sub_1', 'status' => 'past_due',
            'current_period_start' => now()->timestamp,
            'current_period_end' => now()->addMonth()->timestamp,
        ]],
    ])->assertOk();

    $subscription->refresh();

    // They are the authority on whether the money arrived; we stay the
    // authority on what the customer may use.
    expect($subscription->stripe_status)->toBe('past_due')
        ->and($subscription->status)->toBe('active');
});

it('carries the Stripe subscription from the checkout onto the contract', function () {
    Queue::fake();

    $this->postJson(route('webhooks.stripe'), [
        'id' => 'evt_new', 'type' => 'checkout.session.completed',
        'data' => ['object' => [
            'id' => 'cs_new', 'payment_status' => 'paid', 'subscription' => 'sub_new',
            'customer_details' => ['email' => 'neu@example.com'],
            'amount_total' => 21480, 'currency' => 'eur',
            'metadata' => ['plan' => 'team', 'datacenter' => 'fsn'],
        ]],
    ])->assertOk();

    // Without this, no later billing event could be matched to the contract.
    expect(Subscription::query()->sole()->stripe_subscription_id)->toBe('sub_new');
});

it('never rewrites the snapshot when a renewal arrives', function () {
    $subscription = stripeContract();

    // The catalogue moves on between terms.
    $catalogue = app(PlanCatalogue::class);
    $current = $catalogue->currentVersion('team');
    $catalogue->schedule($current, $current->available_from, now());

    $dearer = PlanVersion::query()->create([
        ...$current->only(['plan_family_id', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'template_vmid']),
        'version' => 3, 'features' => $current->features, 'available_from' => now(),
    ]);
    $dearer->prices()->create(['term' => 'monthly', 'amount_cents' => 29900, 'currency' => 'EUR']);
    $dearer->prices()->create(['term' => 'yearly', 'amount_cents' => 358800, 'currency' => 'EUR']);
    $catalogue->publish($dearer, now());

    $this->postJson(route('webhooks.stripe'), [
        'id' => 'evt_ren', 'type' => 'invoice.paid',
        'data' => ['object' => [
            'id' => 'in_ren', 'subscription' => 'sub_1', 'amount_paid' => 9480,
            'billing_reason' => 'subscription_cycle',
            'period_start' => now()->timestamp, 'period_end' => now()->addMonth()->timestamp,
        ]],
    ])->assertOk();

    // A renewal moves the month, not the terms.
    expect($subscription->fresh()->price_cents)->toBe(7900)
        ->and($subscription->fresh()->plan_version_id)->toBe($current->id);
});
