Compare commits

..

No commits in common. "0f80844d204c97ca9b2cb366240f4f06125f68e6" and "5c40b4e64d26412cfed2dae2fd9bff850483514e" have entirely different histories.

43 changed files with 378 additions and 1282 deletions

View File

@ -1,47 +0,0 @@
<?php
namespace App\Invoice\Actions;
use App\Invoice\BillKind;
use App\Invoice\Enums\InvoiceStatus;
use Illuminate\Validation\Rule;
trait HasValidation
{
/**
* @return array<string, string|array<int, string|Rule>>
*/
public function rules(): array
{
return [
'status' => ['required', 'string', 'max:255', Rule::in(InvoiceStatus::values())],
'via' => ['required', 'string', 'max:255', Rule::in(BillKind::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',
'positions' => 'array',
'positions.*.description' => 'required|string|max:300',
'positions.*.price' => 'required|integer|min:0',
'positions.*.member_id' => 'required|exists:members,id',
'positions.*.id' => 'present|nullable|exists:invoice_positions,id',
];
}
/**
* @return array<string, string>
*/
public function getValidationAttributes(): array
{
return [
'to.address' => 'Adresse',
'to.name' => 'Name',
'to.zip' => 'PLZ',
'to.location' => 'Ort',
'status' => 'Status',
'via' => 'Rechnungsweg',
];
}
}

View File

@ -1,23 +0,0 @@
<?php
namespace App\Invoice\Actions;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
use App\Invoice\Models\Invoice;
use App\Lib\Events\Succeeded;
use Illuminate\Http\JsonResponse;
class InvoiceDestroyAction
{
use AsAction;
public function handle(Invoice $invoice): JsonResponse
{
$invoice->delete();
Succeeded::message('Rechnung gelöscht.')->dispatch();
return response()->json([]);
}
}

View File

@ -1,34 +0,0 @@
<?php
namespace App\Invoice\Actions;
use Lorisleiva\Actions\Concerns\AsAction;
use App\Invoice\Models\Invoice;
use App\Invoice\Resources\InvoiceResource;
use Illuminate\Pagination\LengthAwarePaginator;
use Inertia\Inertia;
use Inertia\Response;
class InvoiceIndexAction
{
use AsAction;
/**
* @return LengthAwarePaginator<Invoice>
*/
public function handle(): LengthAwarePaginator
{
return Invoice::select('*')->with('positions')->paginate(15);
}
public function asController(): Response
{
session()->put('menu', 'invoice');
session()->put('title', 'Rechnungen');
return Inertia::render('invoice/Index', [
'data' => InvoiceResource::collection($this->handle()),
]);
}
}

View File

@ -2,15 +2,51 @@
namespace App\Invoice\Actions; namespace App\Invoice\Actions;
use App\Invoice\Enums\InvoiceStatus;
use Lorisleiva\Actions\ActionRequest; use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Concerns\AsAction;
use App\Invoice\Models\Invoice; use App\Invoice\Models\Invoice;
use App\Lib\Events\Succeeded; use Illuminate\Validation\Rule;
class InvoiceStoreAction class InvoiceStoreAction
{ {
use AsAction; use AsAction;
use HasValidation;
/**
* @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 public function handle(ActionRequest $request): void
{ {
@ -19,7 +55,5 @@ class InvoiceStoreAction
foreach ($request->validated('positions') as $position) { foreach ($request->validated('positions') as $position) {
$invoice->positions()->create($position); $invoice->positions()->create($position);
} }
Succeeded::message('Rechnung erstellt.')->dispatch();
} }
} }

View File

@ -1,33 +0,0 @@
<?php
namespace App\Invoice\Actions;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
use App\Invoice\Models\Invoice;
use App\Lib\Events\Succeeded;
use Illuminate\Support\Arr;
class InvoiceUpdateAction
{
use AsAction;
use HasValidation;
public function handle(Invoice $invoice, ActionRequest $request): void
{
$invoice->update($request->safe()->except('positions'));
foreach ($request->validated('positions') as $position) {
if ($position['id']) {
$invoice->positions()->firstWhere('id', $position['id'])->update(Arr::except($position, 'id'));
continue;
}
$invoice->positions()->create($position);
}
$invoice->positions()->whereNotIn('id', array_column($request->validated('positions'), 'id'))->delete();
Succeeded::message('Rechnung bearbeitet.')->dispatch();
}
}

View File

@ -1,65 +0,0 @@
<?php
namespace App\Invoice\Actions;
use App\Invoice\Models\Invoice;
use App\Lib\JobMiddleware\JobChannels;
use App\Lib\JobMiddleware\WithJobState;
use App\Lib\Queue\TracksJob;
use App\Member\Member;
use Illuminate\Http\JsonResponse;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
class MassStoreAction
{
use AsAction;
use TracksJob;
/**
* @return array<string, string>
*/
public function rules(): array
{
return [
'year' => 'required|numeric',
];
}
public function handle(int $year): void
{
$memberGroup = Member::payable()->get()
->groupBy(fn ($member) => "{$member->bill_kind->value}{$member->lastname}{$member->address}{$member->zip}{$member->location}");
foreach ($memberGroup as $members) {
$invoice = Invoice::createForMember($members->first());
foreach ($members as $member) {
foreach ($member->subscription->children as $child) {
$invoice->positions()->create([
'description' => str($child->name)->replace('{name}', $member->firstname . ' ' . $member->lastname)->replace('{year}', $year),
'price' => $child->amount,
'member_id' => $member->id,
]);
}
}
}
}
public function asController(ActionRequest $request): JsonResponse
{
$this->startJob($request->year);
return response()->json([]);
}
/**
* @param mixed $parameters
*/
public function jobState(WithJobState $jobState, ...$parameters): WithJobState
{
return $jobState
->after('Zahlungen erstellt')
->failed('Fehler beim Erstellen von Zahlungen')
->shouldReload(JobChannels::make()->add('invoice'));
}
}

View File

@ -17,12 +17,4 @@ enum InvoiceStatus: string
{ {
return collect(static::cases())->map(fn ($case) => $case->value); return collect(static::cases())->map(fn ($case) => $case->value);
} }
/**
* @return array<int, array{id: string, name: string}>
*/
public static function forSelect(): array
{
return array_map(fn ($case) => ['id' => $case->value, 'name' => $case->value], static::cases());
}
} }

View File

@ -2,9 +2,6 @@
namespace App\Invoice\Models; namespace App\Invoice\Models;
use App\Invoice\BillKind;
use App\Invoice\Enums\InvoiceStatus;
use App\Member\Member;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
@ -17,13 +14,6 @@ class Invoice extends Model
public $casts = [ public $casts = [
'to' => 'json', 'to' => 'json',
'status' => InvoiceStatus::class,
'via' => BillKind::class,
];
/** @var array<int, string> */
public $dates = [
'sent_at',
]; ];
/** /**
@ -33,26 +23,4 @@ class Invoice extends Model
{ {
return $this->hasMany(InvoicePosition::class); return $this->hasMany(InvoicePosition::class);
} }
public static function createForMember(Member $member): self
{
return static::create([
'to' => [
'name' => 'Familie ' . $member->lastname,
'address' => $member->address,
'zip' => $member->zip,
'location' => $member->location,
],
'greeting' => 'Liebe Familie ' . $member->lastname,
'status' => InvoiceStatus::NEW,
'via' => $member->bill_kind,
]);
}
public static function booted(): void
{
static::deleting(function ($model) {
$model->positions()->delete();
});
}
} }

View File

@ -2,39 +2,12 @@
namespace App\Invoice\Models; namespace App\Invoice\Models;
use App\Member\Member;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class InvoicePosition extends Model class InvoicePosition extends Model
{ {
use HasFactory; use HasFactory;
public $guarded = []; public $guarded = [];
/**
* @return BelongsTo<Member, self>
*/
public function member(): BelongsTo
{
return $this->belongsTo(Member::class);
}
/**
* @return BelongsTo<Invoice, self>
*/
public function invoice(): BelongsTo
{
return $this->belongsTo(Invoice::class);
}
public static function booted(): void
{
static::deleted(function ($model) {
if ($model->invoice->positions()->get()->count() === 0) {
$model->invoice->delete();
}
});
}
} }

View File

@ -1,29 +0,0 @@
<?php
namespace App\Invoice\Resources;
use App\Invoice\Models\InvoicePosition;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin InvoicePosition
*/
class InvoicePositionResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array<string, mixed>
*/
public function toArray($request)
{
return [
'id' => $this->id,
'price' => $this->price,
'member_id' => $this->member_id,
'description' => $this->description,
];
}
}

View File

@ -1,77 +0,0 @@
<?php
namespace App\Invoice\Resources;
use App\Invoice\BillKind;
use App\Invoice\Enums\InvoiceStatus;
use App\Invoice\Models\Invoice;
use App\Lib\HasMeta;
use App\Member\Member;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Invoice
*/
class InvoiceResource extends JsonResource
{
use HasMeta;
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array<string, mixed>
*/
public function toArray($request)
{
return [
'id' => $this->id,
'to' => $this->to,
'sum_human' => number_format($this->positions->sum('price') / 100, 2, ',', '') . ' €',
'sent_at_human' => $this->sent_at?->format('d.m.Y') ?: '',
'status' => $this->status->value,
'via' => $this->via->value,
'positions' => InvoicePositionResource::collection($this->whenLoaded('positions')),
'greeting' => $this->greeting,
'links' => [
'update' => route('invoice.update', ['invoice' => $this->getModel()]),
'destroy' => route('invoice.destroy', ['invoice' => $this->getModel()]),
]
];
}
/**
* @return array<string, mixed>
*/
public static function meta(): array
{
return [
'links' => [
'mass-store' => route('invoice.mass-store'),
'store' => route('invoice.store'),
],
'vias' => BillKind::forSelect(),
'statuses' => InvoiceStatus::forSelect(),
'members' => Member::forSelect(),
'default' => [
'to' => [
'name' => '',
'address' => '',
'zip' => '',
'location' => '',
],
'positions' => [],
'greeting' => '',
'status' => InvoiceStatus::NEW->value,
'via' => null,
],
'default_position' => [
'id' => null,
'price' => 0,
'description' => '',
'member_id' => null,
]
];
}
}

View File

@ -1,40 +0,0 @@
<?php
namespace App\Lib\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class Succeeded implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
final private function __construct(public string $message)
{
}
public static function message(string $message): self
{
return new self($message);
}
public function dispatch(): void
{
event($this);
}
/**
* Get the channels the event should broadcast on.
*
* @return array<int, Channel>
*/
public function broadcastOn()
{
return [
new Channel('jobs'),
];
}
}

View File

@ -17,7 +17,7 @@ trait TracksJob
{ {
$jobId = Str::uuid(); $jobId = Str::uuid();
$jobState = WithJobState::make($jobId); $jobState = WithJobState::make($jobId);
tap($this->jobState(...[$jobState, ...$parameters])->beforeMessage, fn ($beforeMessage) => $beforeMessage && $beforeMessage->dispatch());; $this->jobState(...[$jobState, ...$parameters])->beforeMessage->dispatch();
$parameters[] = $jobId; $parameters[] = $jobId;
static::dispatch(...$parameters); static::dispatch(...$parameters);
} }

View File

@ -8,7 +8,6 @@ use App\Course\Models\CourseMember;
use App\Gender; use App\Gender;
use App\Group; use App\Group;
use App\Invoice\BillKind; use App\Invoice\BillKind;
use App\Invoice\Models\InvoicePosition;
use App\Nami\HasNamiField; use App\Nami\HasNamiField;
use App\Nationality; use App\Nationality;
use App\Payment\Payment; use App\Payment\Payment;
@ -214,14 +213,6 @@ class Member extends Model implements Geolocatable
]); ]);
} }
/**
* @return HasMany<InvoicePosition>
*/
public function invoicePositions(): HasMany
{
return $this->hasMany(InvoicePosition::class);
}
/** /**
* @return BelongsTo<Confession, self> * @return BelongsTo<Confession, self>
*/ */
@ -300,9 +291,6 @@ class Member extends Model implements Geolocatable
$model->payments->each->delete(); $model->payments->each->delete();
$model->memberships->each->delete(); $model->memberships->each->delete();
$model->courses->each->delete(); $model->courses->each->delete();
$model->invoicePositions->each(function ($position) {
$position->delete();
});
}); });
static::saving(fn ($model) => $model->updateSearch()); static::saving(fn ($model) => $model->updateSearch());
@ -369,6 +357,18 @@ class Member extends Model implements Geolocatable
return $query->where('bill_kind', '!=', null)->where('subscription_id', '!=', null); return $query->where('bill_kind', '!=', null)->where('subscription_id', '!=', null);
} }
/**
* @param Builder<self> $query
*
* @return Builder<self>
*/
public function scopeWhereNoPayment(Builder $query, int $year): Builder
{
return $query->whereDoesntHave('payments', function (Builder $q) use ($year) {
$q->where('nr', '=', $year);
});
}
/** /**
* @param Builder<self> $query * @param Builder<self> $query
* *
@ -506,14 +506,6 @@ class Member extends Model implements Geolocatable
])->implode(' '); ])->implode(' ');
} }
/**
* @return array<int, array{id: int, name: string}>
*/
public static function forSelect(): array
{
return static::select(['id', 'firstname', 'lastname'])->get()->map(fn ($member) => ['id' => $member->id, 'name' => $member->fullname])->toArray();
}
// -------------------------------- Geolocation -------------------------------- // -------------------------------- Geolocation --------------------------------
// ***************************************************************************** // *****************************************************************************
public function fillCoordinate(Coordinate $coordinate): void public function fillCoordinate(Coordinate $coordinate): void

View File

@ -157,6 +157,7 @@ class MemberResource extends JsonResource
'links' => [ 'links' => [
'index' => route('member.index'), 'index' => route('member.index'),
'create' => route('member.create'), 'create' => route('member.create'),
'allpayment' => route('allpayment.page'),
'sendpayment' => route('sendpayment.create'), 'sendpayment' => route('sendpayment.create'),
], ],
]; ];

View File

@ -0,0 +1,28 @@
<?php
namespace App\Payment\Actions;
use Inertia;
use Inertia\Response;
use Lorisleiva\Actions\Concerns\AsAction;
class AllpaymentPageAction
{
use AsAction;
/**
* @return array<string, string>
*/
public function handle(): array
{
return [];
}
public function asController(): Response
{
session()->put('menu', 'member');
session()->put('title', 'Rechnungen erstellen');
return Inertia::render('allpayment/VForm', $this->handle());
}
}

View File

@ -0,0 +1,83 @@
<?php
namespace App\Payment\Actions;
use App\Member\Member;
use App\Member\Membership;
use App\Payment\Status;
use App\Payment\Subscription;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\RedirectResponse;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
class AllpaymentStoreAction
{
use AsAction;
/**
* @return array<string, string>
*/
public function rules(): array
{
return [
'year' => 'required|numeric',
'for_promise' => 'present|boolean',
];
}
public function handle(int $year, bool $forPromise): void
{
foreach (Member::payable()->whereNoPayment($year)->get() as $member) {
$member->createPayment([
'nr' => $year,
'subscription_id' => $member->subscription_id,
'status_id' => Status::default(),
]);
if (!$forPromise) {
continue;
}
$this->createPaymentsForPromise($member, $year);
}
}
private function createPaymentsForPromise(Member $member, int $year): void
{
$subscription = Subscription::firstWhere('for_promise', true);
if (is_null($subscription)) {
return;
}
foreach ($this->promisedMemberships($member, $year) as $membership) {
$attributes = [
'nr' => $membership->subactivity->name.' '.$membership->promised_at->year,
'subscription_id' => $subscription->id,
];
if (!$member->payments()->where($attributes)->exists()) {
$member->createPayment([
...$attributes,
'status_id' => Status::default(),
]);
}
}
}
/**
* @return Collection<int, Membership>
*/
public function promisedMemberships(Member $member, int $year): Collection
{
return $member->memberships()->whereNotNull('promised_at')->whereYear('promised_at', now()->year($year)->subYear())->get();
}
public function asController(ActionRequest $request): RedirectResponse
{
$this->handle($request->year, $request->for_promise);
return redirect()->back()->success('Zahlungen erstellt');
}
}

View File

@ -6,9 +6,6 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
/**
* @deprecated
*/
class Status extends Model class Status extends Model
{ {
use HasFactory; use HasFactory;

View File

@ -1,53 +0,0 @@
<?php
namespace Database\Factories\Invoice\Models;
use App\Invoice\BillKind;
use App\Invoice\Enums\InvoiceStatus;
use App\Invoice\Models\Invoice;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\Factory;
use Tests\Feature\Invoice\ReceiverRequestFactory;
/**
* @extends Factory<Invoice>
*/
class InvoiceFactory extends Factory
{
protected $model = Invoice::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition()
{
return [
'greeting' => $this->faker->words(4, true),
'to' => ReceiverRequestFactory::new()->create(),
'status' => InvoiceStatus::NEW->value,
'via' => BillKind::POST->value
];
}
public function to(ReceiverRequestFactory $to): self
{
return $this->state(['to' => $to->create()]);
}
public function sentAt(Carbon $sentAt): self
{
return $this->state(['sent_at' => $sentAt]);
}
public function status(InvoiceStatus $status): self
{
return $this->state(['status' => $status->value]);
}
public function via(BillKind $via): self
{
return $this->state(['via' => $via->value]);
}
}

View File

@ -1,34 +0,0 @@
<?php
namespace Database\Factories\Invoice\Models;
use App\Invoice\Models\InvoicePosition;
use App\Member\Member;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<InvoicePosition>
*/
class InvoicePositionFactory extends Factory
{
protected $model = InvoicePosition::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition()
{
return [
'description' => $this->faker->words(4, true),
'member_id' => Member::factory()->defaults()->create()->id,
'price' => $this->faker->numberBetween(1000, 2000),
];
}
public function price(int $price): self
{
return $this->state(['price' => $price]);
}
}

View File

@ -95,15 +95,4 @@ class MemberFactory extends Factory
} }
}); });
} }
public function sameFamilyAs(Member $member): self
{
return $this->state([
'firstname' => $member->firstname . 'a',
'lastname' => $member->lastname,
'address' => $member->address,
'zip' => $member->zip,
'location' => $member->location,
]);
}
} }

View File

@ -17,9 +17,9 @@ return new class extends Migration
$table->id(); $table->id();
$table->json('to'); $table->json('to');
$table->string('greeting'); $table->string('greeting');
$table->text('intro');
$table->text('outro');
$table->string('status'); $table->string('status');
$table->date('sent_at')->nullable();
$table->string('via');
$table->timestamps(); $table->timestamps();
}); });

View File

@ -4,9 +4,6 @@ includes:
parameters: parameters:
stubFiles:
- tests/stub/phpstan/TestResponse.stub
paths: paths:
- app - app
- tests - tests

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512.018 512.018"><path d="M6.98 256.673c-5.504 2.027-8.341 8.128-6.336 13.653l6.699 18.432L58.35 237.75zM55.257 420.513l30.72 84.48a10.57 10.57 0 0 0 5.525 6.016 10.73 10.73 0 0 0 4.501 1.003c1.259 0 2.496-.213 3.691-.661l33.899-12.501zM511.364 348.385l-35.157-96.661-84.373 84.373 41.813-15.403c5.483-2.091 11.669.768 13.696 6.315 2.048 5.525-.789 11.669-6.315 13.696l-53.12 19.584a10.617 10.617 0 0 1-3.691.661c-4.331 0-8.427-2.667-10.005-6.976-.021-.064 0-.128-.021-.192l-89.408 89.408 220.245-81.152c5.525-2.026 8.362-8.128 6.336-13.653M508.889 173.793 338.222 3.126c-4.16-4.16-10.923-4.16-15.083 0l-320 320c-4.16 4.16-4.16 10.923 0 15.083l170.667 170.667a10.56 10.56 0 0 0 7.531 3.136c2.731 0 5.461-1.045 7.552-3.115l320-320a10.7 10.7 0 0 0 0-15.104m-384 121.771L82.222 338.23a10.716 10.716 0 0 1-15.104 0c-4.16-4.16-4.16-10.923 0-15.083l42.667-42.667c4.16-4.16 10.923-4.16 15.083 0 4.16 4.161 4.181 10.902.021 15.084m184.213 13.546c-7.552 7.552-17.813 11.179-29.227 11.179-18.859 0-40.896-9.877-59.328-28.331-13.483-13.483-22.955-29.611-26.645-45.397-4.096-17.6-.725-32.917 9.493-43.157 10.219-10.24 25.536-13.611 43.157-9.493 15.787 3.691 31.915 13.141 45.397 26.645 29.633 29.61 37.185 68.522 17.153 88.554m135.787-120.213-42.667 42.667a10.716 10.716 0 0 1-15.104 0c-4.16-4.16-4.16-10.923 0-15.083l42.667-42.667c4.16-4.16 10.923-4.16 15.083 0s4.181 10.902.021 15.083"/></svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -1,10 +1,10 @@
<template> <template>
<div class="fixed z-40 top-0 left-0 w-full h-full flex items-center justify-center p-6"> <div class="fixed z-40 top-0 left-0 w-full h-full flex items-center justify-center p-6">
<div class="relative rounded-lg p-8 bg-zinc-800 shadow-2xl shadow-black border border-zinc-700 border-solid w-full" :class="innerWidth"> <div class="relative rounded-lg p-8 bg-zinc-800 shadow-2xl shadow-black border border-zinc-700 border-solid w-full max-w-xl">
<a href="#" class="absolute top-0 right-0 mt-6 mr-6" @click.prevent="$emit('close')"> <a href="#" class="absolute top-0 right-0 mt-6 mr-6" @click.prevent="$emit('close')">
<ui-sprite src="close" class="text-zinc-400 w-6 h-6"></ui-sprite> <ui-sprite src="close" class="text-zinc-400 w-6 h-6"></ui-sprite>
</a> </a>
<h3 v-if="heading" class="font-semibold text-primary-200 text-xl" v-html="heading"></h3> <h3 class="font-semibold text-primary-200 text-xl" v-html="heading"></h3>
<div class="text-primary-100"> <div class="text-primary-100">
<slot></slot> <slot></slot>
</div> </div>
@ -15,14 +15,7 @@
<script> <script>
export default { export default {
props: { props: {
heading: { heading: {},
type: String,
default: () => '',
},
innerWidth: {
default: () => 'max-w-xl',
type: String,
},
}, },
}; };
</script> </script>

View File

@ -1,9 +1,8 @@
import {ref, inject, computed, onBeforeUnmount} from 'vue'; import {ref, computed, onBeforeUnmount} from 'vue';
import {router} from '@inertiajs/vue3'; import {router} from '@inertiajs/vue3';
import useQueueEvents from './useQueueEvents.js'; import useQueueEvents from './useQueueEvents.js';
export function useIndex(props, siteName) { export function useIndex(props, siteName) {
const axios = inject('axios');
const {startListener, stopListener} = useQueueEvents(siteName, () => reload(false)); const {startListener, stopListener} = useQueueEvents(siteName, () => reload(false));
const rawProps = JSON.parse(JSON.stringify(props)); const rawProps = JSON.parse(JSON.stringify(props));
const inner = { const inner = {
@ -57,6 +56,20 @@ export function useIndex(props, siteName) {
reload(true); reload(true);
} }
function requestCallback(successMessage, failureMessage) {
return {
onSuccess: () => {
this.$success(successMessage);
reload(false);
},
onFailure: () => {
this.$error(failureMessage);
reload(false);
},
preserveState: true,
};
}
startListener(); startListener();
onBeforeUnmount(() => stopListener()); onBeforeUnmount(() => stopListener());
@ -66,12 +79,12 @@ export function useIndex(props, siteName) {
can, can,
getFilter, getFilter,
setFilter, setFilter,
requestCallback,
meta: inner.meta, meta: inner.meta,
filterString, filterString,
router, router,
toFilterString, toFilterString,
reloadPage, reloadPage,
axios,
}; };
} }

View File

@ -1,108 +0,0 @@
import {computed, ref, inject, onBeforeUnmount} from 'vue';
import {router} from '@inertiajs/vue3';
import useQueueEvents from './useQueueEvents.js';
export function useIndex(props, siteName) {
const axios = inject('axios');
const {startListener, stopListener} = useQueueEvents(siteName, () => reload(false));
const single = ref(null);
const rawProps = JSON.parse(JSON.stringify(props));
const inner = {
data: ref(rawProps.data),
meta: ref(rawProps.meta),
};
function toFilterString(data) {
return btoa(encodeURIComponent(JSON.stringify(data)));
}
const filterString = computed(() => toFilterString(inner.meta.value.filter));
function reload(resetPage = true, withMeta = true, data) {
data = {
filter: filterString.value,
page: resetPage ? 1 : inner.meta.value.current_page,
...data,
};
router.visit(window.location.pathname, {
data,
preserveState: true,
only: ['data'],
onSuccess: (page) => {
inner.data.value = page.props.data.data;
if (withMeta) {
inner.meta.value = {
...inner.meta.value,
...page.props.data.meta,
};
}
},
});
}
function reloadPage(page) {
reload(false, true, {page: page});
}
function can(permission) {
return inner.meta.value.can[permission];
}
function create() {
single.value = JSON.parse(JSON.stringify(inner.meta.value.default));
}
function edit(model) {
single.value = JSON.parse(JSON.stringify(model));
}
async function submit() {
single.value.id ? await axios.patch(single.value.links.update, single.value) : await axios.post(inner.meta.value.links.store, single.value);
reload();
single.value = null;
}
async function remove(model) {
await axios.delete(model.links.destroy);
reload();
}
function can(permission) {
return inner.meta.value.can[permission];
}
function cancel() {
single.value = null;
}
startListener();
onBeforeUnmount(() => stopListener());
return {
data: inner.data,
meta: inner.meta,
single,
create,
edit,
reload,
reloadPage,
can,
router,
submit,
remove,
cancel,
axios,
};
}
const indexProps = {
data: {
default: () => {
return {data: [], meta: {}};
},
type: Object,
},
};
export {indexProps};

View File

@ -11,7 +11,6 @@
<v-link href="/" menu="dashboard" icon="loss">Dashboard</v-link> <v-link href="/" menu="dashboard" icon="loss">Dashboard</v-link>
<v-link href="/member" menu="member" icon="user">Mitglieder</v-link> <v-link href="/member" menu="member" icon="user">Mitglieder</v-link>
<v-link v-show="hasModule('bill')" href="/subscription" menu="subscription" icon="money">Beiträge</v-link> <v-link v-show="hasModule('bill')" href="/subscription" menu="subscription" icon="money">Beiträge</v-link>
<v-link v-show="hasModule('bill')" href="/invoice" menu="invoice" icon="moneypaper">Rechnungen</v-link>
<v-link href="/contribution" menu="contribution" icon="contribution">Zuschüsse</v-link> <v-link href="/contribution" menu="contribution" icon="contribution">Zuschüsse</v-link>
<v-link href="/activity" menu="activity" icon="activity">Tätigkeiten</v-link> <v-link href="/activity" menu="activity" icon="activity">Tätigkeiten</v-link>
<v-link href="/group" menu="group" icon="group">Gruppen</v-link> <v-link href="/group" menu="group" icon="group">Gruppen</v-link>

View File

@ -0,0 +1,31 @@
<template>
<page-layout>
<form class="p-6 grid gap-4 justify-start" @submit.prevent="submit">
<f-text id="year" v-model="inner.year" label="Jahr" required></f-text>
<f-switch id="for_promise" label="Versprechen einbeziehen" v-model="inner.for_promise" size="sm"></f-switch>
<button type="submit" class="btn btn-primary">Absenden</button>
</form>
</page-layout>
</template>
<script>
export default {
data: function () {
return {
inner: {
for_promise: false,
},
};
},
props: {},
methods: {
submit() {
this.$inertia.post(`/allpayment`, this.inner);
},
},
};
</script>

View File

@ -1,123 +0,0 @@
<template>
<page-layout>
<template #toolbar>
<page-toolbar-button color="primary" icon="plus" @click="create">Rechnung anlegen</page-toolbar-button>
<page-toolbar-button color="primary" icon="plus" @click="massstore = { year: '' }">Massenrechnung
anlegen</page-toolbar-button>
</template>
<ui-popup v-if="massstore !== null" heading="Massenrechnung anlegen" @close="massstore = null">
<form @submit.prevent="sendMassstore">
<section class="grid grid-cols-2 gap-3 mt-6">
<f-text id="year" v-model="massstore.year" name="year" label="Jahr" required></f-text>
</section>
<section class="flex mt-4 space-x-2">
<ui-button type="submit" class="btn-danger">Speichern</ui-button>
<ui-button class="btn-primary" @click.prevent="massstore = null">Abbrechen</ui-button>
</section>
</form>
</ui-popup>
<ui-popup v-if="deleting !== null" heading="Rechnung löschen?" @close="deleting = null">
<div>
<p class="mt-4">Diese Rechnung löschen?</p>
<div class="grid grid-cols-2 gap-3 mt-6">
<a href="#" class="text-center btn btn-danger" @click.prevent="
remove(deleting);
deleting = null;
">Rechnung löschen</a>
<a href="#" class="text-center btn btn-primary" @click.prevent="deleting = null">Abbrechen</a>
</div>
</div>
</ui-popup>
<ui-popup v-if="single !== null" :heading="`Rechnung ${single.id ? 'bearbeiten' : 'erstellen'}`"
inner-width="max-w-4xl" @close="cancel">
<form class="grid grid-cols-2 gap-3 mt-4" @submit.prevent="submit">
<ui-box heading="Empfänger" container-class="grid grid-cols-2 gap-3">
<f-text id="to_name" v-model="single.to.name" name="to_name" label="Name" class="col-span-full"
required></f-text>
<f-text id="to_address" v-model="single.to.address" name="to_address" class="col-span-full"
label="Adresse" required></f-text>
<f-text id="to_zip" v-model="single.to.zip" name="to_zip" label="PLZ" required></f-text>
<f-text id="to_location" v-model="single.to.location" name="to_location" label="Ort" required></f-text>
</ui-box>
<ui-box heading="Status" container-class="grid gap-3">
<f-select id="status" v-model="single.status" :options="meta.statuses" name="status" label="Status"
required></f-select>
<f-select id="via" v-model="single.via" :options="meta.vias" name="via" label="Rechnungsweg"
required></f-select>
<f-text id="greeting" v-model="single.greeting" name="greeting" label="Anrede" required></f-text>
</ui-box>
<ui-box heading="Positionen" class="col-span-full" container-class="grid gap-3">
<template #in-title>
<ui-icon-button class="ml-3 btn-primary" icon="plus"
@click="single.positions.push({ ...meta.default_position })">Neu</ui-icon-button>
</template>
<div v-for="(position, index) in single.positions" :key="index" class="flex items-end space-x-3">
<f-text :id="`position-description-${index}`" v-model="position.description" class="grow"
:name="`position-description-${index}`" label="Beschreibung" required></f-text>
<f-text :id="`position-price-${index}`" v-model="position.price" mode="area"
:name="`position-price-${index}`" label="Preis" required></f-text>
<f-select :id="`position-member-${index}`" v-model="position.member_id" :options="meta.members"
:name="`position-member-${index}`" label="Mitglied" required></f-select>
<button type="button" class="btn btn-danger btn-sm h-[35px]" icon="trash"
@click="single.positions.splice(index, 1)"><ui-sprite src="trash"></ui-sprite></button>
</div>
</ui-box>
<section class="flex mt-4 space-x-2">
<ui-button type="submit" class="btn-danger">Speichern</ui-button>
<ui-button class="btn-primary" @click.prevent="cancel">Abbrechen</ui-button>
</section>
</form>
</ui-popup>
<table cellspacing="0" cellpadding="0" border="0" class="custom-table custom-table-sm">
<thead>
<th>Empfänger</th>
<th>Gesamtbetrag</th>
<th>Status</th>
<th>Gesendet am</th>
<th>Rechnungsweg</th>
<th></th>
</thead>
<tr v-for="(invoice, index) in data" :key="index">
<td>
<div v-text="invoice.to.name"></div>
</td>
<td>
<div v-text="invoice.sum_human"></div>
</td>
<td>
<div v-text="invoice.status"></div>
</td>
<td>
<div v-text="invoice.sent_at_human"></div>
</td>
<td>
<div v-text="invoice.via"></div>
</td>
<td>
<a v-tooltip="`Bearbeiten`" href="#" class="inline-flex btn btn-warning btn-sm"
@click.prevent="edit(invoice)"><ui-sprite src="pencil"></ui-sprite></a>
<a v-tooltip="`Löschen`" href="#" class="ml-2 inline-flex btn btn-danger btn-sm"
@click.prevent="deleting = invoice"><ui-sprite src="trash"></ui-sprite></a>
</td>
</tr>
</table>
<div class="px-6">
<ui-pagination class="mt-4" :value="meta" @reload="reloadPage"></ui-pagination>
</div>
</page-layout>
</template>
<script setup>
import { ref } from 'vue';
import { indexProps, useIndex } from '../../composables/useInertiaApiIndex.js';
const props = defineProps(indexProps);
var { axios, meta, data, reloadPage, create, single, edit, cancel, submit, remove } = useIndex(props.data, 'invoice');
const massstore = ref(null);
const deleting = ref(null);
async function sendMassstore() {
await axios.post(meta.value.links['mass-store'], massstore.value);
massstore.value = null;
}
</script>

View File

@ -25,10 +25,6 @@ use App\Initialize\Actions\InitializeFormAction;
use App\Initialize\Actions\NamiGetSearchLayerAction; use App\Initialize\Actions\NamiGetSearchLayerAction;
use App\Initialize\Actions\NamiLoginCheckAction; use App\Initialize\Actions\NamiLoginCheckAction;
use App\Initialize\Actions\NamiSearchAction; use App\Initialize\Actions\NamiSearchAction;
use App\Invoice\Actions\InvoiceDestroyAction;
use App\Invoice\Actions\InvoiceIndexAction;
use App\Invoice\Actions\InvoiceUpdateAction;
use App\Invoice\Actions\MassStoreAction;
use App\Maildispatcher\Actions\CreateAction; use App\Maildispatcher\Actions\CreateAction;
use App\Maildispatcher\Actions\DestroyAction; use App\Maildispatcher\Actions\DestroyAction;
use App\Maildispatcher\Actions\EditAction; use App\Maildispatcher\Actions\EditAction;
@ -49,6 +45,8 @@ use App\Membership\Actions\MembershipDestroyAction;
use App\Membership\Actions\MembershipStoreAction; use App\Membership\Actions\MembershipStoreAction;
use App\Membership\Actions\MembershipUpdateAction; use App\Membership\Actions\MembershipUpdateAction;
use App\Membership\Actions\StoreForGroupAction; use App\Membership\Actions\StoreForGroupAction;
use App\Payment\Actions\AllpaymentPageAction;
use App\Payment\Actions\AllpaymentStoreAction;
use App\Payment\Actions\DisplayPdfAction; use App\Payment\Actions\DisplayPdfAction;
use App\Payment\Actions\IndexAction as PaymentIndexAction; use App\Payment\Actions\IndexAction as PaymentIndexAction;
use App\Payment\Actions\PaymentDestroyAction; use App\Payment\Actions\PaymentDestroyAction;
@ -71,6 +69,8 @@ Route::group(['middleware' => 'auth:web'], function (): void {
Route::resource('member', MemberController::class)->except('show', 'destroy'); Route::resource('member', MemberController::class)->except('show', 'destroy');
Route::delete('/member/{member}', MemberDeleteAction::class); Route::delete('/member/{member}', MemberDeleteAction::class);
Route::get('/member/{member}', MemberShowAction::class)->name('member.show'); Route::get('/member/{member}', MemberShowAction::class)->name('member.show');
Route::get('allpayment', AllpaymentPageAction::class)->name('allpayment.page');
Route::post('allpayment', AllpaymentStoreAction::class)->name('allpayment.store');
Route::resource('subscription', SubscriptionController::class); Route::resource('subscription', SubscriptionController::class);
Route::get('/sendpayment', [SendpaymentController::class, 'create'])->name('sendpayment.create'); Route::get('/sendpayment', [SendpaymentController::class, 'create'])->name('sendpayment.create');
Route::get('/sendpayment/pdf', [SendpaymentController::class, 'send'])->name('sendpayment.pdf'); Route::get('/sendpayment/pdf', [SendpaymentController::class, 'send'])->name('sendpayment.pdf');
@ -113,14 +113,8 @@ Route::group(['middleware' => 'auth:web'], function (): void {
Route::patch('/payment/{payment}', PaymentUpdateAction::class)->name('payment.update'); Route::patch('/payment/{payment}', PaymentUpdateAction::class)->name('payment.update');
Route::delete('/payment/{payment}', PaymentDestroyAction::class)->name('payment.destroy'); Route::delete('/payment/{payment}', PaymentDestroyAction::class)->name('payment.destroy');
// -------------------------------- allpayment ---------------------------------
Route::post('/invoice/mass-store', MassStoreAction::class)->name('invoice.mass-store');
// ---------------------------------- invoice ---------------------------------- // ---------------------------------- invoice ----------------------------------
Route::get('/invoice', InvoiceIndexAction::class)->name('invoice.index');
Route::post('/invoice', InvoiceStoreAction::class)->name('invoice.store'); Route::post('/invoice', InvoiceStoreAction::class)->name('invoice.store');
Route::patch('/invoice/{invoice}', InvoiceUpdateAction::class)->name('invoice.update');
Route::delete('/invoice/{invoice}', InvoiceDestroyAction::class)->name('invoice.destroy');
// --------------------------------- membership -------------------------------- // --------------------------------- membership --------------------------------
Route::get('/member/{member}/membership', MembershipIndexAction::class)->name('member.membership.index'); Route::get('/member/{member}/membership', MembershipIndexAction::class)->name('member.membership.index');

View File

@ -4,16 +4,19 @@ namespace Tests\Feature\Invoice;
use App\Invoice\BillDocument; use App\Invoice\BillDocument;
use App\Invoice\BillKind; use App\Invoice\BillKind;
use App\Invoice\DocumentFactory;
use App\Invoice\Invoice; use App\Invoice\Invoice;
use App\Invoice\InvoiceSettings; use App\Invoice\InvoiceSettings;
use App\Invoice\Queries\BillKindQuery; use App\Invoice\Queries\BillKindQuery;
use App\Invoice\Queries\InvoiceMemberQuery; use App\Invoice\Queries\InvoiceMemberQuery;
use App\Invoice\Queries\SingleMemberQuery;
use App\Invoice\RememberDocument; use App\Invoice\RememberDocument;
use App\Member\Member; use App\Member\Member;
use App\Payment\Payment; use App\Payment\Payment;
use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\RequestFactories\Child; use Tests\RequestFactories\Child;
use Tests\TestCase; use Tests\TestCase;
use Zoomyboy\Tex\Tex;
class BillRememberDocumentTest extends TestCase class BillRememberDocumentTest extends TestCase
{ {

View File

@ -1,24 +0,0 @@
<?php
namespace Tests\Feature\Invoice;
use App\Invoice\Models\Invoice;
use App\Invoice\Models\InvoicePosition;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;
class InvoiceDestroyActionTest extends TestCase
{
use DatabaseTransactions;
public function testItDestroysInvoice(): void
{
$this->login()->loginNami()->withoutExceptionHandling();
$invoice = Invoice::factory()->has(InvoicePosition::factory(), 'positions')->create();
$this->delete(route('invoice.destroy', ['invoice' => $invoice]))->assertOk();
$this->assertDatabaseCount('invoices', 0);
$this->assertDatabaseCount('invoice_positions', 0);
}
}

View File

@ -1,78 +0,0 @@
<?php
namespace Tests\Feature\Invoice;
use App\Invoice\BillKind;
use App\Invoice\Enums\InvoiceStatus;
use App\Invoice\Models\Invoice;
use App\Invoice\Models\InvoicePosition;
use App\Member\Member;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;
class InvoiceIndexActionTest extends TestCase
{
use DatabaseTransactions;
public function testItDisplaysInvoices(): void
{
$this->login()->loginNami()->withoutExceptionHandling();
$member = Member::factory()->defaults()->create(['firstname' => 'Aaaa', 'lastname' => 'Aaab']);
$invoice = Invoice::factory()
->has(InvoicePosition::factory()->price(1100)->for($member)->state(['description' => 'lala']), 'positions')
->has(InvoicePosition::factory()->price(2200), 'positions')
->to(ReceiverRequestFactory::new()->name('Familie Blabla'))
->sentAt(now()->subDay())
->via(BillKind::POST)
->status(InvoiceStatus::SENT)
->create();
$this->get(route('invoice.index'))
->assertInertiaPath('data.data.0.to.name', 'Familie Blabla')
->assertInertiaPath('data.data.0.id', $invoice->id)
->assertInertiaPath('data.data.0.sum_human', '33,00 €')
->assertInertiaPath('data.data.0.sent_at_human', now()->subDay()->format('d.m.Y'))
->assertInertiaPath('data.data.0.status', 'Rechnung gestellt')
->assertInertiaPath('data.data.0.via', 'Post')
->assertInertiaPath('data.data.0.greeting', $invoice->greeting)
->assertInertiaPath('data.data.0.positions.0.price', 1100)
->assertInertiaPath('data.data.0.positions.0.member_id', $member->id)
->assertInertiaPath('data.data.0.positions.0.description', 'lala')
->assertInertiaPath('data.data.0.positions.0.id', $invoice->positions->first()->id)
->assertInertiaPath('data.data.0.links.update', route('invoice.update', ['invoice' => $invoice]))
->assertInertiaPath('data.data.0.links.destroy', route('invoice.destroy', ['invoice' => $invoice]))
->assertInertiaPath('data.meta.links.mass-store', route('invoice.mass-store'))
->assertInertiaPath('data.meta.links.store', route('invoice.store'))
->assertInertiaPath('data.meta.vias.0', ['id' => 'E-Mail', 'name' => 'E-Mail'])
->assertInertiaPath('data.meta.statuses.0', ['id' => 'Neu', 'name' => 'Neu'])
->assertInertiaPath('data.meta.members.0', ['id' => $member->id, 'name' => 'Aaaa Aaab'])
->assertInertiaPath('data.meta.default', [
'to' => [
'name' => '',
'address' => '',
'zip' => '',
'location' => '',
],
'positions' => [],
'greeting' => '',
'status' => InvoiceStatus::NEW->value,
'via' => null,
])
->assertInertiaPath('data.meta.default_position', [
'id' => null,
'price' => 0,
'description' => '',
'member_id' => null,
]);
}
public function testValuesCanBeNull(): void
{
$this->login()->loginNami()->withoutExceptionHandling();
Invoice::factory()->create();
$this->get(route('invoice.index'))
->assertInertiaPath('data.data.0.sent_at_human', '');
}
}

View File

@ -12,8 +12,6 @@ class InvoicePositionRequestFactory extends RequestFactory
return [ return [
'description' => 'Beitrag Abc', 'description' => 'Beitrag Abc',
'price' => 3250, 'price' => 3250,
'member_id' => Member::factory()->defaults()->create()->id,
'id' => null,
]; ];
} }
@ -31,9 +29,4 @@ class InvoicePositionRequestFactory extends RequestFactory
{ {
return $this->state(['member_id' => $member->id]); return $this->state(['member_id' => $member->id]);
} }
public function id(int $id): self
{
return $this->state(['id' => $id]);
}
} }

View File

@ -2,22 +2,19 @@
namespace Tests\Feature\Invoice; namespace Tests\Feature\Invoice;
use App\Invoice\BillKind;
use App\Invoice\Enums\InvoiceStatus; use App\Invoice\Enums\InvoiceStatus;
use App\Member\Member;
use Worksome\RequestFactories\RequestFactory; use Worksome\RequestFactories\RequestFactory;
class InvoiceRequestFactory extends RequestFactory class InvoiceRequestFactory extends RequestFactory
{ {
/** @var array<int, InvoicePositionRequestFactory> */
public $positions = [];
public function definition(): array public function definition(): array
{ {
return [ return [
'to' => ReceiverRequestFactory::new(), 'to' => ReceiverRequestFactory::new(),
'greeting' => 'Hallo Familie', 'greeting' => 'Hallo Familie',
'status' => InvoiceStatus::NEW->value, 'intro' => 'Hiermit stellen wir ihnen den Beitrag in Rechnung.',
'via' => BillKind::EMAIL->value, 'outro' => 'Das ist die Rechnung',
'positions' => [] 'positions' => []
]; ];
} }
@ -34,21 +31,8 @@ class InvoiceRequestFactory extends RequestFactory
public function position(InvoicePositionRequestFactory $factory): self public function position(InvoicePositionRequestFactory $factory): self
{ {
$this->positions[] = $factory; return $this->state(['positions' => [
$factory->create(),
return $this; ]]);
}
public function create(array $attributes = []): array
{
return parent::create([
'positions' => array_map(fn ($position) => $position->create(), $this->positions),
...$attributes,
]);
}
public function via(BillKind $via): self
{
return $this->state(['via' => $via->value]);
} }
} }

View File

@ -2,7 +2,6 @@
namespace Tests\Feature\Invoice; namespace Tests\Feature\Invoice;
use App\Invoice\BillKind;
use App\Invoice\Enums\InvoiceStatus; use App\Invoice\Enums\InvoiceStatus;
use App\Invoice\Models\Invoice; use App\Invoice\Models\Invoice;
use App\Member\Member; use App\Member\Member;
@ -24,19 +23,21 @@ class InvoiceStoreActionTest extends TestCase
route('invoice.store'), route('invoice.store'),
InvoiceRequestFactory::new() InvoiceRequestFactory::new()
->to(ReceiverRequestFactory::new()->name('Familie Blabla')->address('Musterstr 44')->zip('22222')->location('Solingen')) ->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) ->status(InvoiceStatus::PAID)
->via(BillKind::POST)
->state([ ->state([
'greeting' => 'Hallo Familie', 'greeting' => 'Hallo Familie',
'intro' => 'Hiermit stellen wir ihnen den Beitrag in Rechnung.',
'outro' => 'Das ist die Rechnung',
]) ])
->position(InvoicePositionRequestFactory::new()->description('Beitrag Abc')->price(3250)->member($member))
->create() ->create()
); );
$response->assertOk(); $response->assertOk();
$this->assertDatabaseHas('invoices', [ $this->assertDatabaseHas('invoices', [
'greeting' => 'Hallo Familie', 'greeting' => 'Hallo Familie',
'via' => BillKind::POST->value, 'intro' => 'Hiermit stellen wir ihnen den Beitrag in Rechnung.',
'outro' => 'Das ist die Rechnung',
'status' => InvoiceStatus::PAID->value, 'status' => InvoiceStatus::PAID->value,
]); ]);
$invoice = Invoice::firstWhere('greeting', 'Hallo Familie'); $invoice = Invoice::firstWhere('greeting', 'Hallo Familie');
@ -85,16 +86,6 @@ class InvoiceStoreActionTest extends TestCase
['to.zip' => ''], ['to.zip' => ''],
['to.zip' => 'PLZ ist erforderlich.'] ['to.zip' => 'PLZ ist erforderlich.']
]; ];
yield [
['via' => ''],
['via' => 'Rechnungsweg ist erforderlich.']
];
yield [
['via' => 'lala'],
['via' => 'Der gewählte Wert für Rechnungsweg ist ungültig.']
];
} }
/** /**
@ -111,7 +102,6 @@ class InvoiceStoreActionTest extends TestCase
InvoiceRequestFactory::new() InvoiceRequestFactory::new()
->to(ReceiverRequestFactory::new()) ->to(ReceiverRequestFactory::new())
->position(InvoicePositionRequestFactory::new()->member(Member::factory()->defaults()->create())) ->position(InvoicePositionRequestFactory::new()->member(Member::factory()->defaults()->create()))
->via(BillKind::POST)
->state($input) ->state($input)
->create() ->create()
); );

View File

@ -1,165 +0,0 @@
<?php
namespace Tests\Feature\Invoice;
use App\Invoice\BillKind;
use App\Invoice\Enums\InvoiceStatus;
use App\Invoice\Models\Invoice;
use App\Invoice\Models\InvoicePosition;
use App\Member\Member;
use Generator;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;
class InvoiceUpdateActionTest extends TestCase
{
use DatabaseTransactions;
public function testItCanUpdateAnInvoice(): void
{
$this->login()->loginNami()->withoutExceptionHandling();
$invoice = Invoice::factory()->create();
$this->patchJson(
route('invoice.update', ['invoice' => $invoice]),
InvoiceRequestFactory::new()
->to(ReceiverRequestFactory::new()->name('Familie Blabla')->address('Musterstr 44')->zip('22222')->location('Solingen'))
->status(InvoiceStatus::PAID)
->via(BillKind::POST)
->state([
'greeting' => 'Hallo Familie',
])
->create()
)->assertOk();
$this->assertDatabaseCount('invoices', 1);
$this->assertDatabaseHas('invoices', [
'greeting' => 'Hallo Familie',
'via' => BillKind::POST->value,
'status' => InvoiceStatus::PAID->value,
'id' => $invoice->id,
]);
$invoice = Invoice::firstWhere('greeting', 'Hallo Familie');
$this->assertEquals([
'name' => 'Familie Blabla',
'address' => 'Musterstr 44',
'zip' => '22222',
'location' => 'Solingen',
], $invoice->to);
}
public function testItAddsAPosition(): void
{
$this->login()->loginNami()->withoutExceptionHandling();
$invoice = Invoice::factory()->create();
$this->patchJson(
route('invoice.update', ['invoice' => $invoice]),
InvoiceRequestFactory::new()
->position(InvoicePositionRequestFactory::new())
->position(InvoicePositionRequestFactory::new())
->create()
)->assertOk();
$this->assertDatabaseCount('invoice_positions', 2);
}
public function testItUpdatesAPosition(): void
{
$this->login()->loginNami()->withoutExceptionHandling();
$invoice = Invoice::factory()->has(InvoicePosition::factory(), 'positions')->create();
$this->patchJson(
route('invoice.update', ['invoice' => $invoice]),
InvoiceRequestFactory::new()
->position(InvoicePositionRequestFactory::new()->description('la')->id($invoice->positions->first()->id))
->create()
)->assertOk();
$this->assertDatabaseCount('invoice_positions', 1);
$this->assertDatabaseHas('invoice_positions', [
'description' => 'la',
'id' => $invoice->positions->first()->id,
]);
}
public function testItDeletesAPosition(): void
{
$this->login()->loginNami()->withoutExceptionHandling();
$invoice = Invoice::factory()->has(InvoicePosition::factory(), 'positions')->create();
$this->patchJson(
route('invoice.update', ['invoice' => $invoice]),
InvoiceRequestFactory::new()
->create()
)->assertOk();
$this->assertDatabaseCount('invoice_positions', 0);
}
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.']
];
yield [
['via' => ''],
['via' => 'Rechnungsweg ist erforderlich.']
];
yield [
['via' => 'lala'],
['via' => 'Der gewählte Wert für Rechnungsweg ist ungültig.']
];
}
/**
* @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()))
->via(BillKind::POST)
->state($input)
->create()
);
$response->assertJsonValidationErrors($errors);
}
}

View File

@ -1,106 +0,0 @@
<?php
namespace Tests\Feature\Invoice;
use App\Invoice\BillKind;
use App\Invoice\Models\Invoice;
use App\Member\Member;
use App\Payment\Subscription;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\RequestFactories\Child;
use Tests\TestCase;
class MassStoreActionTest extends TestCase
{
use DatabaseTransactions;
public function setUp(): void
{
parent::setUp();
$this->login()->loginNami()->withoutExceptionHandling();
}
public function testItDoesntCreatePaymentsWithoutSubscription(): void
{
Member::factory()->defaults()->emailBillKind()->create(['subscription_id' => null]);
$this->postJson(route('invoice.mass-store'), [
'year' => now()->addYear()->year,
])->assertOk();
$this->assertDatabaseEmpty('invoices');
}
public function testItDoesntCreatePaymentWithoutBillKind(): void
{
Member::factory()->defaults()->create();
$this->postJson(route('invoice.mass-store'), [
'year' => now()->addYear()->year,
])->assertOk();
$this->assertDatabaseEmpty('invoices');
}
public function testItCreatesPayments(): void
{
$member = Member::factory()->defaults()
->for(Subscription::factory()->children([
new Child('beitrag {name}', 4466),
new Child('beitrag2 für {name} für {year}', 2290),
]))->emailBillKind()->create(['firstname' => 'Max', 'lastname' => 'Muster', 'address' => 'Maxstr 4', 'zip' => '33445', 'location' => 'Solingen']);
$this->postJson(route('invoice.mass-store'), [
'year' => now()->addYear()->year,
])->assertOk();
$invoice = Invoice::first();
$this->assertNotNull($invoice);
$this->assertEquals([
'name' => 'Familie Muster',
'address' => 'Maxstr 4',
'zip' => '33445',
'location' => 'Solingen',
], $invoice->to);
$this->assertEquals(BillKind::EMAIL, $invoice->via);
$this->assertDatabaseHas('invoice_positions', [
'invoice_id' => $invoice->id,
'member_id' => $member->id,
'price' => 4466,
'description' => 'beitrag Max Muster'
]);
$this->assertDatabaseHas('invoice_positions', [
'invoice_id' => $invoice->id,
'member_id' => $member->id,
'price' => 2290,
'description' => 'beitrag2 für Max Muster für ' . now()->addYear()->year
]);
}
public function testItCreatesOneInvoiceForFamilyMember(): void
{
$subscription = Subscription::factory()->children([new Child('beitrag {name}', 4466)])->create();
$member = Member::factory()->defaults()->for($subscription)->emailBillKind()->create(['firstname' => 'Max', 'lastname' => 'Muster']);
Member::factory()->defaults()->for($subscription)->sameFamilyAs($member)->emailBillKind()->create(['firstname' => 'Jane']);
$this->postJson(route('invoice.mass-store'), ['year' => now()->addYear()->year])->assertOk();
$this->assertDatabaseCount('invoices', 1);
$this->assertDatabaseCount('invoice_positions', 2);
$this->assertDatabaseHas('invoice_positions', ['description' => 'beitrag Max Muster']);
$this->assertDatabaseHas('invoice_positions', ['description' => 'beitrag Jane Muster']);
}
public function testItSeparatesBillKinds(): void
{
$subscription = Subscription::factory()->children([new Child('beitrag {name]', 4466)])->create();
$member = Member::factory()->defaults()->for($subscription)->emailBillKind()->create();
Member::factory()->defaults()->for($subscription)->sameFamilyAs($member)->postBillKind()->create();
$this->postJson(route('invoice.mass-store'), ['year' => now()->addYear()->year])->assertOk();
$this->assertDatabaseCount('invoices', 2);
$this->assertDatabaseCount('invoice_positions', 2);
}
}

View File

@ -5,8 +5,6 @@ namespace Tests\Feature\Member;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use App\Course\Models\Course; use App\Course\Models\Course;
use App\Course\Models\CourseMember; use App\Course\Models\CourseMember;
use App\Invoice\Models\Invoice;
use App\Invoice\Models\InvoicePosition;
use App\Lib\Events\JobFailed; use App\Lib\Events\JobFailed;
use App\Lib\Events\JobFinished; use App\Lib\Events\JobFinished;
use App\Lib\Events\JobStarted; use App\Lib\Events\JobStarted;
@ -77,37 +75,6 @@ class DeleteTest extends TestCase
$this->assertDatabaseMissing('members', ['id' => $member->id]); $this->assertDatabaseMissing('members', ['id' => $member->id]);
} }
public function testItDeletesInvoicePositions(): void
{
$this->withoutExceptionHandling()->login()->loginNami();
$member = Member::factory()->defaults()->create();
$member2 = Member::factory()->defaults()->create();
Invoice::factory()
->has(InvoicePosition::factory()->for($member), 'positions')
->has(InvoicePosition::factory()->for($member2), 'positions')
->create();
MemberDeleteAction::run($member->id);
$this->assertDatabaseCount('invoices', 1);
$this->assertDatabaseCount('invoice_positions', 1);
}
public function testItDeletesInvoicePositionsAndInvoices(): void
{
$this->withoutExceptionHandling()->login()->loginNami();
$member = Member::factory()->defaults()->create();
Invoice::factory()
->has(InvoicePosition::factory()->for($member), 'positions')
->has(InvoicePosition::factory()->for($member), 'positions')
->create();
MemberDeleteAction::run($member->id);
$this->assertDatabaseCount('invoices', 0);
$this->assertDatabaseCount('invoice_positions', 0);
}
public function testItFiresEventWhenFinished(): void public function testItFiresEventWhenFinished(): void
{ {
Event::fake([JobStarted::class, JobFinished::class]); Event::fake([JobStarted::class, JobFinished::class]);

View File

@ -0,0 +1,145 @@
<?php
namespace Tests\Feature\Payment;
use App\Member\Member;
use App\Member\Membership;
use App\Payment\Payment;
use App\Payment\Status;
use App\Payment\Subscription;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;
class AllpaymentTest extends TestCase
{
use DatabaseTransactions;
public function setUp(): void
{
parent::setUp();
$this->login()->loginNami();
}
public function testItDoesntCreatePaymentsWithoutSubscription(): void
{
$member = Member::factory()->defaults()->emailBillKind()->create();
$member->update(['subscription_id' => null]);
$response = $this->from('/allpayment/create')->post('allpayment', [
'year' => now()->addYear()->year,
]);
$response->assertRedirect('/allpayment/create');
$this->assertEmpty($member->payments()->get());
}
public function testItDoesntCreatePaymentWithoutBillKind(): void
{
$member = Member::factory()->defaults()->create();
$response = $this->from('/allpayment/create')->post('allpayment', [
'year' => now()->addYear()->year,
]);
$response->assertRedirect('/allpayment/create');
$this->assertEmpty($member->payments()->get());
}
public function testItCreatesPayments(): void
{
$member = Member::factory()->defaults()->emailBillKind()->create();
$response = $this->from('/allpayment/create')->post('allpayment', [
'year' => now()->addYear()->year,
'for_promise' => false,
]);
$response->assertRedirect('/allpayment/create');
$this->assertDatabaseHas('payments', [
'member_id' => $member->id,
'nr' => now()->addYear()->year,
'subscription_id' => $member->subscription->id,
'status_id' => Status::first()->id,
]);
}
public function testItCreatesPromisePayments(): void
{
$member = Member::factory()
->defaults()
->emailBillKind()
->has(Membership::factory()->in('€ Mitglied', 123, 'Rover', 124)->promise(now()->subYear()->startOfYear()))
->create();
$subscription = Subscription::factory()->forPromise()->create();
$this->from('/allpayment/create')->post('allpayment', [
'year' => now()->year,
'for_promise' => true,
]);
$this->assertDatabaseHas('payments', [
'member_id' => $member->id,
'nr' => 'Rover '.now()->subYear()->year,
'subscription_id' => $subscription->id,
'status_id' => Status::first()->id,
]);
}
public function testItDoesntCreatePromisePaymentsWhenPromiseIsOver(): void
{
$member = Member::factory()
->defaults()
->emailBillKind()
->has(Membership::factory()->in('€ Mitglied', 123, 'Rover', 124)->promise(now()->subYears(2)->startOfYear()))
->create();
$subscription = Subscription::factory()->forPromise()->create();
$this->from('/allpayment/create')->post('allpayment', [
'year' => now()->year,
'for_promise' => true,
]);
$this->assertDatabaseMissing('payments', [
'subscription_id' => $subscription->id,
]);
}
public function testItDoesntCreatePromisePaymentsWhenUserAlreadyHasPayment(): void
{
$subscription = Subscription::factory()->forPromise()->create();
$member = Member::factory()
->defaults()
->emailBillKind()
->has(Membership::factory()->in('€ Mitglied', 123, 'Rover', 124)->promise(now()->subYear()->startOfYear()))
->has(Payment::factory()->notPaid()->nr('Rover '.now()->subYear()->year)->for($subscription))
->create();
$this->from('/allpayment/create')->post('allpayment', [
'year' => now()->year,
'for_promise' => true,
]);
$this->assertCount(2, $member->payments);
}
public function testItDoesntCreatePromisePaymentsWhenNoSubscriptionFound(): void
{
$member = Member::factory()
->defaults()
->emailBillKind()
->has(Membership::factory()->in('€ Mitglied', 123, 'Rover', 124)->promise(now()->subYear()->startOfYear()))
->has(Payment::factory()->notPaid()->nr('Rover '.now()->subYear()->year))
->create();
$this->from('/allpayment/create')->post('allpayment', [
'year' => now()->year,
'for_promise' => true,
]);
$this->assertCount(2, $member->payments);
}
}

View File

@ -9,10 +9,8 @@ use App\User;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase; use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Testing\AssertableJsonString;
use Illuminate\Testing\TestResponse; use Illuminate\Testing\TestResponse;
use Phake; use Phake;
use PHPUnit\Framework\Assert;
use Symfony\Component\HttpFoundation\File\File; use Symfony\Component\HttpFoundation\File\File;
use Tests\Lib\MakesHttpCalls; use Tests\Lib\MakesHttpCalls;
use Tests\Lib\TestsInertia; use Tests\Lib\TestsInertia;
@ -31,7 +29,6 @@ abstract class TestCase extends BaseTestCase
parent::setUp(); parent::setUp();
Auth::fake(); Auth::fake();
Member::disableGeolocation(); Member::disableGeolocation();
$this->initInertiaTestcase();
} }
public function loginNami(int $mglnr = 12345, string $password = 'password', int|Group $groupId = 55): self public function loginNami(int $mglnr = 12345, string $password = 'password', int|Group $groupId = 55): self
@ -120,17 +117,4 @@ abstract class TestCase extends BaseTestCase
$this->assertCount(1, $output, 'Failed to parse output format of pdfinfo'); $this->assertCount(1, $output, 'Failed to parse output format of pdfinfo');
$this->assertEquals($pageCount, $output[0]); $this->assertEquals($pageCount, $output[0]);
} }
public function initInertiaTestcase(): void
{
TestResponse::macro('assertInertiaPath', function ($path, $value) {
/** @var TestResponse */
$response = $this;
$props = data_get($response->viewData('page'), 'props');
Assert::assertNotNull($props);
$json = new AssertableJsonString($props);
$json->assertPath($path, $value);
return $this;
});
}
} }

View File

@ -1,14 +0,0 @@
<?php
namespace Illuminate\Testing;
use Symfony\Component\HttpFoundation\File\File;
/**
* @method self assertInertiaPath(string $path, string|array<string, mixed>|int $value)
* @method File getFile()
*/
class TestResponse
{
}