laravel-shop/tests/Feature/Loan/LoanPricingTest.php

386 lines
14 KiB
PHP
Raw Normal View History

<?php
namespace Blax\Shop\Tests\Feature\Loan;
use Blax\Shop\Enums\BillingScheme;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Enums\PurchaseStatus;
use Blax\Shop\Http\Resources\PurchaseResource;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPrice;
use Blax\Shop\Models\ProductPriceTier;
use Blax\Shop\Models\ProductPurchase;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use PHPUnit\Framework\Attributes\Test;
use Workbench\App\Models\User;
/**
* Tiered loan pricing is a property of the ProductPrice, not of the host
* app's config. Each ProductPrice with billing_scheme=tiered owns a ladder
* of ProductPriceTier rows; ProductPrice::calculateForUsage($days) walks
* the ladder and returns total cents owed. ProductPurchase::calculateCost()
* delegates through `price_id` (or the purchasable's defaultPrice()).
*
* Covers:
* - per_unit price flat per-day billing
* - tiered price Stripe-style `up_to` walk with multi-tier spans
* - the user-facing library scenario (free 2 weeks €1/day €2/day @ 2 months)
* - returned-loan cap (cost frozen at meta.returned_at)
* - per-call price override
* - fractional days
* - PurchaseResource surfacing accrued_cost
* - no-price purchase zero cost (free loan)
*/
class LoanPricingTest extends TestCase
{
use RefreshDatabase;
private User $borrower;
private Product $book;
protected function setUp(): void
{
parent::setUp();
Carbon::setTestNow(Carbon::parse('2026-05-14 10:00:00'));
$this->borrower = User::factory()->create();
$this->book = Product::factory()->create([
'name' => 'Hyperion',
'type' => ProductType::LOANABLE,
'manage_stock' => true,
]);
$this->book->increaseStock(1);
}
/**
* Build a tiered ProductPrice with the given ladder.
*
* @param array<int, array{up_to: ?int, unit_amount: int}> $tiers
*/
private function tieredPrice(array $tiers, bool $default = true): ProductPrice
{
$price = ProductPrice::factory()->create([
'purchasable_id' => $this->book->id,
'purchasable_type' => Product::class,
'unit_amount' => 0,
'billing_scheme' => BillingScheme::TIERED,
'is_default' => $default,
]);
foreach ($tiers as $i => $tier) {
ProductPriceTier::factory()->create([
'price_id' => $price->id,
'up_to' => $tier['up_to'] ?? null,
'unit_amount' => $tier['unit_amount'],
'sort_order' => $i,
]);
}
return $price->load('tiers');
}
private function loan(
Carbon $from,
?Carbon $until = null,
?ProductPrice $price = null
): ProductPurchase {
return $this->book->purchases()->create([
'purchaser_id' => $this->borrower->id,
'purchaser_type' => User::class,
'price_id' => $price?->id,
'quantity' => 1,
'amount' => 0,
'amount_paid' => 0,
'status' => PurchaseStatus::PENDING,
'from' => $from,
'until' => $until ?? $from->copy()->addWeeks(2),
'meta' => ['extensions_used' => 0],
]);
}
#[Test]
public function a_loan_with_no_associated_price_costs_nothing(): void
{
$loan = $this->loan(Carbon::parse('2026-01-01 10:00:00'));
Carbon::setTestNow(Carbon::parse('2026-12-01 10:00:00'));
$this->assertSame(0, $loan->accruedCost());
}
#[Test]
public function per_unit_billing_scheme_is_flat_per_day(): void
{
// billing_scheme=per_unit → unit_amount × days.
$price = ProductPrice::factory()->create([
'purchasable_id' => $this->book->id,
'purchasable_type' => Product::class,
'unit_amount' => 50,
'billing_scheme' => BillingScheme::PER_UNIT,
'is_default' => true,
]);
$loan = $this->loan(Carbon::parse('2026-05-01 10:00:00'), price: $price);
Carbon::setTestNow(Carbon::parse('2026-05-11 10:00:00'));
$this->assertSame(500, $loan->accruedCost(), '10 days × 50c');
}
#[Test]
public function tiered_billing_walks_the_ladder_with_up_to_boundaries(): void
{
$price = $this->tieredPrice([
['up_to' => 14, 'unit_amount' => 0],
['up_to' => 60, 'unit_amount' => 100],
['up_to' => null, 'unit_amount' => 200],
]);
$loan = $this->loan(Carbon::parse('2026-01-01 10:00:00'), price: $price);
// Day 10: free
Carbon::setTestNow(Carbon::parse('2026-01-11 10:00:00'));
$this->assertSame(0, $loan->accruedCost(), 'day 10 free');
// Day 20: 14 free + 6 days at 100c
Carbon::setTestNow(Carbon::parse('2026-01-21 10:00:00'));
$this->assertSame(600, $loan->accruedCost(), 'day 20 = 6×100');
// Day 75: 14 free + 46×100 + 15×200
Carbon::setTestNow(Carbon::parse('2026-03-17 10:00:00'));
$this->assertSame(7600, $loan->accruedCost(), 'day 75 = 4600 + 3000');
}
#[Test]
public function the_user_specified_library_scenario(): void
{
// Library configuration: free for 14 days, then €1/day, then €2/day
// after two months. Defined on the price model, not in config.
$price = $this->tieredPrice([
['up_to' => 14, 'unit_amount' => 0],
['up_to' => 60, 'unit_amount' => 100],
['up_to' => null, 'unit_amount' => 200],
]);
$loan = $this->loan(Carbon::parse('2026-01-01 10:00:00'), price: $price);
$scenarios = [
[0, 0, 'same-day return'],
[7, 0, '1 week (grace)'],
[14, 0, 'exactly 2 weeks (last free day)'],
[15, 100, 'day 15 → 1 day at €1'],
[30, 1600, 'day 30 → 16 days at €1'],
[60, 4600, '2 months → 46 days at €1'],
[61, 4800, 'day 61 → +1 day at €2'],
[90, 10600, 'day 90 → 46×€1 + 30×€2'],
];
foreach ($scenarios as [$days, $expected, $label]) {
Carbon::setTestNow(Carbon::parse('2026-01-01 10:00:00')->addDays($days));
$this->assertSame($expected, $loan->accruedCost(), "after {$days} days: {$label}");
}
}
#[Test]
public function calculate_cost_caps_at_return_time(): void
{
$price = $this->tieredPrice([
['up_to' => 14, 'unit_amount' => 0],
['up_to' => null, 'unit_amount' => 100],
]);
$loan = $this->loan(Carbon::parse('2026-01-01 10:00:00'), price: $price);
Carbon::setTestNow(Carbon::parse('2026-01-21 10:00:00')); // day 20 → 600c
$loan->markReturned();
$loan->refresh();
// Time marches on; cost should remain frozen.
Carbon::setTestNow(Carbon::parse('2026-12-01 10:00:00'));
$this->assertSame(600, $loan->accruedCost());
}
#[Test]
public function calculate_cost_accepts_an_explicit_as_of_argument(): void
{
$price = $this->tieredPrice([
['up_to' => null, 'unit_amount' => 100],
]);
$loan = $this->loan(Carbon::parse('2026-01-01 10:00:00'), price: $price);
$this->assertSame(500, $loan->calculateCost(Carbon::parse('2026-01-06 10:00:00')));
$this->assertSame(1500, $loan->calculateCost(Carbon::parse('2026-01-16 10:00:00')));
}
#[Test]
public function calculate_cost_accepts_a_per_call_price_override(): void
{
// Loan has no price by default → 0.
$loan = $this->loan(Carbon::parse('2026-01-01 10:00:00'));
Carbon::setTestNow(Carbon::parse('2026-01-11 10:00:00'));
$this->assertSame(0, $loan->accruedCost());
// Per-call override with a tiered price.
$override = $this->tieredPrice([
['up_to' => null, 'unit_amount' => 50],
], default: false);
$this->assertSame(500, $loan->calculateCost(null, $override));
}
#[Test]
public function fractional_days_are_billed_proportionally(): void
{
$price = $this->tieredPrice([
['up_to' => null, 'unit_amount' => 200],
]);
$loan = $this->loan(Carbon::parse('2026-01-01 10:00:00'), price: $price);
Carbon::setTestNow(Carbon::parse('2026-01-01 22:00:00')); // 0.5 days
$this->assertSame(100, $loan->accruedCost());
}
#[Test]
public function purchase_resource_surfaces_accrued_cost(): void
{
$price = $this->tieredPrice([
['up_to' => 14, 'unit_amount' => 0],
['up_to' => null, 'unit_amount' => 100],
]);
$loan = $this->loan(Carbon::parse('2026-01-01 10:00:00'), price: $price);
Carbon::setTestNow(Carbon::parse('2026-01-21 10:00:00'));
$payload = PurchaseResource::make($loan)->toArray(Request::create('/'));
$this->assertSame(600, $payload['accrued_cost']);
}
#[Test]
public function purchase_resource_returns_null_accrued_cost_for_non_loan_purchases(): void
{
$purchase = $this->book->purchases()->create([
'purchaser_id' => $this->borrower->id,
'purchaser_type' => User::class,
'quantity' => 1,
'amount' => 5000,
'amount_paid' => 5000,
'status' => PurchaseStatus::COMPLETED,
// no from/until — plain e-commerce purchase
]);
$payload = PurchaseResource::make($purchase)->toArray(Request::create('/'));
$this->assertNull($payload['accrued_cost']);
}
#[Test]
public function product_price_calculate_for_usage_handles_zero_and_negative_usage(): void
{
$price = $this->tieredPrice([
['up_to' => null, 'unit_amount' => 100],
]);
$this->assertSame(0, $price->calculateForUsage(0));
$this->assertSame(0, $price->calculateForUsage(-3));
}
#[Test]
public function product_price_flat_amount_is_added_per_entered_tier(): void
{
$price = ProductPrice::factory()->create([
'purchasable_id' => $this->book->id,
'purchasable_type' => Product::class,
'unit_amount' => 0,
'billing_scheme' => BillingScheme::TIERED,
'is_default' => true,
]);
// Tier 1: 0-14 free with €5 flat-on-entry setup fee.
// Tier 2: 14+ at €1/day with no flat fee.
ProductPriceTier::factory()->create([
'price_id' => $price->id,
'up_to' => 14,
'unit_amount' => 0,
'flat_amount' => 500,
'sort_order' => 0,
]);
ProductPriceTier::factory()->create([
'price_id' => $price->id,
'up_to' => null,
'unit_amount' => 100,
'sort_order' => 1,
]);
$price->load('tiers');
// Within tier 1: only the flat 500c applies.
$this->assertSame(500, $price->calculateForUsage(5));
// Crosses both tiers: 500 (flat) + 0×14 (free days) + 100×6 (paid days)
$this->assertSame(1100, $price->calculateForUsage(20));
}
/* ───────────────────── edge cases ───────────────────── */
#[Test]
public function tiered_price_with_no_tiers_falls_back_to_unit_amount(): void
{
// A ProductPrice with billing_scheme=tiered but an empty tier set
// should NOT throw — it should treat unit_amount as a flat per-unit
// rate, matching per_unit behaviour.
$price = ProductPrice::factory()->create([
'purchasable_id' => $this->book->id,
'purchasable_type' => Product::class,
'unit_amount' => 75,
'billing_scheme' => BillingScheme::TIERED,
'is_default' => true,
]);
$this->assertSame(0, $price->tiers()->count());
$this->assertSame(750, $price->calculateForUsage(10));
}
#[Test]
public function purchase_price_relation_returns_the_attached_price(): void
{
$price = $this->tieredPrice([
['up_to' => null, 'unit_amount' => 100],
]);
$loan = $this->loan(Carbon::parse('2026-01-01 10:00:00'), price: $price);
$this->assertInstanceOf(ProductPrice::class, $loan->price);
$this->assertSame($price->id, $loan->price->id);
}
#[Test]
public function calculate_cost_falls_back_to_purchasable_default_price_when_purchase_has_no_price_id(): void
{
// The purchase has no price_id set, but the Book has a default
// price — calculateCost should resolve through purchasable->defaultPrice().
$defaultPrice = $this->tieredPrice([
['up_to' => null, 'unit_amount' => 50],
], default: true);
$loan = $this->loan(Carbon::parse('2026-01-01 10:00:00')); // no price_id
Carbon::setTestNow(Carbon::parse('2026-01-11 10:00:00')); // 10 days
$this->assertNull($loan->price_id);
$this->assertSame(500, $loan->accruedCost(), 'fallback to defaultPrice → 10 × 50c');
}
#[Test]
public function explicit_price_id_takes_precedence_over_default_price(): void
{
// Default price says €1/day, explicit price says €5/day.
$this->tieredPrice([
['up_to' => null, 'unit_amount' => 100],
], default: true);
$premiumPrice = $this->tieredPrice([
['up_to' => null, 'unit_amount' => 500],
], default: false);
$loan = $this->loan(Carbon::parse('2026-01-01 10:00:00'), price: $premiumPrice);
Carbon::setTestNow(Carbon::parse('2026-01-11 10:00:00')); // 10 days
$this->assertSame(5000, $loan->accruedCost(), 'uses premiumPrice not default');
}
}