Skip to content

Commit 64fe925

Browse files
authored
feat: use paddle prices and pass country data (#34)
1 parent be33411 commit 64fe925

21 files changed

+681
-49
lines changed

app/Helpers/Products.php

+80
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<?php
2+
3+
namespace App\Helpers;
4+
5+
use App\Models\User;
6+
use Illuminate\Support\Collection;
7+
use Illuminate\Support\Facades\App;
8+
use Illuminate\Support\Facades\Cache;
9+
use Laravel\Paddle\Cashier;
10+
use Laravel\Paddle\ProductPrice;
11+
12+
class Products
13+
{
14+
/**
15+
* Get the prices for a set of products for a given user.
16+
*
17+
* @param \Illuminate\Support\Collection $products
18+
* @param \App\Models\User|null $user
19+
* @param int $quantity
20+
* @return \Illuminate\Support\Collection
21+
*/
22+
public function getProductPrices(Collection $products, ?User $user = null, int $quantity = 1): Collection
23+
{
24+
$country = $user !== null ? $user->paddleCountry() : config('customers.fallback_country');
25+
26+
return $this->getPrices($products, $country)
27+
->map(function (array $price) use ($country, $quantity) {
28+
$price['price'] = collect($price['price'])
29+
->mapWithKeys(fn ($item, $key) => [$key => $item * $quantity])
30+
->toArray();
31+
$pprice = new ProductPrice($country, $price);
32+
33+
return [
34+
'product_id' => $price['product_id'],
35+
'price' => $pprice->price()->gross(),
36+
'price_value' => $pprice->price()->rawGross(),
37+
'currency' => $price['currency'],
38+
'frequency' => $pprice->planInterval(),
39+
'frequency_name' => $this->getFrequency($pprice),
40+
];
41+
});
42+
}
43+
44+
private function getPrices(Collection $products, string $country): Collection
45+
{
46+
$key = $this->getKey($country, $products);
47+
48+
return Cache::remember($key, 60 * 60, function () use ($products, $country) {
49+
$prices = Cashier::productPrices($products->toArray(), [
50+
'customer_country' => $country,
51+
]);
52+
53+
return $prices->map(fn (ProductPrice $price) => $price->toArray());
54+
});
55+
}
56+
57+
private function getKey(string $country, Collection $products): string
58+
{
59+
return App::getLocale().'|'.$country.'|'.$products->implode(',');
60+
}
61+
62+
private function getFrequency(ProductPrice $price): ?string
63+
{
64+
$interval = $price->planInterval();
65+
$frequency = $price->planFrequency();
66+
67+
switch ($interval) {
68+
case 'day':
69+
return trans_choice('day|:period days', $frequency, ['period' => $frequency]);
70+
case 'week':
71+
return trans_choice('week|:period weeks', $frequency, ['period' => $frequency]);
72+
case 'month':
73+
return trans_choice('month|:period months', $frequency, ['period' => $frequency]);
74+
case 'year':
75+
return trans_choice('year|:period years', $frequency, ['period' => $frequency]);
76+
default:
77+
return null;
78+
}
79+
}
80+
}

app/Http/Controllers/MonicaController.php

+9-3
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace App\Http\Controllers;
44

5+
use App\Helpers\Products;
56
use App\Models\Plan;
67
use Illuminate\Http\Request;
78
use Inertia\Inertia;
@@ -14,14 +15,19 @@ public function index(Request $request)
1415
{
1516
$plans = Plan::where('product', static::PRODUCT)->get();
1617

17-
$plansCollection = $plans->map(function (Plan $plan) use ($request): array {
18+
$productIds = $plans->pluck('plan_id_on_paddle');
19+
$prices = app(Products::class)->getProductPrices($productIds, $request->user());
20+
21+
$plansCollection = $plans->map(function (Plan $plan) use ($request, $prices): array {
22+
$price = $prices->where('product_id', $plan->plan_id_on_paddle)->first();
23+
1824
return [
1925
'id' => $plan->id,
2026
'friendly_name' => $plan->friendly_name,
2127
'description' => $plan->description,
2228
'plan_name' => $plan->plan_name,
23-
'price' => $plan->price,
24-
'frequency' => $plan->frequency,
29+
'price' => $price['price'],
30+
'frequency' => $price['frequency_name'],
2531
'url' => [
2632
'pay_link' => $this->getPayLink($request, $plan),
2733
],

app/Http/Controllers/OfficeLifeController.php

+18-10
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace App\Http\Controllers;
44

5+
use App\Helpers\Products;
56
use App\Http\Requests\OfficeLifePriceRequest;
67
use App\Models\Plan;
78
use Illuminate\Http\Request;
@@ -15,21 +16,23 @@ public function index(Request $request)
1516
{
1617
$plans = Plan::where('product', static::PRODUCT)->get();
1718

18-
$plansCollection = $plans->map(function (Plan $plan) use ($request): array {
19+
$productIds = $plans->pluck('plan_id_on_paddle');
20+
$prices = app(Products::class)->getProductPrices($productIds, $request->user());
21+
22+
$plansCollection = $plans->map(function (Plan $plan) use ($request, $prices): array {
23+
$price = $prices->where('product_id', $plan->plan_id_on_paddle)->first();
24+
1925
return [
2026
'id' => $plan->id,
2127
'friendly_name' => $plan->friendly_name,
2228
'description' => $plan->description,
2329
'plan_name' => $plan->plan_name,
24-
'single_price' => $plan->price,
25-
'price' => $plan->price,
26-
'frequency' => $plan->frequency,
30+
'single_price' => $price['price'],
31+
'price' => $price['price'],
32+
'frequency' => $price['frequency_name'],
2733
'quantity' => 1,
2834
'url' => [
2935
'pay_link' => $this->getPayLink($request, $plan),
30-
'price' => route('officelife.price', [
31-
'plan' => $plan->id,
32-
]),
3336
],
3437
];
3538
});
@@ -53,11 +56,16 @@ public function price(OfficeLifePriceRequest $request, Plan $plan)
5356
abort(401);
5457
}
5558

56-
$quotedPrice = $plan->price * $request->input('quantity');
59+
$plans = Plan::where('product', static::PRODUCT)->get();
60+
$productIds = $plans->pluck('plan_id_on_paddle');
61+
62+
$price = app(Products::class)->getProductPrices($productIds, $request->user(), $request->quantity())
63+
->where('product_id', $plan->plan_id_on_paddle)
64+
->first();
5765

5866
return response()->json([
59-
'price' => $quotedPrice,
60-
'pay_link' => $this->getPayLink($request, $plan, $request->input('quantity')),
67+
'price' => $price['price'],
68+
'pay_link' => $this->getPayLink($request, $plan, $request->quantity()),
6169
]);
6270
}
6371

app/Http/Requests/OfficeLifePriceRequest.php

+5
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,9 @@ public function rules()
2727
'quantity' => 'required|integer|min:1',
2828
];
2929
}
30+
31+
public function quantity(): int
32+
{
33+
return intval($this->input('quantity'));
34+
}
3035
}

app/Models/Plan.php

-8
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,6 @@ class Plan extends Model
1010
{
1111
use HasFactory;
1212

13-
/**
14-
* Possible type.
15-
*/
16-
const TYPE_MONTHLY = 'monthly';
17-
const TYPE_YEARLY = 'yearly';
18-
1913
/**
2014
* The attributes that are mass assignable.
2115
*
@@ -28,8 +22,6 @@ class Plan extends Model
2822
'description',
2923
'plan_name',
3024
'plan_id_on_paddle',
31-
'price',
32-
'frequency',
3325
];
3426

3527
/**

app/Models/User.php

+28
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,34 @@ public function userTokens()
8080
return $this->hasMany(UserToken::class);
8181
}
8282

83+
/**
84+
* Get the billable model's country to associate with Paddle.
85+
*
86+
* This needs to be a 2 letter code. See the link below for supported countries.
87+
*
88+
* @return string|null
89+
*
90+
* @link https://developer.paddle.com/reference/platform-parameters/supported-countries
91+
*/
92+
public function paddleCountry(): ?string
93+
{
94+
return $this->country ?? config('customers.fallback_country');
95+
}
96+
97+
/**
98+
* Get the billable model's postcode to associate with Paddle.
99+
*
100+
* See the link below for countries which require this.
101+
*
102+
* @return string|null
103+
*
104+
* @link https://developer.paddle.com/reference/platform-parameters/supported-countries#countries-requiring-postcode
105+
*/
106+
public function paddlePostcode(): ?string
107+
{
108+
return $this->postal_code;
109+
}
110+
83111
/**
84112
* Get the webauthn keys associated to this user.
85113
*

app/Services/CreateLicenceKey.php

+9-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace App\Services;
44

5+
use App\Helpers\Products;
56
use App\Models\LicenceKey;
67
use App\Models\Plan;
78
use App\Models\Subscription;
@@ -54,8 +55,15 @@ public function execute(User $user, Subscription $subscription, array $payload):
5455
*/
5556
private function generateKey(): array
5657
{
58+
$plans = Plan::where('product', $this->plan->product)->get();
59+
60+
$productIds = $plans->pluck('plan_id_on_paddle');
61+
$prices = app(Products::class)->getProductPrices($productIds);
62+
63+
$price = $prices->where('product_id', $this->plan->plan_id_on_paddle)->first();
64+
5765
return [
58-
'frequency' => $this->plan->frequency,
66+
'frequency' => $price['frequency'],
5967
'purchaser_email' => $this->user->email,
6068
'next_check_at' => $this->nextDate->format('Y-m-d'),
6169
];

config/customers.php

+11
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,15 @@
1919

2020
'cipher' => 'AES-256-GCM',
2121

22+
/*
23+
|--------------------------------------------------------------------------
24+
| Application Fallback Country
25+
|--------------------------------------------------------------------------
26+
|
27+
| This is the default country if the user did not defined one.
28+
|
29+
*/
30+
31+
'fallback_country' => 'US',
32+
2233
];

database/factories/PlanFactory.php

-2
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,6 @@ public function definition()
2727
'description' => $this->faker->name(),
2828
'plan_name' => $this->faker->name(),
2929
'plan_id_on_paddle' => $this->faker->numberBetween(1, 100),
30-
'price' => $this->faker->randomNumber(),
31-
'frequency' => Plan::TYPE_MONTHLY,
3230
];
3331
}
3432

database/factories/UserFactory.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ public function definition()
3737
'address_line_2' => $this->faker->streetAddress(),
3838
'city' => $this->faker->city(),
3939
'postal_code' => $this->faker->postcode(),
40-
'country' => $this->faker->country(),
40+
'country' => $this->faker->countryCode(),
4141
'state' => $this->faker->state(),
4242
];
4343
}

database/migrations/2022_01_07_150132_create_keys_table.php

-2
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,6 @@ public function up()
2020
$table->string('description', 1024);
2121
$table->string('plan_name', 128);
2222
$table->string('plan_id_on_paddle', 64);
23-
$table->integer('price');
24-
$table->string('frequency', 512);
2523
$table->timestamps();
2624
});
2725

lang/en.json

+6-1
Original file line numberDiff line numberDiff line change
@@ -180,5 +180,10 @@
180180
"Register a new key": "Register a new key",
181181
"No keys registered yet.": "No keys registered yet.",
182182
"Update": "Update",
183-
"Unexpected error on login.": "Unexpected error on login."
183+
"Unexpected error on login.": "Unexpected error on login.",
184+
"day|:period days": "day|:period days",
185+
"week|:period weeks": "week|:period weeks",
186+
"month|:period months": "month|:period months",
187+
"year|:period years": "year|:period years",
188+
"Subscribe for :price": "Subscribe for :price"
184189
}

lang/fr.json

+6-1
Original file line numberDiff line numberDiff line change
@@ -180,5 +180,10 @@
180180
"Register a new key": "Enregistrer une nouvelle clé",
181181
"No keys registered yet.": "Aucune clé enregistrée pour le moment.",
182182
"Update": "Mettre à jour",
183-
"Unexpected error on login.": "Erreur inattendue lors de la connexion."
183+
"Unexpected error on login.": "Erreur inattendue lors de la connexion.",
184+
"day|:period days": "jour|:period jours",
185+
"week|:period weeks": "semaine|:period semaines",
186+
"month|:period months": "mois|:period mois",
187+
"year|:period years": "an|:period ans",
188+
"Subscribe for :price": "S’abonner pour :price"
184189
}

resources/js/Pages/Monica/Index.vue

+2-2
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ const paddle = () => {
7575

7676
<div v-for="plan in data.plans" :key="plan.id" class="mb-4 p-3 sm:p-3 w-full overflow-hidden bg-white px-6 py-6 shadow-md sm:rounded-lg flex items-center justify-between">
7777
<div>
78-
<h3 class="text-lg">{{ plan.friendly_name }} - <span class="text-sm text-gray-500">USD ${{ plan.price }} / {{ plan.frequency }}</span></h3>
78+
<h3 class="text-lg">{{ plan.friendly_name }} <span class="text-sm text-gray-500">{{ plan.price }} / {{ plan.frequency }}</span></h3>
7979
<p class="text-gray-600 text-sm">{{ plan.description }}</p>
8080
</div>
8181

@@ -99,7 +99,7 @@ const paddle = () => {
9999
<div v-else>
100100
<div v-for="plan in data.plans" :key="plan.id" class="mb-4 p-3 sm:p-3 w-full overflow-hidden bg-white px-6 py-6 shadow-md sm:rounded-lg flex items-center justify-between">
101101
<div>
102-
<h3 class="text-lg">{{ plan.friendly_name }} - <span class="text-sm text-gray-500">USD ${{ plan.price }} / {{ plan.frequency }}</span></h3>
102+
<h3 class="text-lg">{{ plan.friendly_name }} <span class="text-sm text-gray-500">{{ plan.price }} / {{ plan.frequency }}</span></h3>
103103
<p class="text-gray-600 text-sm">{{ plan.description }}</p>
104104
</div>
105105

resources/js/Pages/OfficeLife/Index.vue

+8-6
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,11 @@ const doRefresh = () => {
4040
};
4141
4242
const checkPrice = (plan) => {
43-
axios.post(plan.url.price, { quantity: plan.quantity })
43+
axios.post(route('officelife.price', { plan: plan.id }), { quantity: plan.quantity })
4444
.then((response) => {
45-
this.localPlans[this.localPlans.findIndex((x) => x.id === plan.id)]['price'] = response.data.price;
46-
this.localPlans[this.localPlans.findIndex((x) => x.id === plan.id)]['url']['pay_link'] = response.data.pay_link;
45+
const lplan = localPlans.value[localPlans.value.findIndex((x) => x.id === plan.id)];
46+
lplan['price'] = response.data.price;
47+
lplan['url']['pay_link'] = response.data.pay_link;
4748
});
4849
};
4950
@@ -85,7 +86,7 @@ const paddle = () => {
8586

8687
<div v-for="plan in data.plans" :key="plan.id" class="mb-4 p-3 sm:p-3 w-full overflow-hidden bg-white px-6 py-6 shadow-md sm:rounded-lg flex items-center justify-between">
8788
<div>
88-
<h3 class="text-lg">{{ plan.friendly_name }} - <span class="text-sm text-gray-500">USD ${{ plan.price }} / {{ plan.frequency }}</span></h3>
89+
<h3 class="text-lg">{{ plan.friendly_name }} <span class="text-sm text-gray-500">{{ plan.price }} / {{ plan.frequency }}</span></h3>
8990
<p class="text-gray-600 text-sm">{{ plan.description }}</p>
9091
</div>
9192

@@ -109,7 +110,7 @@ const paddle = () => {
109110
<div v-else>
110111
<div v-for="plan in localPlans" :key="plan.id" class="mb-4 p-3 sm:p-3 w-full overflow-hidden bg-white px-6 py-6 shadow-md sm:rounded-lg flex items-center justify-between">
111112
<div>
112-
<h3 class="text-lg">{{ plan.friendly_name }} - <span class="text-sm text-gray-500">USD ${{ plan.single_price }} / {{ plan.frequency }}</span></h3>
113+
<h3 class="text-lg">{{ plan.friendly_name }} <span class="text-sm text-gray-500">{{ plan.single_price }} / {{ plan.frequency }}</span></h3>
113114
<p class="text-gray-600 text-sm">{{ plan.description }}</p>
114115
</div>
115116

@@ -120,9 +121,10 @@ const paddle = () => {
120121
v-model="plan.quantity"
121122
class="rounded-md border-gray-300 border text-center mr-2 w-20 focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
122123
type="number"
123-
min="0"
124+
min="1"
124125
max="10000"
125126
@keyup="checkPrice(plan)"
127+
@input="checkPrice(plan)"
126128
/>
127129

128130
<span>{{ $t('seats') }}</span>

0 commit comments

Comments
 (0)