Skip to content

Commit de82f79

Browse files
committed
add commands and jobs to import existing anystack data
1 parent f95c179 commit de82f79

13 files changed

+396
-1
lines changed

.env.example

+3
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,15 @@ STRIPE_WEBHOOK_SECRET=
6565
STRIPE_MINI_PRICE_ID=
6666
STRIPE_PRO_PRICE_ID=
6767
STRIPE_MAX_PRICE_ID=
68+
STRIPE_FOREVER_PRICE_ID=
6869
STRIPE_MINI_PAYMENT_LINK=
6970
STRIPE_PRO_PAYMENT_LINK=
7071
STRIPE_MAX_PAYMENT_LINK=
72+
STRIPE_FOREVER_PAYMENT_LINK=
7173

7274
ANYSTACK_API_KEY=
7375
ANYSTACK_PRODUCT_ID=
7476
ANYSTACK_MINI_POLICY_ID=
7577
ANYSTACK_PRO_POLICY_ID=
7678
ANYSTACK_MAX_POLICY_ID=
79+
ANYSTACK_FOREVER_POLICY_ID=
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
namespace App\Console\Commands;
4+
5+
use App\Jobs\ImportAnystackContacts;
6+
use Illuminate\Console\Command;
7+
use Illuminate\Support\Facades\Bus;
8+
9+
class ImportAnystackContactsCommand extends Command
10+
{
11+
protected $signature = 'app:import-anystack-contacts';
12+
13+
protected $description = 'Import existing contact data from Anystack.';
14+
15+
public function handle(): void
16+
{
17+
Bus::batch([
18+
new ImportAnystackContacts(1),
19+
])
20+
->name('import-anystack-contacts')
21+
->dispatch();
22+
}
23+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
namespace App\Console\Commands;
4+
5+
use App\Jobs\ImportAnystackLicenses;
6+
use Illuminate\Console\Command;
7+
use Illuminate\Support\Facades\Bus;
8+
9+
class ImportAnystackLicensesCommand extends Command
10+
{
11+
protected $signature = 'app:import-anystack-licenses';
12+
13+
protected $description = 'Import existing license data from Anystack.';
14+
15+
public function handle(): void
16+
{
17+
Bus::batch([
18+
new ImportAnystackLicenses(1),
19+
])
20+
->name('import-anystack-licenses')
21+
->dispatch();
22+
}
23+
}

app/Enums/Subscription.php

+14
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ enum Subscription: string
99
case Mini = 'mini';
1010
case Pro = 'pro';
1111
case Max = 'max';
12+
case Forever = 'forever';
13+
case Trial = 'trial';
1214

1315
public static function fromStripeSubscription(\Stripe\Subscription $subscription): self
1416
{
@@ -31,6 +33,18 @@ public static function fromStripePriceId(string $priceId): self
3133
};
3234
}
3335

36+
public static function fromAnystackPolicy(string $policyId): self
37+
{
38+
return match ($policyId) {
39+
config('subscriptions.plans.mini.anystack_policy_id') => self::Mini,
40+
config('subscriptions.plans.pro.anystack_policy_id') => self::Pro,
41+
config('subscriptions.plans.max.anystack_policy_id') => self::Max,
42+
config('subscriptions.plans.forever.anystack_policy_id') => self::Forever,
43+
config('subscriptions.plans.trial.anystack_policy_id') => self::Trial,
44+
default => throw new RuntimeException("Unknown Anystack policy id: {$policyId}"),
45+
};
46+
}
47+
3448
public function name(): string
3549
{
3650
return config("subscriptions.plans.{$this->value}.name");

app/Jobs/CreateUserFromStripeCustomer.php

+10-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ public function handle(): void
3030
return;
3131
}
3232

33-
if ($user = User::query()->where('email', $this->customer->email)->first()) {
33+
$user = User::query()->where('email', $this->customer->email)->first();
34+
35+
if ($user && filled($user->stripe_id)) {
3436
// This could occur if a user performs/attempts multiple checkouts with the same email address.
3537
// In the event all existing stripe customers for this email address do NOT have an active
3638
// subscription, we could theoretically update the stripe_id for the existing user
@@ -40,6 +42,13 @@ public function handle(): void
4042
return;
4143
}
4244

45+
if ($user) {
46+
$user->stripe_id = $this->customer->id;
47+
$user->save();
48+
49+
return;
50+
}
51+
4352
Validator::validate(['email' => $this->customer->email], [
4453
'email' => 'required|email|max:255',
4554
]);

app/Jobs/ImportAnystackContacts.php

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
namespace App\Jobs;
4+
5+
use Illuminate\Bus\Batchable;
6+
use Illuminate\Bus\Queueable;
7+
use Illuminate\Contracts\Queue\ShouldQueue;
8+
use Illuminate\Foundation\Bus\Dispatchable;
9+
use Illuminate\Queue\InteractsWithQueue;
10+
use Illuminate\Queue\Middleware\RateLimited;
11+
use Illuminate\Queue\Middleware\SkipIfBatchCancelled;
12+
use Illuminate\Queue\SerializesModels;
13+
use Illuminate\Support\Facades\Http;
14+
15+
class ImportAnystackContacts implements ShouldQueue
16+
{
17+
use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
18+
19+
public int $maxExceptions = 1;
20+
21+
public function __construct(
22+
public int $page,
23+
) {}
24+
25+
public function middleware(): array
26+
{
27+
return [
28+
new SkipIfBatchCancelled,
29+
new RateLimited('anystack'),
30+
];
31+
}
32+
33+
public function retryUntil(): \DateTime
34+
{
35+
return now()->addMinutes(20);
36+
}
37+
38+
public function handle(): void
39+
{
40+
$response = Http::acceptJson()
41+
->withToken(config('services.anystack.key'))
42+
->get("https://api.anystack.sh/v1/contacts?page={$this->page}")
43+
->json();
44+
45+
collect($response['data'])
46+
->each(function (array $contact) {
47+
dispatch(new UpsertUserFromAnystackContact($contact));
48+
});
49+
50+
if (filled($response['links']['next'])) {
51+
$this->batch()?->add(new self($this->page + 1));
52+
}
53+
}
54+
}

app/Jobs/ImportAnystackLicenses.php

+57
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php
2+
3+
namespace App\Jobs;
4+
5+
use App\Enums\Subscription;
6+
use Illuminate\Bus\Batchable;
7+
use Illuminate\Bus\Queueable;
8+
use Illuminate\Contracts\Queue\ShouldQueue;
9+
use Illuminate\Foundation\Bus\Dispatchable;
10+
use Illuminate\Queue\InteractsWithQueue;
11+
use Illuminate\Queue\Middleware\RateLimited;
12+
use Illuminate\Queue\Middleware\SkipIfBatchCancelled;
13+
use Illuminate\Queue\SerializesModels;
14+
use Illuminate\Support\Facades\Http;
15+
16+
class ImportAnystackLicenses implements ShouldQueue
17+
{
18+
use Batchable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
19+
20+
public int $maxExceptions = 1;
21+
22+
public function __construct(
23+
public int $page,
24+
) {}
25+
26+
public function middleware(): array
27+
{
28+
return [
29+
new SkipIfBatchCancelled,
30+
new RateLimited('anystack'),
31+
];
32+
}
33+
34+
public function retryUntil(): \DateTime
35+
{
36+
return now()->addMinutes(20);
37+
}
38+
39+
public function handle(): void
40+
{
41+
$productId = Subscription::Max->anystackProductId();
42+
43+
$response = Http::acceptJson()
44+
->withToken(config('services.anystack.key'))
45+
->get("https://api.anystack.sh/v1/products/$productId/licenses?page={$this->page}")
46+
->json();
47+
48+
collect($response['data'])
49+
->each(function (array $license) {
50+
dispatch(new UpsertLicenseFromAnystackLicense($license));
51+
});
52+
53+
if (filled($response['links']['next'])) {
54+
$this->batch()?->add(new self($this->page + 1));
55+
}
56+
}
57+
}
+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
namespace App\Jobs;
4+
5+
use App\Models\User;
6+
use Illuminate\Bus\Queueable;
7+
use Illuminate\Contracts\Queue\ShouldQueue;
8+
use Illuminate\Foundation\Bus\Dispatchable;
9+
use Illuminate\Queue\InteractsWithQueue;
10+
use Illuminate\Queue\SerializesModels;
11+
12+
class ImportAnystackLicensesForUser implements ShouldQueue
13+
{
14+
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
15+
16+
public function __construct(
17+
public User $user,
18+
) {}
19+
20+
public function handle(): void
21+
{
22+
if (! $this->user->anystack_contact_id) {
23+
$this->fail('Cannot import licenses: user does not have an anystack_contact_id.');
24+
25+
return;
26+
}
27+
28+
}
29+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
namespace App\Jobs;
4+
5+
use App\Enums\Subscription;
6+
use App\Models\License;
7+
use App\Models\User;
8+
use Illuminate\Bus\Queueable;
9+
use Illuminate\Contracts\Queue\ShouldQueue;
10+
use Illuminate\Foundation\Bus\Dispatchable;
11+
use Illuminate\Queue\InteractsWithQueue;
12+
use Illuminate\Queue\SerializesModels;
13+
14+
class UpsertLicenseFromAnystackLicense implements ShouldQueue
15+
{
16+
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
17+
18+
public int $maxExceptions = 1;
19+
20+
public function __construct(
21+
public array $licenseData
22+
) {}
23+
24+
public function handle(): void
25+
{
26+
License::updateOrCreate(['key' => $this->licenseData['key']], $this->values());
27+
}
28+
29+
protected function values(): array
30+
{
31+
$values = [
32+
'anystack_id' => $this->licenseData['id'],
33+
// subscription_item_id is not set here because we don't want to replace any existing values.
34+
'policy_name' => Subscription::fromAnystackPolicy($this->licenseData['policy_id'])->value,
35+
'expires_at' => $this->licenseData['expires_at'],
36+
'created_at' => $this->licenseData['created_at'],
37+
'updated_at' => $this->licenseData['updated_at'],
38+
];
39+
40+
if ($user = $this->user()) {
41+
$values['user_id'] = $user->id;
42+
}
43+
44+
return $values;
45+
}
46+
47+
protected function user(): User
48+
{
49+
return User::query()
50+
->where('anystack_contact_id', $this->licenseData['contact_id'])
51+
->firstOrFail();
52+
}
53+
}
+75
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<?php
2+
3+
namespace App\Jobs;
4+
5+
use App\Models\User;
6+
use Exception;
7+
use Illuminate\Bus\Queueable;
8+
use Illuminate\Contracts\Queue\ShouldQueue;
9+
use Illuminate\Database\Eloquent\Collection;
10+
use Illuminate\Foundation\Bus\Dispatchable;
11+
use Illuminate\Queue\InteractsWithQueue;
12+
use Illuminate\Queue\SerializesModels;
13+
use Illuminate\Support\Facades\Hash;
14+
use Illuminate\Support\Facades\Log;
15+
use Illuminate\Support\Str;
16+
17+
class UpsertUserFromAnystackContact implements ShouldQueue
18+
{
19+
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
20+
21+
public int $maxExceptions = 1;
22+
23+
public function __construct(
24+
public array $contactData
25+
) {}
26+
27+
public function handle(): void
28+
{
29+
$users = $this->matchingUsers();
30+
31+
if ($users->count() > 1) {
32+
$userIds = $users->pluck('id')->implode(', ');
33+
34+
throw new Exception("Multiple users [$userIds] found for contact by id [{$this->contactData['id']}] or email [{$this->contactData['email']}]");
35+
}
36+
37+
$user = $users->first() ?? new User;
38+
39+
$this->assertUserAttributesAreValid($user);
40+
41+
Log::debug(($user->exists() ? "Updating user [{$user->id}]" : 'Creating user')." from anystack contact [{$this->contactData['id']}].");
42+
43+
$user->anystack_contact_id ??= $this->contactData['id'];
44+
$user->email ??= $this->contactData['email'];
45+
$user->name ??= $this->contactData['full_name'];
46+
$user->created_at ??= $this->contactData['created_at'];
47+
$user->updated_at ??= $this->contactData['updated_at'];
48+
$user->password ??= Hash::make(Str::random(72));
49+
50+
$user->save();
51+
}
52+
53+
protected function matchingUsers(): Collection
54+
{
55+
return User::query()
56+
->where('email', $this->contactData['email'])
57+
->orWhere('anystack_contact_id', $this->contactData['id'])
58+
->get();
59+
}
60+
61+
protected function assertUserAttributesAreValid(User $user): void
62+
{
63+
if (! $user->exists) {
64+
return;
65+
}
66+
67+
if (filled($user->anystack_contact_id) && $user->anystack_contact_id !== $this->contactData['id']) {
68+
throw new Exception("User [{$user->id}] already exists but the user's anystack_contact_id [{$user->anystack_contact_id}] does not match the id [{$this->contactData['id']}].");
69+
}
70+
71+
if ($user->email !== $this->contactData['email']) {
72+
throw new Exception("User [{$user->id}] already exists but the user's email [{$user->email}] does not match the email [{$this->contactData['email']}].");
73+
}
74+
}
75+
}

0 commit comments

Comments
 (0)