diff --git a/app/Console/Commands/GenerateStripePromotionCodesCommand.php b/app/Console/Commands/GenerateStripePromotionCodesCommand.php new file mode 100644 index 00000000..90c451a0 --- /dev/null +++ b/app/Console/Commands/GenerateStripePromotionCodesCommand.php @@ -0,0 +1,103 @@ +argument('count'), FILTER_VALIDATE_INT); + + if ($count === false || $count < 1) { + $this->error('Count must be a positive integer.'); + + return self::FAILURE; + } + + $couponId = (string) $this->option('coupon'); + $generatedCodes = []; + $rows = []; + + $this->info("Generating {$count} single-use promotion codes for coupon {$couponId}..."); + + for ($index = 1; $index <= $count; $index++) { + try { + $promotionCode = $this->createPromotionCode($couponId, $generatedCodes); + } catch (ApiErrorException $exception) { + $this->error("Failed generating promotion code {$index}: {$exception->getMessage()}"); + + return self::FAILURE; + } + + $generatedCodes[] = $promotionCode->code; + $rows[] = [$index, $promotionCode->id, $promotionCode->code]; + } + + $this->newLine(); + $this->table(['#', 'Stripe ID', 'Code'], $rows); + $this->newLine(); + $this->info('Generated '.$count.' promotion code'.($count === 1 ? '' : 's').'.'); + + return self::SUCCESS; + } + + /** + * @param list $generatedCodes + */ + private function createPromotionCode(string $couponId, array $generatedCodes): PromotionCode + { + for ($attempt = 1; $attempt <= 5; $attempt++) { + $code = $this->makeCode($generatedCodes); + + try { + return Cashier::stripe()->promotionCodes->create([ + 'coupon' => $couponId, + 'code' => $code, + 'max_redemptions' => 1, + ]); + } catch (ApiErrorException $exception) { + if ($attempt < 5 && $this->isDuplicateCodeError($exception)) { + continue; + } + + throw $exception; + } + } + + throw new RuntimeException('Unable to generate a unique promotion code after 5 attempts.'); + } + + /** + * @param list $generatedCodes + */ + private function makeCode(array $generatedCodes): string + { + do { + $code = 'WM-'.Str::upper(Str::random(10)); + } while (in_array($code, $generatedCodes, true)); + + return $code; + } + + private function isDuplicateCodeError(ApiErrorException $exception): bool + { + $message = Str::lower($exception->getMessage()); + + return str_contains($message, 'code') && str_contains($message, 'exist'); + } +} diff --git a/tests/Feature/GenerateStripePromotionCodesCommandTest.php b/tests/Feature/GenerateStripePromotionCodesCommandTest.php new file mode 100644 index 00000000..24b98e35 --- /dev/null +++ b/tests/Feature/GenerateStripePromotionCodesCommandTest.php @@ -0,0 +1,53 @@ + $id, + 'code' => $code, + ]); +} + +test('creates requested number of single use promotion codes for default coupon', function () { + $createdParams = []; + + $promotionCodeService = Mockery::mock(PromotionCodeService::class); + $promotionCodeService->shouldReceive('create') + ->times(3) + ->andReturnUsing(function (array $params) use (&$createdParams): PromotionCode { + $createdParams[] = $params; + $index = count($createdParams); + + return makeStripePromotionCode("promo_{$index}", $params['code']); + }); + + $stripeClient = Mockery::mock(StripeClient::class); + $stripeClient->promotionCodes = $promotionCodeService; + + app()->bind(StripeClient::class, fn () => $stripeClient); + + $this->artisan('stripe:generate-promotion-codes', ['count' => 3]) + ->expectsOutputToContain('Generating 3 single-use promotion codes for coupon 0E5fAsXG...') + ->expectsOutputToContain('Generated 3 promotion codes.') + ->assertSuccessful(); + + expect($createdParams)->toHaveCount(3); + + foreach ($createdParams as $params) { + expect($params['coupon'])->toBe('0E5fAsXG'); + expect($params['max_redemptions'])->toBe(1); + expect($params['code'])->toStartWith('WM-'); + } + + expect(collect($createdParams)->pluck('code')->unique())->toHaveCount(3); +}); + +test('fails when count is not a positive integer', function () { + $this->artisan('stripe:generate-promotion-codes', ['count' => 0]) + ->expectsOutputToContain('Count must be a positive integer.') + ->assertFailed(); +});