Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/Console/Commands/BackfillMissingPayouts.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ public function handle(): int
foreach ($licenses as $license) {
$developerAccount = $license->plugin->developerAccount;

$split = PluginPayout::calculateSplit($license->price_paid, $developerAccount->platformFeePercent());
$split = PluginPayout::calculateSplit($license->price_paid, $developerAccount->platformFeePercentFor($license));

$status = $developerAccount->canReceivePayouts()
? PayoutStatus::Pending
Expand Down
3 changes: 3 additions & 0 deletions app/Enums/PluginTier.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,17 @@ public function getPrices(): array
self::Bronze => [
PriceTier::Regular->value => 2900,
PriceTier::Subscriber->value => 1100,
PriceTier::Ultra->value => 2000,
],
self::Silver => [
PriceTier::Regular->value => 4900,
PriceTier::Subscriber->value => 1700,
PriceTier::Ultra->value => 3500,
],
self::Gold => [
PriceTier::Regular->value => 9900,
PriceTier::Subscriber->value => 3100,
PriceTier::Ultra->value => 7000,
],
};
}
Expand Down
8 changes: 6 additions & 2 deletions app/Enums/PriceTier.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ enum PriceTier: string
case Regular = 'regular';
case Subscriber = 'subscriber';
case Eap = 'eap';
case Ultra = 'ultra';

public function label(): string
{
return match ($this) {
self::Regular => 'Regular',
self::Subscriber => 'Subscriber',
self::Eap => 'Early Access',
self::Ultra => 'Ultra',
};
}

Expand All @@ -23,6 +25,7 @@ public function description(): string
self::Regular => 'Standard pricing for all customers',
self::Subscriber => 'Discounted pricing for subscribers',
self::Eap => 'Special pricing for Early Access Program customers',
self::Ultra => 'Discounted pricing for Ultra subscribers',
};
}

Expand All @@ -35,8 +38,9 @@ public function priority(): int
{
return match ($this) {
self::Eap => 1,
self::Subscriber => 2,
self::Regular => 3,
self::Ultra => 2,
self::Subscriber => 3,
self::Regular => 4,
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public function form(Schema $schema): Schema
->options(PriceTier::class)
->required()
->default(PriceTier::Regular)
->helperText('Regular = standard price, Subscriber = Pro/Max holders, EAP = Early Access'),
->helperText('Regular = standard price, Subscriber = Pro/Max holders, EAP = Early Access, Ultra = Ultra subscribers'),

Forms\Components\TextInput::make('amount')
->label('Price (cents)')
Expand Down Expand Up @@ -59,6 +59,7 @@ public function table(Table $table): Table
PriceTier::Regular => 'gray',
PriceTier::Subscriber => 'info',
PriceTier::Eap => 'success',
PriceTier::Ultra => 'warning',
})
->sortable(),

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public function form(Schema $schema): Schema
->options(PriceTier::class)
->required()
->default(PriceTier::Regular)
->helperText('Regular = standard price, Subscriber = Pro/Max holders, EAP = Early Access'),
->helperText('Regular = standard price, Subscriber = Pro/Max holders, EAP = Early Access, Ultra = Ultra subscribers (the developer keeps 100% of these sales)'),

Forms\Components\TextInput::make('amount')
->label('Price (cents)')
Expand Down Expand Up @@ -59,6 +59,7 @@ public function table(Table $table): Table
PriceTier::Regular => 'gray',
PriceTier::Subscriber => 'info',
PriceTier::Eap => 'success',
PriceTier::Ultra => 'warning',
})
->sortable(),

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public function form(Schema $schema): Schema
->options(PriceTier::class)
->required()
->default(PriceTier::Regular)
->helperText('Regular = standard price, Subscriber = Pro/Max holders, EAP = Early Access'),
->helperText('Regular = standard price, Subscriber = Pro/Max holders, EAP = Early Access, Ultra = Ultra subscribers'),

Forms\Components\TextInput::make('stripe_price_id')
->label('Stripe price ID')
Expand Down Expand Up @@ -108,6 +108,7 @@ public function table(Table $table): Table
PriceTier::Regular => 'gray',
PriceTier::Subscriber => 'info',
PriceTier::Eap => 'success',
PriceTier::Ultra => 'warning',
})
->sortable(),

Expand Down
1 change: 1 addition & 0 deletions app/Filament/Resources/ThirdPartySaleResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ public static function table(Table $table): Table
Tables\Columns\TextColumn::make('price_paid')
->label('Paid')
->formatStateUsing(fn (int $state): string => '$'.number_format($state / 100, 2))
->description(fn (PluginLicense $record): ?string => $record->wasSoldAtUltraPrice() ? 'Ultra price' : null)
->summarize(Tables\Columns\Summarizers\Sum::make('sum')->money('USD', divideBy: 100))
->sortable(),

Expand Down
6 changes: 3 additions & 3 deletions app/Http/Controllers/CartController.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public function show(Request $request): View
// Refresh prices and notify of changes
$priceChanges = $this->cartService->refreshPrices($cart);

$cart = $cart->fresh(['items.plugin.activePrice', 'items.plugin.user', 'items.pluginBundle.plugins', 'items.product.activePrice']);
$cart = $cart->fresh(['items.plugin.activePrice', 'items.plugin.user', 'items.pluginPrice', 'items.pluginBundle.plugins', 'items.product.activePrice']);

// Get bundle IDs already in the cart
$cartBundleIds = $cart->items()
Expand Down Expand Up @@ -480,7 +480,7 @@ protected function createMultiItemCheckoutSession($cart, $user): Session
'currency' => strtolower($item->currency),
'unit_amount' => $item->bundle_price_at_addition,
'product_data' => [
'name' => $bundle->name.' (Bundle)',
'name' => $item->getItemName(),
'description' => 'Includes: '.$pluginNames,
],
],
Expand Down Expand Up @@ -523,7 +523,7 @@ protected function createMultiItemCheckoutSession($cart, $user): Session
'currency' => strtolower($item->currency),
'unit_amount' => $item->price_at_addition,
'product_data' => [
'name' => $plugin->name,
'name' => $item->getItemName(),
'description' => $plugin->description ?? 'NativePHP Plugin',
],
],
Expand Down
1 change: 1 addition & 0 deletions app/Http/Controllers/PluginDirectoryController.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ public function show(string $vendor, string $package): View
'bundles' => $bundles,
'bestPrice' => $bestPrice,
'regularPrice' => $regularPrice,
'ultraOfferPrice' => $user?->hasActiveUltraSubscription() ? null : $plugin->getUltraPrice(),
'hasDiscount' => $bestPrice && $regularPrice && $bestPrice->id !== $regularPrice->id,
'isAdminPreview' => (! $plugin->isApproved() || ! $plugin->is_active) && ($isAdmin || $isOwner),
]);
Expand Down
58 changes: 53 additions & 5 deletions app/Jobs/HandleInvoicePaidJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace App\Jobs;

use App\Enums\PayoutStatus;
use App\Enums\PriceTier;
use App\Enums\Subscription;
use App\Models\Cart;
use App\Models\CartItem;
Expand Down Expand Up @@ -226,6 +227,7 @@ private function processCartPurchase(string $cartId): void
{
$cart = Cart::with([
'items.plugin.developerAccount',
'items.pluginPrice',
'items.pluginBundle.plugins.developerAccount',
'items.product',
])->find($cartId);
Expand Down Expand Up @@ -350,7 +352,51 @@ private function processCartPluginItem(User $user, CartItem $item): void
return;
}

$this->createPluginLicense($user, $plugin, $item->price_at_addition);
$this->createPluginLicense($user, $plugin, $this->amountPaidFor($item), $item->pluginPrice?->tier);
}

/**
* What the buyer paid for a plugin or bundle once any promotion code or coupon has come off.
* Developers are paid their share of this rather than of the listed price, so it has to match the charge.
*
* Stripe reports the discount against each invoice line, which checkout names after the cart
* item. If the item's line can't be found, the invoice's discount is shared out in proportion
* to the listed price instead, rounded down so the payouts for one invoice can never add up
* to more than was charged.
*/
private function amountPaidFor(CartItem $item): int
{
$listedAmount = $item->getItemPrice();

$invoiceDiscount = (int) collect($this->invoice->total_discount_amounts ?? [])->sum('amount');

if ($invoiceDiscount <= 0) {
return $listedAmount;
}

$line = collect($this->invoice->lines->data ?? [])->first(
fn (StripeObject $line): bool => ($line->description ?? null) === $item->getItemName()
&& ($line->amount ?? null) === $listedAmount
);

if ($line) {
$lineDiscount = (int) collect($line->discount_amounts ?? [])->sum('amount');

return max(0, $listedAmount - $lineDiscount);
}

$subtotal = (int) ($this->invoice->subtotal ?? 0);

if ($subtotal <= 0) {
return $listedAmount;
}

Log::warning('Invoice line not found for discounted cart item; sharing the discount proportionally', [
'invoice_id' => $this->invoice->id,
'cart_item_id' => $item->id,
]);

return intdiv($listedAmount * max(0, $subtotal - $invoiceDiscount), $subtotal);
}

private function processCartBundleItem(User $user, CartItem $item): void
Expand All @@ -366,8 +412,8 @@ private function processCartBundleItem(User $user, CartItem $item): void
// Load plugins with developer accounts if not already loaded
$bundle->loadMissing('plugins.developerAccount');

// Calculate proportional allocation based on price at time of addition
$allocations = $bundle->calculateProportionalAllocation($item->bundle_price_at_addition);
// Calculate proportional allocation based on what was paid for the bundle
$allocations = $bundle->calculateProportionalAllocation($this->amountPaidFor($item));

foreach ($bundle->plugins as $plugin) {
// Check if license already exists for this invoice + plugin + bundle
Expand Down Expand Up @@ -549,14 +595,15 @@ private function processPluginBundles(User $user, array $metadata): void
}
}

private function createPluginLicense(User $user, Plugin $plugin, int $amount): PluginLicense
private function createPluginLicense(User $user, Plugin $plugin, int $amount, ?PriceTier $priceTier = null): PluginLicense
{
$license = PluginLicense::create([
'user_id' => $user->id,
'plugin_id' => $plugin->id,
'stripe_invoice_id' => $this->invoice->id,
'stripe_payment_intent_id' => $this->invoice->payment_intent,
'price_paid' => $amount,
'price_tier' => $priceTier,
'currency' => strtoupper($this->invoice->currency),
'is_grandfathered' => false,
'purchased_at' => now(),
Expand Down Expand Up @@ -603,6 +650,7 @@ private function createBundlePluginLicense(User $user, Plugin $plugin, PluginBun
* Payouts are 1:1 with paid sales. If the developer cannot yet receive payouts,
* the payout is created as Held and promoted to Pending by the daily
* payouts:process-eligible run once their Stripe Connect account is ready.
* Sales at the Ultra price carry no platform fee.
*/
private function createPayoutForLicense(PluginLicense $license, Plugin $plugin, int $amount): void
{
Expand All @@ -622,7 +670,7 @@ private function createPayoutForLicense(PluginLicense $license, Plugin $plugin,
return;
}

$split = PluginPayout::calculateSplit($amount, $plugin->developerAccount->platformFeePercent());
$split = PluginPayout::calculateSplit($amount, $plugin->developerAccount->platformFeePercentFor($license));

PluginPayout::create([
'plugin_license_id' => $license->id,
Expand Down
4 changes: 4 additions & 0 deletions app/Models/CartItem.php
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,10 @@ public function hasPriceChanged(): bool
return $currentPrice->amount !== $this->price_at_addition;
}

/**
* The name the item is sold under at checkout. Plugins and bundles are matched
* back to their Stripe invoice line by it, so it has to stay the same in both places.
*/
public function getItemName(): string
{
if ($this->isProduct()) {
Expand Down
13 changes: 13 additions & 0 deletions app/Models/DeveloperAccount.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,19 @@ public function platformFeePercent(): int
return 100 - $this->payout_percentage;
}

/**
* The platform fee for one sale. A sale at the Ultra price carries no fee,
* so the developer is paid everything the buyer paid.
*/
public function platformFeePercentFor(PluginLicense $license): int
{
if ($license->wasSoldAtUltraPrice()) {
return 0;
}

return $this->platformFeePercent();
}

protected function casts(): array
{
return [
Expand Down
Loading
Loading