Skip to content

Commit

Permalink
Merge pull request #36 from openfoodfoundation/feature/redemption-pro…
Browse files Browse the repository at this point in the history
…cess-api-and-ui-20240820

Feature: Redemption process, API, UI and aggregates
  • Loading branch information
ok200paul authored Sep 9, 2024
2 parents d833be8 + 52e8398 commit fd2884a
Show file tree
Hide file tree
Showing 28 changed files with 2,114 additions and 266 deletions.
19 changes: 14 additions & 5 deletions app/Console/Commands/TestCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

namespace App\Console\Commands;

use App\Models\PersonalAccessToken;
use App\Services\PersonalAccessTokenService;
use App\Models\User;
use App\Models\Voucher;
use App\Models\VoucherSet;
use Illuminate\Console\Command;

class TestCommand extends Command
Expand All @@ -27,9 +28,17 @@ class TestCommand extends Command
*/
public function handle()
{
$model = PersonalAccessToken::find(5);
$jwt = PersonalAccessTokenService::generateJwtForPersonalAccessToken($model);
dd($jwt);

$me = User::find(3);

$voucherSet = VoucherSet::factory()->createQuietly([
'created_by_team_id' => $me->current_team_id,
'created_by_user_id' => $me->id,
]);

$voucher = Voucher::factory()->createQuietly([
'voucher_set_id' => $voucherSet->id,
]);

}
}
7 changes: 7 additions & 0 deletions app/Enums/ApiResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ enum ApiResponse: string
case RESPONSE_AUTHORIZATION_SIGNATURE_INCORRECT_IAT_EXPIRED = 'IAT claim expired.';
case RESPONSE_AUTHORIZATION_SIGNATURE_INCORRECT_IAT_EXP_TOO_LARGE = 'IAT and EXP too far apart. Max diff: 1 minute.';
case RESPONSE_AUTHORIZATION_SIGNATURE_INCORRECT_EXPIRED = 'Token expired.';
case RESPONSE_INVALID_MERCHANT_TEAM = 'Invalid merchant team.';
case RESPONSE_QUERY_FILTER_DISALLOWED = 'Query filter disallowed';
case RESPONSE_REDEMPTION_FAILED_VOUCHER_ALREADY_FULLY_REDEEMED = 'This voucher has already been fully redeemed, no redemption made this time.';
case RESPONSE_REDEMPTION_FAILED_REQUESTED_AMOUNT_TOO_HIGH = 'Requested amount is greater than voucher value remaining, no redemption made this time.';
case RESPONSE_REDEMPTION_FAILED_TOO_MANY_ATTEMPTS = 'Too many redemption attempts, please wait.';
case RESPONSE_REDEMPTION_LIVE_REDEMPTION = 'This was a test redemption. Do NOT provide the person with goods or services.';
case RESPONSE_REDEMPTION_TEST_REDEMPTION = 'Please provide the customer with their goods / services to the value of XXX.';
case RESPONSE_REDEMPTION_SUCCESSFUL = 'Redemption successful.';
case RESPONSE_UPDATED = 'Updated';
}
20 changes: 10 additions & 10 deletions app/Enums/PersonalAccessTokenAbility.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,6 @@ enum PersonalAccessTokenAbility: string
case MY_TEAM_VOUCHERS_READ = 'my-team-vouchers-read';
case MY_TEAM_VOUCHERS_UPDATE = 'my-team-vouchers-update';
case MY_TEAM_VOUCHERS_DELETE = 'my-team-vouchers-delete';
case REDEMPTIONS_CREATE = 'redemptions-create';
case REDEMPTIONS_READ = 'redemptions-read';
case REDEMPTIONS_UPDATE = 'redemptions-update';
case REDEMPTIONS_DELETE = 'redemptions-delete';
case SHOPS_CREATE = 'shops-create';
case SHOPS_READ = 'shops-read';
case SHOPS_UPDATE = 'shops-update';
Expand All @@ -36,6 +32,10 @@ enum PersonalAccessTokenAbility: string
case SYSTEM_STATISTICS_READ = 'system-statistics-read';
case SYSTEM_STATISTICS_UPDATE = 'system-statistics-update';
case SYSTEM_STATISTICS_DELETE = 'system-statistics-delete';
case VOUCHER_REDEMPTIONS_CREATE = 'voucher-redemptions-create';
case VOUCHER_REDEMPTIONS_READ = 'voucher-redemptions-read';
case VOUCHER_REDEMPTIONS_UPDATE = 'voucher-redemptions-update';
case VOUCHER_REDEMPTIONS_DELETE = 'voucher-redemptions-delete';

public static function abilityLabels(): array
{
Expand All @@ -57,10 +57,6 @@ public static function abilityLabels(): array
self::MY_TEAM_VOUCHERS_READ->value => 'My Team Vouchers Read',
self::MY_TEAM_VOUCHERS_UPDATE->value => 'My Team Vouchers Update',
self::MY_TEAM_VOUCHERS_DELETE->value => 'My Team Vouchers Delete',
self::REDEMPTIONS_CREATE->value => 'Redemptions Create: Perform a voucher redemption',
self::REDEMPTIONS_READ->value => 'Redemptions Read: Retrieve a redemption',
self::REDEMPTIONS_UPDATE->value => 'Redemptions Update: Update a redemption',
self::REDEMPTIONS_DELETE->value => 'Redemptions Delete: Delete a redemption',
self::SHOPS_CREATE->value => 'Shops Create: Create a shop that redeems vouchers',
self::SHOPS_READ->value => 'Shops Read: Retrieve shop details from the API',
self::SHOPS_UPDATE->value => 'Shops Update: Update a shop',
Expand All @@ -69,6 +65,10 @@ public static function abilityLabels(): array
self::SYSTEM_STATISTICS_READ->value => 'System Statistics Read',
self::SYSTEM_STATISTICS_UPDATE->value => 'System Statistics Update',
self::SYSTEM_STATISTICS_DELETE->value => 'System Statistics Delete',
self::VOUCHER_REDEMPTIONS_CREATE->value => 'Voucher Redemptions Create',
self::VOUCHER_REDEMPTIONS_READ->value => 'Voucher Redemptions Read',
self::VOUCHER_REDEMPTIONS_UPDATE->value => 'Voucher Redemptions Update',
self::VOUCHER_REDEMPTIONS_DELETE->value => 'Voucher Redemptions Delete',
];
}

Expand Down Expand Up @@ -98,8 +98,8 @@ public static function platformAppTokenAbilities(): array
public static function redemptionAppTokenAbilities(): array
{
return [
self::REDEMPTIONS_CREATE->value => self::abilityLabels()[self::REDEMPTIONS_CREATE->value],
self::REDEMPTIONS_READ->value => self::abilityLabels()[self::REDEMPTIONS_READ->value],
self::VOUCHER_REDEMPTIONS_CREATE->value => self::abilityLabels()[self::VOUCHER_REDEMPTIONS_CREATE->value],
self::VOUCHER_REDEMPTIONS_READ->value => self::abilityLabels()[self::VOUCHER_REDEMPTIONS_READ->value],
];
}

Expand Down
20 changes: 10 additions & 10 deletions app/Http/Controllers/Api/HandlesAPIRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,16 @@
*/
trait HandlesAPIRequests
{
public Request $request;
public array $fields = [];
public int $limit = 50;
public string $message = '';
public int $offset = 0;
public int $responseCode = 200;
public mixed $data = [];
protected mixed $query = false;
public bool $cached = true;
public string $cacheKey = '';
public Request $request;
public array $fields = [];
public int $limit = 50;
public string $message = '';
public int $offset = 0;
public int $responseCode = 200;
public mixed $data = [];
protected mixed $query = false;
public bool $cached = true;
public string $cacheKey = '';

/**
* Set the related data we'll ask for in GET API requests
Expand Down
8 changes: 5 additions & 3 deletions app/Http/Controllers/Api/V1/ApiMyTeamVouchersController.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ class ApiMyTeamVouchersController extends Controller
/**
* Set the related data the GET request is allowed to ask for
*/
public array $availableRelations = [];
public array $availableRelations = [
'voucherRedemptions',
];

public static array $searchableFields = [
'id',
Expand Down Expand Up @@ -191,8 +193,8 @@ public function store(): JsonResponse
public function show(string $id)
{

$this->query = Voucher::where('team_id', Auth::user()->current_team_id)
->orWhere('assigned_to_team_id', Auth::user()->current_team_id)
$this->query = Voucher::where('created_by_team_id', Auth::user()->current_team_id)
->orWhere('allocated_to_service_team_id', Auth::user()->current_team_id)
->with($this->associatedData);

$this->query = $this->updateReadQueryBasedOnUrl();
Expand Down
231 changes: 231 additions & 0 deletions app/Http/Controllers/Api/V1/ApiVoucherRedemptionsController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
<?php

namespace App\Http\Controllers\Api\V1;

use App\Enums\ApiResponse;
use App\Http\Controllers\Api\HandlesAPIRequests;
use App\Http\Controllers\Controller;
use App\Models\Voucher;
use App\Models\VoucherRedemption;
use App\Models\VoucherSetMerchantTeam;
use App\Services\VoucherService;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Knuckles\Scribe\Attributes\Authenticated;
use Knuckles\Scribe\Attributes\Endpoint;
use Knuckles\Scribe\Attributes\Group;
use Knuckles\Scribe\Attributes\Response;
use Knuckles\Scribe\Attributes\Subgroup;

#[Group('App Endpoints')]
#[Subgroup('/voucher-redemptions', 'API for creating voucher redemptions')]
class ApiVoucherRedemptionsController extends Controller
{
use HandlesAPIRequests;

/**
* Set the related data the GET request is allowed to ask for
*
* @var array
*/
public array $availableRelations = [];

public static array $searchableFields = [];

/**
* @hideFromAPIDocumentation
* GET /
*/
public function index(): JsonResponse
{
$this->responseCode = 403;
$this->message = ApiResponse::RESPONSE_METHOD_NOT_ALLOWED->value;

return $this->respond();
}

/**
* POST /
*/
#[Endpoint(
title : 'POST /',
description : 'Create a new voucher redemption.',
authenticated: true
)]
#[Authenticated]
#[Response(
content : '{"meta":{"responseCode":200,"limit":50,"offset":0,"message":"Saved"},"data":{"voucher_id":"ec70cf3b-f4ab-3ce0-9201-c10362aa2f07","voucher_set_id":"6f644113-b836-3e34-8ed7-27c40c10d2c1","redeemed_by_user_id":1,"redeemed_by_team_id":1,"redeemed_amount":1,"is_test":0,"updated_at":"2024-09-06T03:31:20.000000Z","created_at":"2024-09-06T03:31:20.000000Z","id":1}}',
status : 200,
description: '',
)]
public function store(): JsonResponse
{
$validationArray = [
'voucher_id' => [
'required',
Rule::exists('vouchers', 'id'),
],
'voucher_set_id' => [
'required',
Rule::exists('voucher_sets', 'id'),
],
'amount' => [
'integer',
'min:1',
],
];

$validator = Validator::make($this->request->all(), $validationArray);

if ($validator->fails()) {
$this->responseCode = 400;
$this->message = $validator->errors()->first();

return $this->respond();
}

try {

$voucherId = $this->request->get('voucher_id');
$voucherSetId = $this->request->get('voucher_set_id');
$amount = $this->request->get('amount');

/**
* Ensure the voucher exists with the set
*/
$voucher = Voucher::where('voucher_set_id', $voucherSetId)->find($voucherId);

if (!$voucher) {
$this->responseCode = 404;
$this->message = ApiResponse::RESPONSE_NOT_FOUND->value;

return $this->respond();
}

/**
* Ensure the users current team is a merchant for the voucher set.
*/
$voucherSetMerchantTeamIds = VoucherSetMerchantTeam::where('voucher_set_id', $voucherSetId)
->pluck('merchant_team_id')
->unique()
->toArray();

if (!in_array(Auth::user()->current_team_id, $voucherSetMerchantTeamIds)) {
$this->responseCode = 400;
$this->message = ApiResponse::RESPONSE_INVALID_MERCHANT_TEAM->value;

return $this->respond();
}

/**
* Update the remaining amount, just in case
*/
VoucherService::updateVoucherAmountRemaining($voucher);
$voucher->refresh();

if ($voucher->last_redemption_at > now()->subMinute()) {

$this->responseCode = 429;
$this->message = ApiResponse::RESPONSE_REDEMPTION_FAILED_TOO_MANY_ATTEMPTS->value;

return $this->respond();
}

if ($voucher->voucher_value_remaining <= 0) {
$this->responseCode = 400;
$this->message = ApiResponse::RESPONSE_REDEMPTION_FAILED_VOUCHER_ALREADY_FULLY_REDEEMED->value;

return $this->respond();
}

if ($amount > $voucher->voucher_value_remaining) {
$this->responseCode = 400;
$this->message = ApiResponse::RESPONSE_REDEMPTION_FAILED_REQUESTED_AMOUNT_TOO_HIGH->value;

return $this->respond();
}

$redemption = new VoucherRedemption();
$redemption->voucher_id = $voucherId;
$redemption->voucher_set_id = $voucherSetId;
$redemption->redeemed_by_user_id = Auth::id();
$redemption->redeemed_by_team_id = Auth::user()->current_team_id;
$redemption->redeemed_amount = $amount;
$redemption->is_test = $voucher->is_test;
$redemption->save();

$voucher->last_redemption_at = now();
$voucher->save();

VoucherService::updateVoucherAmountRemaining($voucher);

/**
* Set the redemption response wording
*/
$amountInDollars = '$' . number_format(($amount / 100), 2, '.', '');
$liveRedemptionResponse = str_replace(search: 'XXX', replace: $amountInDollars, subject: ApiResponse::RESPONSE_REDEMPTION_LIVE_REDEMPTION->value);
$testRedemptionResponse = ApiResponse::RESPONSE_REDEMPTION_TEST_REDEMPTION->value;
$redemptionMessageSuffix = ($voucher->is_test == 1) ? $testRedemptionResponse : $liveRedemptionResponse;
$this->message = ApiResponse::RESPONSE_REDEMPTION_SUCCESSFUL->value . ' ' . $redemptionMessageSuffix;
$this->data = $redemption;

}
catch (Exception $e) {
$this->responseCode = 500;
$this->message = ApiResponse::RESPONSE_ERROR->value . ':' . $e->getMessage();
}

return $this->respond();
}

/**
* @hideFromAPIDocumentation
* GET /{id}
*
* @param int $id
*
* @return JsonResponse
*/
public function show(int $id): JsonResponse
{
$this->responseCode = 403;
$this->message = ApiResponse::RESPONSE_METHOD_NOT_ALLOWED->value;

return $this->respond();
}

/**
* @hideFromAPIDocumentation
* PUT /{id}
*
* @param int $id
*
* @return JsonResponse
*/
public function update(int $id): JsonResponse
{
$this->responseCode = 403;
$this->message = ApiResponse::RESPONSE_METHOD_NOT_ALLOWED->value;

return $this->respond();
}

/**
* @hideFromAPIDocumentation
* DELETE /{id}
*
* @param int $id
*
* @return JsonResponse
*/
public function destroy(int $id): JsonResponse
{
$this->responseCode = 403;
$this->message = ApiResponse::RESPONSE_METHOD_NOT_ALLOWED->value;

return $this->respond();
}
}
Loading

0 comments on commit fd2884a

Please sign in to comment.