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:
parent
16675f6518
commit
69665c3c58
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
Loading…
Reference in New Issue