feat(stripe): add promo code generator (#311)

## Summary
- add artisan command to generate N Stripe promotion codes
- default coupon to 0E5fAsXG and enforce single redemption
- add feature test coverage for success and validation

## Testing
- php artisan test --compact
tests/Feature/GenerateStripePromotionCodesCommandTest.php
- vendor/bin/pint --dirty --format agent
This commit is contained in:
Víctor Falcón 2026-04-20 18:15:28 +01:00 committed by GitHub
parent 16675f6518
commit 69665c3c58
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 156 additions and 0 deletions

View File

@ -0,0 +1,103 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
use Laravel\Cashier\Cashier;
use RuntimeException;
use Stripe\Exception\ApiErrorException;
use Stripe\PromotionCode;
class GenerateStripePromotionCodesCommand extends Command
{
private const DEFAULT_COUPON_ID = '0E5fAsXG';
protected $signature = 'stripe:generate-promotion-codes
{count : Number of promotion codes to generate}
{--coupon='.self::DEFAULT_COUPON_ID.' : Stripe coupon ID to attach to each code}';
protected $description = 'Generate single-use Stripe promotion codes';
public function handle(): int
{
$count = filter_var($this->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<string> $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<string> $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');
}
}

View File

@ -0,0 +1,53 @@
<?php
use Stripe\PromotionCode;
use Stripe\Service\PromotionCodeService;
use Stripe\StripeClient;
function makeStripePromotionCode(string $id, string $code): PromotionCode
{
return PromotionCode::constructFrom([
'id' => $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();
});