Add InvoiceStoreAction
continuous-integration/drone/push Build is failing Details

This commit is contained in:
Philipp Lang 2023-12-13 00:35:39 +01:00
parent ad8511874d
commit 5c40b4e64d
12 changed files with 389 additions and 1 deletions

View File

@ -0,0 +1,59 @@
<?php
namespace App\Invoice\Actions;
use App\Invoice\Enums\InvoiceStatus;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
use App\Invoice\Models\Invoice;
use Illuminate\Validation\Rule;
class InvoiceStoreAction
{
use AsAction;
/**
* @return array<string, string|array<int, string|Rule>>
*/
public function rules(): array
{
return [
'status' => ['required', 'string', 'max:255', Rule::in(InvoiceStatus::values())],
'to' => 'array',
'to.address' => 'required|string|max:255',
'to.location' => 'required|string|max:255',
'to.zip' => 'required|string|max:255',
'to.name' => 'required|string|max:255',
'greeting' => 'required|string|max:255',
'intro' => 'required|string',
'outro' => 'required|string',
'positions' => 'array',
'positions.*.description' => 'required|string|max:300',
'positions.*.price' => 'required|integer|min:0',
'positions.*.member_id' => 'required|exists:members,id',
];
}
/**
* @return array<string, string>
*/
public function getValidationAttributes(): array
{
return [
'to.address' => 'Adresse',
'to.name' => 'Name',
'to.zip' => 'PLZ',
'to.location' => 'Ort',
'status' => 'Status',
];
}
public function handle(ActionRequest $request): void
{
$invoice = Invoice::create($request->safe()->except('positions'));
foreach ($request->validated('positions') as $position) {
$invoice->positions()->create($position);
}
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Invoice\Enums;
use Illuminate\Support\Collection;
enum InvoiceStatus: string
{
case NEW = 'Neu';
case SENT = 'Rechnung gestellt';
case PAID = 'Rechnung beglichen';
/**
* @return Collection<int, string>
*/
public static function values(): Collection
{
return collect(static::cases())->map(fn ($case) => $case->value);
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Invoice\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Invoice extends Model
{
use HasFactory;
public $guarded = [];
public $casts = [
'to' => 'json',
];
/**
* @return HasMany<InvoicePosition>
*/
public function positions(): HasMany
{
return $this->hasMany(InvoicePosition::class);
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Invoice\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class InvoicePosition extends Model
{
use HasFactory;
public $guarded = [];
}

View File

@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('invoices', function (Blueprint $table) {
$table->id();
$table->json('to');
$table->string('greeting');
$table->text('intro');
$table->text('outro');
$table->string('status');
$table->timestamps();
});
Schema::create('invoice_positions', function (Blueprint $table) {
$table->id();
$table->foreignId('invoice_id');
$table->string('description');
$table->foreignId('member_id');
$table->unsignedBigInteger('price');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('invoice_positions');
Schema::dropIfExists('invoices');
}
};

View File

@ -15,6 +15,7 @@ use App\Contribution\Actions\ValidateAction as ContributionValidateAction;
use App\Course\Actions\CourseDestroyAction;
use App\Course\Actions\CourseIndexAction;
use App\Course\Actions\CourseStoreAction;
use App\Invoice\Actions\InvoiceStoreAction;
use App\Course\Actions\CourseUpdateAction;
use App\Dashboard\Actions\IndexAction as DashboardIndexAction;
use App\Efz\ShowEfzDocumentAction;
@ -112,6 +113,9 @@ Route::group(['middleware' => 'auth:web'], function (): void {
Route::patch('/payment/{payment}', PaymentUpdateAction::class)->name('payment.update');
Route::delete('/payment/{payment}', PaymentDestroyAction::class)->name('payment.destroy');
// ---------------------------------- invoice ----------------------------------
Route::post('/invoice', InvoiceStoreAction::class)->name('invoice.store');
// --------------------------------- membership --------------------------------
Route::get('/member/{member}/membership', MembershipIndexAction::class)->name('member.membership.index');
Route::post('/member/{member}/membership', MembershipStoreAction::class)->name('member.membership.store');

View File

@ -0,0 +1,32 @@
<?php
namespace Tests\Feature\Invoice;
use App\Member\Member;
use Worksome\RequestFactories\RequestFactory;
class InvoicePositionRequestFactory extends RequestFactory
{
public function definition(): array
{
return [
'description' => 'Beitrag Abc',
'price' => 3250,
];
}
public function description(string $description): self
{
return $this->state(['description' => $description]);
}
public function price(int $price): self
{
return $this->state(['price' => $price]);
}
public function member(Member $member): self
{
return $this->state(['member_id' => $member->id]);
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace Tests\Feature\Invoice;
use App\Invoice\Enums\InvoiceStatus;
use App\Member\Member;
use Worksome\RequestFactories\RequestFactory;
class InvoiceRequestFactory extends RequestFactory
{
public function definition(): array
{
return [
'to' => ReceiverRequestFactory::new(),
'greeting' => 'Hallo Familie',
'intro' => 'Hiermit stellen wir ihnen den Beitrag in Rechnung.',
'outro' => 'Das ist die Rechnung',
'positions' => []
];
}
public function to(ReceiverRequestFactory $to): self
{
return $this->state(['to' => $to]);
}
public function status(InvoiceStatus $status): self
{
return $this->state(['status' => $status->value]);
}
public function position(InvoicePositionRequestFactory $factory): self
{
return $this->state(['positions' => [
$factory->create(),
]]);
}
}

View File

@ -0,0 +1,111 @@
<?php
namespace Tests\Feature\Invoice;
use App\Invoice\Enums\InvoiceStatus;
use App\Invoice\Models\Invoice;
use App\Member\Member;
use Generator;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;
class InvoiceStoreActionTest extends TestCase
{
use DatabaseTransactions;
public function testItCanCreateAnInvoice(): void
{
$this->login()->loginNami()->withoutExceptionHandling();
$member = Member::factory()->defaults()->create();
$response = $this->postJson(
route('invoice.store'),
InvoiceRequestFactory::new()
->to(ReceiverRequestFactory::new()->name('Familie Blabla')->address('Musterstr 44')->zip('22222')->location('Solingen'))
->position(InvoicePositionRequestFactory::new()->description('Beitrag Abc')->price(3250)->member($member))
->status(InvoiceStatus::PAID)
->state([
'greeting' => 'Hallo Familie',
'intro' => 'Hiermit stellen wir ihnen den Beitrag in Rechnung.',
'outro' => 'Das ist die Rechnung',
])
->create()
);
$response->assertOk();
$this->assertDatabaseHas('invoices', [
'greeting' => 'Hallo Familie',
'intro' => 'Hiermit stellen wir ihnen den Beitrag in Rechnung.',
'outro' => 'Das ist die Rechnung',
'status' => InvoiceStatus::PAID->value,
]);
$invoice = Invoice::firstWhere('greeting', 'Hallo Familie');
$this->assertDatabaseHas('invoice_positions', [
'invoice_id' => $invoice->id,
'member_id' => $member->id,
'price' => 3250,
'description' => 'Beitrag Abc',
]);
$this->assertEquals([
'name' => 'Familie Blabla',
'address' => 'Musterstr 44',
'zip' => '22222',
'location' => 'Solingen',
], $invoice->to);
}
public function validationDataProvider(): Generator
{
yield [
['to.address' => ''],
['to.address' => 'Adresse ist erforderlich.']
];
yield [
['to.name' => ''],
['to.name' => 'Name ist erforderlich.']
];
yield [
['to.location' => ''],
['to.location' => 'Ort ist erforderlich.']
];
yield [
['status' => ''],
['status' => 'Status ist erforderlich.']
];
yield [
['status' => 'lala'],
['status' => 'Der gewählte Wert für Status ist ungültig.']
];
yield [
['to.zip' => ''],
['to.zip' => 'PLZ ist erforderlich.']
];
}
/**
* @param array<string, mixed> $input
* @param array<string, string> $errors
* @dataProvider validationDataProvider
*/
public function testItValidatesInput(array $input, array $errors): void
{
$this->login()->loginNami();
$response = $this->postJson(
route('invoice.store'),
InvoiceRequestFactory::new()
->to(ReceiverRequestFactory::new())
->position(InvoicePositionRequestFactory::new()->member(Member::factory()->defaults()->create()))
->state($input)
->create()
);
$response->assertJsonValidationErrors($errors);
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace Tests\Feature\Invoice;
use Worksome\RequestFactories\RequestFactory;
class ReceiverRequestFactory extends RequestFactory
{
public function definition(): array
{
return [
'name' => 'Familie Blabla',
'address' => 'Musterstr 44',
'zip' => '22222',
'location' => 'Solingen',
];
}
public function name(string $name): self
{
return $this->state(['name' => $name]);
}
public function address(string $address): self
{
return $this->state(['address' => $address]);
}
public function zip(string $zip): self
{
return $this->state(['zip' => $zip]);
}
public function location(string $location): self
{
return $this->state(['location' => $location]);
}
}

View File

@ -8,6 +8,7 @@ use App\Member\Member;
use App\Payment\Payment;
use App\Payment\Subscription;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Collection;
use Tests\RequestFactories\Child;
use Tests\TestCase;
@ -54,6 +55,7 @@ class IndexTest extends TestCase
]))
->defaults()->create();
/** @var Collection<int|string, Member> */
$members = collect([$member]);
app(DocumentFactory::class)->afterSingle(BillDocument::fromMembers($members), $members);

View File

@ -45,7 +45,6 @@ class PaymentPdfTest extends TestCase
]))
->emailBillKind()
->create(['firstname' => 'Lah', 'lastname' => 'Mom', 'email' => 'peter@example.com']);
/** @var Collection<(int|string), Member> */
$response = $this->get(route('payment.pdf', ['payment' => $member->payments->first()]));
$response->assertStatus(204);