Skip to content

Commit 9e9d25d

Browse files
simonhampclaude
andcommitted
Add tests verifying multi-developer checkout payout correctness
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ec1b4d7 commit 9e9d25d

1 file changed

Lines changed: 329 additions & 0 deletions

File tree

Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
<?php
2+
3+
namespace Tests\Feature;
4+
5+
use App\Enums\PayoutStatus;
6+
use App\Jobs\HandleInvoicePaidJob;
7+
use App\Jobs\ProcessPayoutTransfer;
8+
use App\Models\Cart;
9+
use App\Models\CartItem;
10+
use App\Models\DeveloperAccount;
11+
use App\Models\Plugin;
12+
use App\Models\PluginBundle;
13+
use App\Models\PluginLicense;
14+
use App\Models\PluginPayout;
15+
use App\Models\PluginPrice;
16+
use App\Models\User;
17+
use Illuminate\Foundation\Testing\RefreshDatabase;
18+
use Illuminate\Support\Facades\Queue;
19+
use PHPUnit\Framework\Attributes\Test;
20+
use Stripe\Invoice;
21+
use Tests\TestCase;
22+
23+
class MultiDeveloperCheckoutPayoutTest extends TestCase
24+
{
25+
use RefreshDatabase;
26+
27+
private function createStripeInvoice(string $cartId, string $customerId): Invoice
28+
{
29+
return Invoice::constructFrom([
30+
'id' => 'in_test_'.uniqid(),
31+
'billing_reason' => Invoice::BILLING_REASON_MANUAL,
32+
'customer' => $customerId,
33+
'payment_intent' => 'pi_test_'.uniqid(),
34+
'currency' => 'usd',
35+
'metadata' => ['cart_id' => $cartId],
36+
'lines' => [],
37+
]);
38+
}
39+
40+
private function createDeveloperWithPlugin(int $priceAmount, bool $isOfficial = false): array
41+
{
42+
$developerAccount = DeveloperAccount::factory()->create();
43+
$plugin = Plugin::factory()->approved()->paid()->create([
44+
'is_active' => true,
45+
'is_official' => $isOfficial,
46+
'user_id' => $developerAccount->user_id,
47+
'developer_account_id' => $developerAccount->id,
48+
]);
49+
PluginPrice::factory()->regular()->amount($priceAmount)->create(['plugin_id' => $plugin->id]);
50+
51+
return [$developerAccount, $plugin];
52+
}
53+
54+
#[Test]
55+
public function cart_with_individual_plugins_from_multiple_developers_creates_correct_payouts(): void
56+
{
57+
[$devAccountA, $pluginA] = $this->createDeveloperWithPlugin(2999);
58+
[$devAccountB, $pluginB] = $this->createDeveloperWithPlugin(4999);
59+
[$devAccountC, $pluginC] = $this->createDeveloperWithPlugin(1999);
60+
61+
$buyer = User::factory()->create(['stripe_id' => 'cus_test_buyer_'.uniqid()]);
62+
$cart = Cart::factory()->for($buyer)->create();
63+
64+
CartItem::create([
65+
'cart_id' => $cart->id,
66+
'plugin_id' => $pluginA->id,
67+
'plugin_price_id' => $pluginA->prices->first()->id,
68+
'price_at_addition' => 2999,
69+
]);
70+
CartItem::create([
71+
'cart_id' => $cart->id,
72+
'plugin_id' => $pluginB->id,
73+
'plugin_price_id' => $pluginB->prices->first()->id,
74+
'price_at_addition' => 4999,
75+
]);
76+
CartItem::create([
77+
'cart_id' => $cart->id,
78+
'plugin_id' => $pluginC->id,
79+
'plugin_price_id' => $pluginC->prices->first()->id,
80+
'price_at_addition' => 1999,
81+
]);
82+
83+
$invoice = $this->createStripeInvoice($cart->id, $buyer->stripe_id);
84+
(new HandleInvoicePaidJob($invoice))->handle();
85+
86+
$this->assertCount(3, PluginPayout::all());
87+
88+
// Developer A: $29.99 plugin, 30% fee = $9.00, developer gets $20.99
89+
$payoutA = PluginPayout::where('developer_account_id', $devAccountA->id)->first();
90+
$this->assertNotNull($payoutA);
91+
$this->assertEquals(2999, $payoutA->gross_amount);
92+
$this->assertEquals(900, $payoutA->platform_fee);
93+
$this->assertEquals(2099, $payoutA->developer_amount);
94+
95+
// Developer B: $49.99 plugin, 30% fee = $15.00, developer gets $34.99
96+
$payoutB = PluginPayout::where('developer_account_id', $devAccountB->id)->first();
97+
$this->assertNotNull($payoutB);
98+
$this->assertEquals(4999, $payoutB->gross_amount);
99+
$this->assertEquals(1500, $payoutB->platform_fee);
100+
$this->assertEquals(3499, $payoutB->developer_amount);
101+
102+
// Developer C: $19.99 plugin, 30% fee = $6.00, developer gets $13.99
103+
$payoutC = PluginPayout::where('developer_account_id', $devAccountC->id)->first();
104+
$this->assertNotNull($payoutC);
105+
$this->assertEquals(1999, $payoutC->gross_amount);
106+
$this->assertEquals(600, $payoutC->platform_fee);
107+
$this->assertEquals(1399, $payoutC->developer_amount);
108+
}
109+
110+
#[Test]
111+
public function bundle_with_plugins_from_multiple_developers_creates_proportional_payouts(): void
112+
{
113+
[$devAccountA, $pluginA] = $this->createDeveloperWithPlugin(3000);
114+
[$devAccountB, $pluginB] = $this->createDeveloperWithPlugin(5000);
115+
[$devAccountC, $pluginC] = $this->createDeveloperWithPlugin(2000);
116+
117+
// Total retail value: 3000 + 5000 + 2000 = 10000
118+
// Bundle price: 7000 (30% discount)
119+
$bundle = PluginBundle::factory()->active()->create(['price' => 7000]);
120+
$bundle->plugins()->attach([
121+
$pluginA->id => ['sort_order' => 1],
122+
$pluginB->id => ['sort_order' => 2],
123+
$pluginC->id => ['sort_order' => 3],
124+
]);
125+
126+
$buyer = User::factory()->create(['stripe_id' => 'cus_test_buyer_'.uniqid()]);
127+
$cart = Cart::factory()->for($buyer)->create();
128+
129+
CartItem::create([
130+
'cart_id' => $cart->id,
131+
'plugin_bundle_id' => $bundle->id,
132+
'bundle_price_at_addition' => 7000,
133+
]);
134+
135+
$invoice = $this->createStripeInvoice($cart->id, $buyer->stripe_id);
136+
(new HandleInvoicePaidJob($invoice))->handle();
137+
138+
$this->assertCount(3, PluginPayout::all());
139+
140+
// Plugin A: 3000/10000 * 7000 = 2100
141+
$payoutA = PluginPayout::where('developer_account_id', $devAccountA->id)->first();
142+
$this->assertNotNull($payoutA);
143+
$this->assertEquals(2100, $payoutA->gross_amount);
144+
$splitA = PluginPayout::calculateSplit(2100);
145+
$this->assertEquals($splitA['platform_fee'], $payoutA->platform_fee);
146+
$this->assertEquals($splitA['developer_amount'], $payoutA->developer_amount);
147+
148+
// Plugin B: 5000/10000 * 7000 = 3500
149+
$payoutB = PluginPayout::where('developer_account_id', $devAccountB->id)->first();
150+
$this->assertNotNull($payoutB);
151+
$this->assertEquals(3500, $payoutB->gross_amount);
152+
$splitB = PluginPayout::calculateSplit(3500);
153+
$this->assertEquals($splitB['platform_fee'], $payoutB->platform_fee);
154+
$this->assertEquals($splitB['developer_amount'], $payoutB->developer_amount);
155+
156+
// Plugin C: remainder = 7000 - 2100 - 3500 = 1400
157+
$payoutC = PluginPayout::where('developer_account_id', $devAccountC->id)->first();
158+
$this->assertNotNull($payoutC);
159+
$this->assertEquals(1400, $payoutC->gross_amount);
160+
$splitC = PluginPayout::calculateSplit(1400);
161+
$this->assertEquals($splitC['platform_fee'], $payoutC->platform_fee);
162+
$this->assertEquals($splitC['developer_amount'], $payoutC->developer_amount);
163+
164+
// Verify total allocated equals bundle price
165+
$totalGross = $payoutA->gross_amount + $payoutB->gross_amount + $payoutC->gross_amount;
166+
$this->assertEquals(7000, $totalGross);
167+
}
168+
169+
#[Test]
170+
public function payout_eligible_date_is_no_less_than_14_days_after_purchase(): void
171+
{
172+
[$devAccount, $plugin] = $this->createDeveloperWithPlugin(2999);
173+
174+
$buyer = User::factory()->create(['stripe_id' => 'cus_test_buyer_'.uniqid()]);
175+
$cart = Cart::factory()->for($buyer)->create();
176+
177+
CartItem::create([
178+
'cart_id' => $cart->id,
179+
'plugin_id' => $plugin->id,
180+
'plugin_price_id' => $plugin->prices->first()->id,
181+
'price_at_addition' => 2999,
182+
]);
183+
184+
$invoice = $this->createStripeInvoice($cart->id, $buyer->stripe_id);
185+
(new HandleInvoicePaidJob($invoice))->handle();
186+
187+
$payout = PluginPayout::first();
188+
$this->assertNotNull($payout);
189+
$this->assertEquals(PayoutStatus::Pending, $payout->status);
190+
191+
// The eligible_for_payout_at must be at least 14 days from now
192+
$this->assertTrue(
193+
$payout->eligible_for_payout_at->gte(now()->addDays(14)),
194+
'Payout must not be eligible earlier than 14 days after purchase'
195+
);
196+
}
197+
198+
#[Test]
199+
public function payout_not_dispatched_before_holding_period_elapses(): void
200+
{
201+
Queue::fake();
202+
203+
$developerAccount = DeveloperAccount::factory()->create();
204+
$plugin = Plugin::factory()->paid()->create(['user_id' => $developerAccount->user_id]);
205+
$license = PluginLicense::factory()->create(['plugin_id' => $plugin->id]);
206+
207+
// Create a payout that is 13 days old (within the 14-day minimum)
208+
PluginPayout::create([
209+
'plugin_license_id' => $license->id,
210+
'developer_account_id' => $developerAccount->id,
211+
'gross_amount' => 1000,
212+
'platform_fee' => 300,
213+
'developer_amount' => 700,
214+
'status' => PayoutStatus::Pending,
215+
'eligible_for_payout_at' => now()->addDays(2), // 13 days after a 15-day hold
216+
]);
217+
218+
$this->artisan('payouts:process-eligible')
219+
->expectsOutputToContain('No eligible payouts')
220+
->assertExitCode(0);
221+
222+
Queue::assertNothingPushed();
223+
}
224+
225+
#[Test]
226+
public function payouts_for_multiple_developers_dispatched_after_holding_period(): void
227+
{
228+
Queue::fake();
229+
230+
[$devAccountA, $pluginA] = $this->createDeveloperWithPlugin(2999);
231+
[$devAccountB, $pluginB] = $this->createDeveloperWithPlugin(4999);
232+
233+
$licenseA = PluginLicense::factory()->create(['plugin_id' => $pluginA->id]);
234+
$licenseB = PluginLicense::factory()->create(['plugin_id' => $pluginB->id]);
235+
236+
$payoutA = PluginPayout::create([
237+
'plugin_license_id' => $licenseA->id,
238+
'developer_account_id' => $devAccountA->id,
239+
'gross_amount' => 2999,
240+
'platform_fee' => 900,
241+
'developer_amount' => 2099,
242+
'status' => PayoutStatus::Pending,
243+
'eligible_for_payout_at' => now()->subDay(),
244+
]);
245+
246+
$payoutB = PluginPayout::create([
247+
'plugin_license_id' => $licenseB->id,
248+
'developer_account_id' => $devAccountB->id,
249+
'gross_amount' => 4999,
250+
'platform_fee' => 1500,
251+
'developer_amount' => 3499,
252+
'status' => PayoutStatus::Pending,
253+
'eligible_for_payout_at' => now()->subDay(),
254+
]);
255+
256+
$this->artisan('payouts:process-eligible')
257+
->expectsOutputToContain('Dispatched 2 payout transfer job(s)')
258+
->assertExitCode(0);
259+
260+
Queue::assertPushed(ProcessPayoutTransfer::class, 2);
261+
Queue::assertPushed(ProcessPayoutTransfer::class, fn ($job) => $job->payout->id === $payoutA->id);
262+
Queue::assertPushed(ProcessPayoutTransfer::class, fn ($job) => $job->payout->id === $payoutB->id);
263+
}
264+
265+
#[Test]
266+
public function mixed_cart_with_individual_plugins_and_bundle_from_different_developers(): void
267+
{
268+
// Developer A has a standalone plugin
269+
[$devAccountA, $pluginA] = $this->createDeveloperWithPlugin(2999);
270+
271+
// Developers B and C have plugins in a bundle
272+
[$devAccountB, $pluginB] = $this->createDeveloperWithPlugin(4000);
273+
[$devAccountC, $pluginC] = $this->createDeveloperWithPlugin(6000);
274+
275+
// Bundle retail value: 4000 + 6000 = 10000, bundle price: 8000
276+
$bundle = PluginBundle::factory()->active()->create(['price' => 8000]);
277+
$bundle->plugins()->attach([
278+
$pluginB->id => ['sort_order' => 1],
279+
$pluginC->id => ['sort_order' => 2],
280+
]);
281+
282+
$buyer = User::factory()->create(['stripe_id' => 'cus_test_buyer_'.uniqid()]);
283+
$cart = Cart::factory()->for($buyer)->create();
284+
285+
// Individual plugin from Developer A
286+
CartItem::create([
287+
'cart_id' => $cart->id,
288+
'plugin_id' => $pluginA->id,
289+
'plugin_price_id' => $pluginA->prices->first()->id,
290+
'price_at_addition' => 2999,
291+
]);
292+
293+
// Bundle containing plugins from Developer B and C
294+
CartItem::create([
295+
'cart_id' => $cart->id,
296+
'plugin_bundle_id' => $bundle->id,
297+
'bundle_price_at_addition' => 8000,
298+
]);
299+
300+
$invoice = $this->createStripeInvoice($cart->id, $buyer->stripe_id);
301+
(new HandleInvoicePaidJob($invoice))->handle();
302+
303+
// Should create 3 payouts: 1 for individual + 2 for bundle
304+
$this->assertCount(3, PluginPayout::all());
305+
306+
// Developer A: standalone plugin at $29.99
307+
$payoutA = PluginPayout::where('developer_account_id', $devAccountA->id)->first();
308+
$this->assertNotNull($payoutA);
309+
$this->assertEquals(2999, $payoutA->gross_amount);
310+
311+
// Developer B: 4000/10000 * 8000 = 3200
312+
$payoutB = PluginPayout::where('developer_account_id', $devAccountB->id)->first();
313+
$this->assertNotNull($payoutB);
314+
$this->assertEquals(3200, $payoutB->gross_amount);
315+
316+
// Developer C: remainder = 8000 - 3200 = 4800
317+
$payoutC = PluginPayout::where('developer_account_id', $devAccountC->id)->first();
318+
$this->assertNotNull($payoutC);
319+
$this->assertEquals(4800, $payoutC->gross_amount);
320+
321+
// All payouts should have the holding period set
322+
PluginPayout::all()->each(function ($payout) {
323+
$this->assertTrue(
324+
$payout->eligible_for_payout_at->gte(now()->addDays(14)),
325+
"Payout {$payout->id} must not be eligible earlier than 14 days after purchase"
326+
);
327+
});
328+
}
329+
}

0 commit comments

Comments
 (0)