386 lines
14 KiB
PHP
386 lines
14 KiB
PHP
<?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');
|
||
}
|
||
}
|