Compare commits

..

No commits in common. "ebf856252c12829614df25dc6e15e99b8afa193e" and "20836e7228cd22430409e57c0f66f69a2835783f" have entirely different histories.

122 changed files with 2835 additions and 3381 deletions

View File

@ -4,10 +4,10 @@ namespace App\Dashboard;
use App\Dashboard\Blocks\Block;
use App\Efz\EfzPendingBlock;
use App\Invoice\MemberPaymentBlock;
use App\Member\PsPendingBlock;
use App\Membership\AgeGroupCountBlock;
use App\Membership\TestersBlock;
use App\Payment\MemberPaymentBlock;
class DashboardFactory
{

View File

@ -1,20 +0,0 @@
<?php
namespace App\Invoice\Actions;
use App\Invoice\BillDocument;
use App\Invoice\Models\Invoice;
use Illuminate\Http\Response;
use Lorisleiva\Actions\Concerns\AsAction;
use Zoomyboy\Tex\BaseCompiler;
use Zoomyboy\Tex\Tex;
class DisplayPdfAction
{
use AsAction;
public function handle(Invoice $invoice): BaseCompiler|Response
{
return Tex::compile(BillDocument::fromInvoice($invoice));
}
}

View File

@ -1,20 +0,0 @@
<?php
namespace App\Invoice\Actions;
use App\Invoice\Models\Invoice;
use App\Invoice\RememberDocument;
use Illuminate\Http\Response;
use Lorisleiva\Actions\Concerns\AsAction;
use Zoomyboy\Tex\BaseCompiler;
use Zoomyboy\Tex\Tex;
class DisplayRememberpdfAction
{
use AsAction;
public function handle(Invoice $invoice): BaseCompiler|Response
{
return Tex::compile(RememberDocument::fromInvoice($invoice));
}
}

View File

@ -1,50 +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())],
'usage' => 'required|max:255|string',
'mail_email' => 'nullable|string|max:255|email',
'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',
'usage' => 'Verwendungszweck',
];
}
}

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,13 +2,10 @@
namespace App\Invoice\Actions;
use App\Invoice\BillDocument;
use App\Invoice\BillKind;
use App\Invoice\Enums\InvoiceStatus;
use App\Invoice\Mails\BillMail;
use App\Invoice\Mails\RememberMail;
use App\Invoice\Models\Invoice;
use App\Invoice\RememberDocument;
use App\Invoice\DocumentFactory;
use App\Invoice\Queries\BillKindQuery;
use App\Payment\PaymentMail;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Lorisleiva\Actions\Concerns\AsAction;
@ -35,18 +32,15 @@ class InvoiceSendAction
*/
public function handle(): int
{
foreach (Invoice::whereNeedsBill()->where('via', BillKind::EMAIL)->get() as $invoice) {
$document = BillDocument::fromInvoice($invoice);
$path = Storage::disk('temp')->path(Tex::compile($document)->storeIn('', 'temp'));
Mail::to($invoice->getMailRecipient())->send(new BillMail($invoice, $path));
$invoice->sent($document);
}
foreach (app(DocumentFactory::class)->getTypes() as $type) {
$invoices = app(DocumentFactory::class)->invoiceCollection($type, new BillKindQuery(BillKind::EMAIL));
foreach (Invoice::whereNeedsRemember()->where('via', BillKind::EMAIL)->get() as $invoice) {
$document = RememberDocument::fromInvoice($invoice);
$path = Storage::disk('temp')->path(Tex::compile($document)->storeIn('', 'temp'));
Mail::to($invoice->getMailRecipient())->send(new RememberMail($invoice, $path));
$invoice->sent($document);
foreach ($invoices as $invoice) {
$invoicePath = Storage::disk('temp')->path(Tex::compile($invoice)->storeIn('', 'temp'));
Mail::to($invoice->getRecipient())
->send(new PaymentMail($invoice, $invoicePath));
app(DocumentFactory::class)->afterSingle($invoice);
}
}
return 0;

View File

@ -1,25 +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;
class InvoiceStoreAction
{
use AsAction;
use HasValidation;
public function handle(ActionRequest $request): void
{
$invoice = Invoice::create($request->safe()->except('positions'));
foreach ($request->validated('positions') as $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,40 +0,0 @@
<?php
namespace App\Invoice\Actions;
use App\Invoice\BillDocument;
use App\Invoice\BillKind;
use App\Invoice\Models\Invoice;
use App\Invoice\RememberDocument;
use Illuminate\Http\Response;
use Lorisleiva\Actions\Concerns\AsAction;
use Zoomyboy\Tex\BaseCompiler;
use Zoomyboy\Tex\Tex;
class MassPostPdfAction
{
use AsAction;
public function handle(): BaseCompiler|Response
{
$documents = [];
foreach (Invoice::whereNeedsBill()->where('via', BillKind::POST)->get() as $invoice) {
$document = BillDocument::fromInvoice($invoice);
$documents[] = $document;
$invoice->sent($document);
}
foreach (Invoice::whereNeedsRemember()->where('via', BillKind::POST)->get() as $invoice) {
$document = RememberDocument::fromInvoice($invoice);
$documents[] = $document;
$invoice->sent($document);
}
if (!count($documents)) {
return response()->noContent();
}
return Tex::merge($documents);
}
}

View File

@ -1,74 +0,0 @@
<?php
namespace App\Invoice\Actions;
use App\Invoice\Events\InvoicesMassStored;
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 Illuminate\Support\Collection;
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
{
/** @var Collection<int, Invoice> */
$invoices = collect([]);
$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,
]);
}
}
$invoices->push($invoice);
}
event(new InvoicesMassStored($year, $invoices));
}
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

@ -1,31 +0,0 @@
<?php
namespace App\Invoice\Actions;
use App\Invoice\Models\InvoicePosition;
use App\Invoice\Resources\InvoicePositionResource;
use App\Member\Member;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Lorisleiva\Actions\Concerns\AsAction;
class PaymentPositionIndexAction
{
use AsAction;
/**
* @return Collection<int, InvoicePosition>
*/
public function handle(Member $member): Collection
{
return $member->load('invoicePositions.invoice')->invoicePositions;
}
public function asController(Member $member): JsonResponse
{
return response()->json([
'data' => InvoicePositionResource::collection($this->handle($member)),
]);
}
}

View File

@ -5,8 +5,12 @@ namespace App\Invoice;
use App\Payment\Payment;
use Illuminate\Database\Eloquent\Relations\HasMany;
class BillDocument extends InvoiceDocument
class BillDocument extends Invoice
{
public function linkLabel(): string
{
return 'Rechnung erstellen';
}
public function getSubject(): string
{
@ -17,4 +21,42 @@ class BillDocument extends InvoiceDocument
{
return 'tex.bill';
}
public static function sendAllLabel(): string
{
return 'Rechnungen versenden';
}
public function afterSingle(Payment $payment): void
{
$payment->update(['status_id' => 2]);
}
public function getMailSubject(): string
{
return 'Jahresrechnung';
}
/**
* @param HasMany<Payment> $query
*
* @return HasMany<Payment>
*/
public static function paymentsQuery(HasMany $query): HasMany
{
return $query->whereNeedsBill();
}
/**
* Get Descriptions for sendpayment page.
*
* @return array<int, string>
*/
public static function getDescription(): array
{
return [
'Diese Funktion erstellt ein PDF mit allen noch nicht versendenden Rechnungen bei den Mitgliedern die Post als Versandweg haben.',
'Die Rechnungen werden automatisch auf "Rechnung gestellt" aktualisiert.',
];
}
}

View File

@ -0,0 +1,67 @@
<?php
namespace App\Invoice;
use App\Invoice\Queries\InvoiceMemberQuery;
use Illuminate\Support\Collection;
class DocumentFactory
{
/**
* @var array<int, class-string<Invoice>>
*/
private array $types = [
BillDocument::class,
RememberDocument::class,
];
/**
* @return Collection<int, class-string<Invoice>>
*/
public function getTypes(): Collection
{
return collect($this->types);
}
/**
* @param class-string<Invoice> $type
*/
public function singleInvoice(string $type, InvoiceMemberQuery $query): ?Invoice
{
$pages = $query->getPages($type);
if ($pages->isEmpty()) {
return null;
}
return $this->resolve($type, $pages);
}
/**
* @param class-string<Invoice> $type
*
* @return Collection<int, Invoice>
*/
public function invoiceCollection(string $type, InvoiceMemberQuery $query): Collection
{
return $query
->getPages($type)
->map(fn ($page) => $this->resolve($type, collect([$page])));
}
public function afterSingle(Invoice $invoice): void
{
foreach ($invoice->allPayments() as $payment) {
$invoice->afterSingle($payment);
}
}
/**
* @param class-string<Invoice> $type
* @param Collection<int, Page> $pages
*/
private function resolve(string $type, Collection $pages): Invoice
{
return new $type($pages);
}
}

View File

@ -1,28 +0,0 @@
<?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);
}
/**
* @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

@ -1,36 +0,0 @@
<?php
namespace App\Invoice\Events;
use App\Invoice\Models\Invoice;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;
class InvoicesMassStored
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* Create a new event instance.
*
* @param Collection<int, Invoice> $invoices
* @return void
*/
public function __construct(public int $year, public Collection $invoices)
{
//
}
/**
* Get the channels the event should broadcast on.
*
* @return \Illuminate\Broadcasting\Channel|array
*/
public function broadcastOn()
{
return new PrivateChannel('channel-name');
}
}

124
app/Invoice/Invoice.php Normal file
View File

@ -0,0 +1,124 @@
<?php
namespace App\Invoice;
use App\Payment\Payment;
use Carbon\Carbon;
use Exception;
use Generator;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Zoomyboy\Tex\Document;
use Zoomyboy\Tex\Engine;
use Zoomyboy\Tex\Template;
abstract class Invoice extends Document
{
abstract public function getSubject(): string;
abstract public function view(): string;
abstract public function linkLabel(): string;
abstract public static function sendAllLabel(): string;
/**
* @param HasMany<Payment> $query
*
* @return HasMany<Payment>
*/
abstract public static function paymentsQuery(HasMany $query): HasMany;
/**
* @return array<int, string>
*/
abstract public static function getDescription(): array;
abstract public function afterSingle(Payment $payment): void;
/**
* @var Collection<int, Page>
*/
public Collection $pages;
public string $subject;
protected string $filename;
public string $until;
public InvoiceSettings $settings;
/**
* @param Collection<int, Page> $pages
*/
public function __construct(Collection $pages)
{
$this->pages = $pages;
$this->subject = $this->getSubject();
$this->until = now()->addWeeks(2)->format('d.m.Y');
$this->setFilename(Str::slug("{$this->getSubject()} für {$pages->first()?->familyName}"));
$this->settings = app(InvoiceSettings::class);
}
public function number(int $number): string
{
return number_format($number / 100, 2, '.', '');
}
public function getUntil(): Carbon
{
return now()->addWeeks(2);
}
public function getEngine(): Engine
{
return Engine::PDFLATEX;
}
public function basename(): string
{
return $this->filename;
}
public function template(): Template
{
return Template::make('tex.templates.default');
}
public function setFilename(string $filename): self
{
$this->filename = $filename;
return $this;
}
public function getRecipient(): MailRecipient
{
if (!$this->pages->first()?->email) {
throw new Exception('Cannot get Recipient. Mail not set.');
}
return new MailRecipient($this->pages->first()->email, $this->pages->first()->familyName);
}
public function allPayments(): Generator
{
foreach ($this->pages as $page) {
foreach ($page->getPayments() as $payment) {
yield $payment;
}
}
}
/**
* @return view-string
*/
public function mailView(): string
{
$view = 'mail.payment.'.Str::snake(class_basename($this));
if (!view()->exists($view)) {
throw new Exception('Mail view '.$view.' existiert nicht.');
}
return $view;
}
}

View File

@ -1,89 +0,0 @@
<?php
namespace App\Invoice;
use App\Invoice\Models\Invoice;
use App\Payment\Payment;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
use Zoomyboy\Tex\Document;
use Zoomyboy\Tex\Engine;
use Zoomyboy\Tex\Template;
abstract class InvoiceDocument extends Document
{
abstract public function getSubject(): string;
abstract public function view(): string;
public string $until;
public string $filename;
/**
* @param array<string, string> $positions
*/
public function __construct(
public string $toName,
public string $toAddress,
public string $toZip,
public string $toLocation,
public string $greeting,
public array $positions,
public string $usage,
) {
$this->until = now()->addWeeks(2)->format('d.m.Y');
$this->filename = Str::slug("{$this->getSubject()} für {$toName}");
}
public static function fromInvoice(Invoice $invoice): self
{
return static::withoutMagicalCreationFrom([
'toName' => $invoice->to['name'],
'toAddress' => $invoice->to['address'],
'toZip' => $invoice->to['zip'],
'toLocation' => $invoice->to['location'],
'greeting' => $invoice->greeting,
'positions' => static::renderPositions($invoice),
'usage' => $invoice->usage,
]);
}
public function settings(): InvoiceSettings
{
return app(InvoiceSettings::class);
}
public function getEngine(): Engine
{
return Engine::PDFLATEX;
}
public function basename(): string
{
return $this->filename;
}
public function template(): Template
{
return Template::make('tex.templates.default');
}
public function setFilename(string $filename): self
{
$this->filename = $filename;
return $this;
}
/**
* @return array<string, string>
*/
public static function renderPositions(Invoice $invoice): array
{
return $invoice->positions->mapWithKeys(fn ($position) => [$position->description => static::number($position->price)])->toArray();
}
public static function number(int $number): string
{
return number_format($number / 100, 2, '.', '');
}
}

View File

@ -1,36 +0,0 @@
<?php
namespace App\Invoice\Mails;
use App\Invoice\Models\Invoice;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class BillMail extends Mailable
{
use Queueable;
use SerializesModels;
/**
* Create a new message instance.
*
* @return void
*/
public function __construct(public Invoice $invoice, public string $filename)
{
}
/**
* Build the message.
*
* @return $this
*/
public function build()
{
return $this->markdown('mail.invoice.bill')
->attach($this->filename)
->replyTo('kasse@stamm-silva.de')
->subject('Rechnung | DPSG Stamm Silva');
}
}

View File

@ -1,36 +0,0 @@
<?php
namespace App\Invoice\Mails;
use App\Invoice\Models\Invoice;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class RememberMail extends Mailable
{
use Queueable;
use SerializesModels;
/**
* Create a new message instance.
*
* @return void
*/
public function __construct(public Invoice $invoice, public string $filename)
{
}
/**
* Build the message.
*
* @return $this
*/
public function build()
{
return $this->markdown('mail.invoice.remember')
->attach($this->filename)
->replyTo('kasse@stamm-silva.de')
->subject('Zahlungserinnerung | DPSG Stamm Silva');
}
}

View File

@ -1,126 +0,0 @@
<?php
namespace App\Invoice\Models;
use App\Invoice\BillDocument;
use App\Invoice\BillKind;
use App\Invoice\Enums\InvoiceStatus;
use App\Invoice\InvoiceDocument;
use App\Invoice\RememberDocument;
use App\Member\Member;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use stdClass;
class Invoice extends Model
{
use HasFactory;
public $guarded = [];
public $casts = [
'to' => 'json',
'status' => InvoiceStatus::class,
'via' => BillKind::class,
];
/** @var array<int, string> */
public $dates = [
'sent_at',
'last_remembered_at',
];
/**
* @return HasMany<InvoicePosition>
*/
public function positions(): HasMany
{
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,
'usage' => 'Mitgliedsbeitrag für ' . $member->lastname,
'mail_email' => $member->email_parents ?: $member->email,
]);
}
public static function booted(): void
{
static::deleting(function ($model) {
$model->positions()->delete();
});
}
/**
* @param Builder<self> $query
*
* @return Builder<self>
*/
public function scopeWhereNeedsPayment(Builder $query): Builder
{
return $query->whereIn('status', [InvoiceStatus::NEW->value, InvoiceStatus::SENT->value]);
}
/**
* @param Builder<self> $query
*
* @return Builder<self>
*/
public function scopeWhereNeedsBill(Builder $query): Builder
{
return $query->where('status', InvoiceStatus::NEW);
}
/**
* @param Builder<self> $query
*
* @return Builder<self>
*/
public function scopeWhereNeedsRemember(Builder $query): Builder
{
return $query
->where('status', InvoiceStatus::SENT)
->whereNotNull('sent_at')
->whereNotNull('last_remembered_at')
->where('last_remembered_at', '<=', now()->subMonths(3));
}
public function getMailRecipient(): stdClass
{
return (object) [
'email' => $this->mail_email,
'name' => $this->to['name']
];
}
public function sent(InvoiceDocument $document): void
{
if (is_a($document, BillDocument::class)) {
$this->update([
'sent_at' => now(),
'status' => InvoiceStatus::SENT,
'last_remembered_at' => now(),
]);
}
if (is_a($document, RememberDocument::class)) {
$this->update([
'last_remembered_at' => now(),
'status' => InvoiceStatus::SENT,
]);
}
}
}

View File

@ -1,40 +0,0 @@
<?php
namespace App\Invoice\Models;
use App\Member\Member;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class InvoicePosition extends Model
{
use HasFactory;
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();
}
});
}
}

76
app/Invoice/Page.php Normal file
View File

@ -0,0 +1,76 @@
<?php
namespace App\Invoice;
use App\Member\Member;
use App\Payment\Payment;
use Illuminate\Support\Collection;
class Page
{
/**
* @var Collection<int, Member>
*/
private Collection $members;
public string $familyName;
public string $singleName;
public string $address;
public string $zip;
public string $location;
public string $usage;
public ?string $email;
/**
* @var array<string, string>
*/
public array $positions;
/**
* @param Collection<int, Member> $members
*/
public function __construct(Collection $members)
{
$this->members = $members;
$this->familyName = $this->members->first()->lastname;
$this->singleName = $members->first()->lastname;
$this->address = $members->first()->address;
$this->zip = $members->first()->zip;
$this->location = $members->first()->location;
$this->email = $members->first()->email_parents ?: $members->first()->email;
$this->positions = $this->getPositions();
$this->usage = "Mitgliedsbeitrag für {$this->familyName}";
}
/**
* @return array<string, string>
*/
public function getPositions(): array
{
/** @var array<string, string> */
$result = [];
foreach ($this->getPayments() as $payment) {
if ($payment->subscription->split) {
foreach ($payment->subscription->children as $child) {
$result["{$payment->subscription->name} ({$child->name}) {$payment->nr} für {$payment->member->firstname} {$payment->member->lastname}"] = $this->number($child->amount);
}
} else {
$result["{$payment->subscription->name} {$payment->nr} für {$payment->member->firstname} {$payment->member->lastname}"] = $this->number($payment->subscription->getAmount());
}
}
return $result;
}
/**
* @return Collection<int, Payment>
*/
public function getPayments(): Collection
{
return $this->members->pluck('payments')->flatten(1);
}
public function number(int $number): string
{
return number_format($number / 100, 2, '.', '');
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Invoice\Queries;
use App\Invoice\BillKind;
use App\Member\Member;
use Illuminate\Database\Eloquent\Builder;
class BillKindQuery extends InvoiceMemberQuery
{
public function __construct(
private BillKind $billKind
) {
}
/**
* @return Builder<Member>
*/
protected function getQuery(): Builder
{
return Member::where('bill_kind', $this->billKind);
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Invoice\Queries;
use App\Invoice\Invoice;
use App\Invoice\Page;
use App\Member\Member;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
abstract class InvoiceMemberQuery
{
/**
* @return Builder<Member>
*/
abstract protected function getQuery(): Builder;
/**
* @param class-string<Invoice> $type
*
* @return Collection<int, Page>
*/
public function getPages(string $type): Collection
{
return $this->get($type)->groupBy(
fn ($member) => Str::slug(
"{$member->lastname}{$member->address}{$member->zip}{$member->location}",
),
)->map(fn ($page) => new Page($page));
}
/**
* @param class-string<Invoice> $type
*
* @return EloquentCollection<int, Member>
*/
private function get(string $type): EloquentCollection
{
return $this->getQuery()
->with([
'payments' => fn ($query) => $type::paymentsQuery($query)
->orderByRaw('nr, member_id'),
])
->get()
->filter(fn (Member $member) => $member->payments->count() > 0);
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Invoice\Queries;
use App\Member\Member;
use Illuminate\Database\Eloquent\Builder;
class SingleMemberQuery extends InvoiceMemberQuery
{
public function __construct(
private Member $member
) {
}
/**
* @return Builder<Member>
*/
protected function getQuery(): Builder
{
return Member::where($this->member->only(['lastname', 'address', 'zip', 'location']));
}
}

View File

@ -5,8 +5,12 @@ namespace App\Invoice;
use App\Payment\Payment;
use Illuminate\Database\Eloquent\Relations\HasMany;
class RememberDocument extends InvoiceDocument
class RememberDocument extends Invoice
{
public function linkLabel(): string
{
return 'Erinnerung erstellen';
}
public function getSubject(): string
{
@ -17,4 +21,42 @@ class RememberDocument extends InvoiceDocument
{
return 'tex.remember';
}
public static function sendAllLabel(): string
{
return 'Erinnerungen versenden';
}
public function afterSingle(Payment $payment): void
{
$payment->update(['last_remembered_at' => now()]);
}
public function getMailSubject(): string
{
return 'Zahlungserinnerung';
}
/**
* @param HasMany<Payment> $query
*
* @return HasMany<Payment>
*/
public static function paymentsQuery(HasMany $query): HasMany
{
return $query->whereNeedsRemember();
}
/**
* Get Descriptions for sendpayment page.
*
* @return array<int, string>
*/
public static function getDescription(): array
{
return [
'Diese Funktion erstellt Erinnerungs-PDFs mit allen versendeten aber noch nich bezahlten Rechnungen bei den Mitgliedern die Post als Versandweg haben.',
'Das zuletzt erinnerte Datum wird auf heute gesetzt.',
];
}
}

View File

@ -1,31 +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,
'invoice' => new InvoiceResource($this->whenLoaded('invoice')),
'price_human' => number_format($this->price / 100, 2, ',', '') . ' €',
];
}
}

View File

@ -1,84 +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,
'usage' => $this->usage,
'mail_email' => $this->mail_email,
'links' => [
'pdf' => route('invoice.pdf', ['invoice' => $this->getModel()]),
'rememberpdf' => route('invoice.rememberpdf', ['invoice' => $this->getModel()]),
'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'),
'masspdf' => route('invoice.masspdf'),
],
'vias' => BillKind::forSelect(),
'statuses' => InvoiceStatus::forSelect(),
'members' => Member::forSelect(),
'default' => [
'to' => [
'name' => '',
'address' => '',
'zip' => '',
'location' => '',
],
'positions' => [],
'greeting' => '',
'status' => InvoiceStatus::NEW->value,
'via' => null,
'usage' => '',
'mail_email' => '',
],
'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();
$jobState = WithJobState::make($jobId);
tap($this->jobState(...[$jobState, ...$parameters])->beforeMessage, fn ($beforeMessage) => $beforeMessage && $beforeMessage->dispatch());;
$this->jobState(...[$jobState, ...$parameters])->beforeMessage->dispatch();
$parameters[] = $jobId;
static::dispatch(...$parameters);
}

View File

@ -18,14 +18,13 @@ class MemberShowAction
public function handle(Member $member): array
{
return [
'data' => new MemberResource(
$member
->load('memberships')
->load('invoicePositions.invoice')
->load('nationality')
->load('region')
->load('subscription')
->load('courses.course')
'data' => new MemberResource($member
->load('memberships')
->load('payments.subscription.children')
->load('nationality')
->load('region')
->load('subscription')
->load('courses.course')
),
'meta' => MemberResource::meta(),
];
@ -34,7 +33,7 @@ class MemberShowAction
public function asController(Member $member): Response
{
session()->put('menu', 'member');
session()->put('title', 'Mitglied ' . $member->fullname);
session()->put('title', 'Mitglied '.$member->fullname);
return Inertia::render('member/ShowView', $this->handle($member));
}

View File

@ -8,9 +8,9 @@ use App\Course\Models\CourseMember;
use App\Gender;
use App\Group;
use App\Invoice\BillKind;
use App\Invoice\Models\InvoicePosition;
use App\Nami\HasNamiField;
use App\Nationality;
use App\Payment\Payment;
use App\Payment\Subscription;
use App\Pdf\Sender;
use App\Region;
@ -110,6 +110,13 @@ class Member extends Model implements Geolocatable
$this->update(['version' => $version]);
}
public function createPayment(array $attributes): void
{
$this->payments()->create(array_merge($attributes, [
'last_remembered_at' => now(),
]));
}
// ----------------------------------- Getters -----------------------------------
public function getFullnameAttribute(): string
{
@ -206,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>
*/
@ -262,6 +261,14 @@ class Member extends Model implements Geolocatable
return $this->hasMany(Membership::class);
}
/**
* @return HasMany<Payment>
*/
public function payments(): HasMany
{
return $this->hasMany(Payment::class)->orderBy('nr');
}
/**
* @return HasMany<Membership>
*/
@ -281,11 +288,9 @@ class Member extends Model implements Geolocatable
public static function booted()
{
static::deleting(function (self $model): void {
$model->payments->each->delete();
$model->memberships->each->delete();
$model->courses->each->delete();
$model->invoicePositions->each(function ($position) {
$position->delete();
});
});
static::saving(fn ($model) => $model->updateSearch());
@ -310,9 +315,11 @@ class Member extends Model implements Geolocatable
public function scopeWithPendingPayment(Builder $query): Builder
{
return $query->addSelect([
'pending_payment' => InvoicePosition::selectRaw('SUM(price)')
->whereColumn('invoice_positions.member_id', 'members.id')
->whereHas('invoice', fn ($query) => $query->whereNeedsPayment())
'pending_payment' => Payment::selectRaw('SUM(subscription_children.amount)')
->whereColumn('payments.member_id', 'members.id')
->whereNeedsPayment()
->join('subscriptions', 'subscriptions.id', 'payments.subscription_id')
->join('subscription_children', 'subscriptions.id', 'subscription_children.parent_id'),
]);
}
@ -323,7 +330,9 @@ class Member extends Model implements Geolocatable
*/
public function scopeWhereHasPendingPayment(Builder $query): Builder
{
return $query->whereHas('invoicePositions', fn ($q) => $q->whereHas('invoice', fn ($q) => $q->whereNeedsPayment()));
return $query->whereHas('payments', function (Builder $q): void {
$q->whereNeedsPayment();
});
}
/**
@ -333,8 +342,8 @@ class Member extends Model implements Geolocatable
*/
public function scopeWhereAusstand(Builder $query): Builder
{
return $query->whereHas('invoicePositions', function ($q) {
return $q->whereHas('invoice', fn ($query) => $query->whereNeedsPayment());
return $query->whereHas('payments', function ($q) {
return $q->whereHas('status', fn ($q) => $q->where('is_remember', true));
});
}
@ -348,6 +357,18 @@ class Member extends Model implements Geolocatable
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
*
@ -485,14 +506,6 @@ class Member extends Model implements Geolocatable
])->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 --------------------------------
// *****************************************************************************
public function fillCoordinate(Coordinate $coordinate): void

View File

@ -8,13 +8,14 @@ use App\Course\Models\Course;
use App\Course\Resources\CourseMemberResource;
use App\Gender;
use App\Invoice\BillKind;
use App\Invoice\Resources\InvoicePositionResource;
use App\Lib\HasMeta;
use App\Member\Data\NestedGroup;
use App\Member\Resources\NationalityResource;
use App\Member\Resources\RegionResource;
use App\Membership\MembershipResource;
use App\Nationality;
use App\Payment\PaymentResource;
use App\Payment\Status;
use App\Payment\Subscription;
use App\Payment\SubscriptionResource;
use App\Region;
@ -71,11 +72,11 @@ class MemberResource extends JsonResource
'bill_kind_name' => optional($this->bill_kind)->value,
'has_nami' => null !== $this->nami_id,
'children_phone' => $this->children_phone,
'payments' => PaymentResource::collection($this->whenLoaded('payments')),
'pending_payment' => $this->pending_payment ? number_format($this->pending_payment / 100, 2, ',', '.') . ' €' : null,
'age_group_icon' => $this->ageGroupMemberships->first()?->subactivity->slug,
'courses' => CourseMemberResource::collection($this->whenLoaded('courses')),
'memberships' => MembershipResource::collection($this->whenLoaded('memberships')),
'invoicePositions' => InvoicePositionResource::collection($this->whenLoaded('invoicePositions')),
'nationality' => new NationalityResource($this->whenLoaded('nationality')),
'region' => new RegionResource($this->whenLoaded('region')),
'full_address' => $this->fullAddress,
@ -108,7 +109,7 @@ class MemberResource extends JsonResource
'group_name' => $this->group->name,
'links' => [
'membership_index' => route('member.membership.index', ['member' => $this->getModel()]),
'invoiceposition_index' => route('member.invoice-position.index', ['member' => $this->getModel()]),
'payment_index' => route('member.payment.index', ['member' => $this->getModel()]),
'course_index' => route('member.course.index', ['member' => $this->getModel()]),
'show' => route('member.show', ['member' => $this->getModel()]),
'edit' => route('member.edit', ['member' => $this->getModel()]),
@ -156,6 +157,8 @@ class MemberResource extends JsonResource
'links' => [
'index' => route('member.index'),
'create' => route('member.create'),
'allpayment' => route('allpayment.page'),
'sendpayment' => route('sendpayment.create'),
],
];
}

View File

@ -52,7 +52,7 @@ class MembershipResource extends JsonResource
],
'groups' => NestedGroup::cacheForSelect(),
'activities' => $activities->map(fn ($activity) => ['id' => $activity->id, 'name' => $activity->name]),
'subactivities' => $activities->mapWithKeys(fn ($activity) => [$activity->id => $activity->subactivities->map(fn ($subactivity) => ['id' => $subactivity->id, 'name' => $subactivity->name, 'is_age_group' => $subactivity->is_age_group])]),
'subactivities' => $activities->mapWithKeys(fn ($activity) => [$activity->id => $activity->subactivities->map(fn ($subactivity) => ['id' => $subactivity->id, 'name' => $subactivity->name])]),
'default' => [
'group_id' => $member->group_id,
'activity_id' => null,

View File

@ -0,0 +1,25 @@
<?php
namespace App\Payment;
use App\Invoice\DocumentFactory;
use Illuminate\Support\Collection;
class ActionFactory
{
/**
* @return Collection<int, array{link: array{href: string, label: mixed}, text: mixed}>
*/
public function allLinks(): Collection
{
return app(DocumentFactory::class)->getTypes()->map(function ($repo) {
return [
'link' => [
'href' => route('sendpayment.pdf', ['type' => $repo]),
'label' => $repo::sendAllLabel(),
],
'text' => $repo::getDescription(),
];
});
}
}

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

@ -0,0 +1,31 @@
<?php
namespace App\Payment\Actions;
use App\Member\Member;
use App\Payment\Payment;
use App\Payment\PaymentResource;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Lorisleiva\Actions\Concerns\AsAction;
class IndexAction
{
use AsAction;
/**
* @return Collection<int, Payment>
*/
public function handle(Member $member): Collection
{
return $member->payments()->with('subscription')->get();
}
public function asController(Member $member): AnonymousResourceCollection
{
return PaymentResource::collection($this->handle($member))
->additional([
'meta' => PaymentResource::memberMeta($member),
]);
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Payment\Actions;
use App\Lib\JobMiddleware\JobChannels;
use App\Lib\JobMiddleware\WithJobState;
use App\Lib\Queue\TracksJob;
use App\Payment\Payment;
use Illuminate\Http\JsonResponse;
use Lorisleiva\Actions\Concerns\AsAction;
class PaymentDestroyAction
{
use AsAction;
use TracksJob;
public function handle(int $paymentId): void
{
Payment::find($paymentId)->delete();
}
public function asController(Payment $payment): JsonResponse
{
$this->startJob($payment->id, $payment->member->fullname);
return response()->json([]);
}
/**
* @param mixed $parameters
*/
public function jobState(WithJobState $jobState, ...$parameters): WithJobState
{
$memberName = $parameters[1];
return $jobState
->before('Zahlung für ' . $memberName . ' wird gelöscht')
->after('Zahlung für ' . $memberName . ' gelöscht')
->failed('Fehler beim Löschen der Zahlung für ' . $memberName)
->shouldReload(JobChannels::make()->add('member')->add('payment'));
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace App\Payment\Actions;
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 PaymentStoreAction
{
use AsAction;
use TracksJob;
/**
* @param array<string, string> $attributes
*/
public function handle(Member $member, array $attributes): void
{
$member->createPayment($attributes);
}
/**
* @return array<string, string>
*/
public function rules(): array
{
return [
'nr' => 'required',
'subscription_id' => 'required|exists:subscriptions,id',
'status_id' => 'required|exists:statuses,id',
];
}
public function asController(Member $member, ActionRequest $request): JsonResponse
{
$this->startJob($member, $request->validated());
return response()->json([]);
}
/**
* @param mixed $parameters
*/
public function jobState(WithJobState $jobState, ...$parameters): WithJobState
{
$member = $parameters[0];
return $jobState
->before('Zahlung für ' . $member->fullname . ' wird gespeichert')
->after('Zahlung für ' . $member->fullname . ' gespeichert')
->failed('Fehler beim Erstellen der Zahlung für ' . $member->fullname)
->shouldReload(JobChannels::make()->add('member')->add('payment'));
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace App\Payment\Actions;
use App\Lib\JobMiddleware\JobChannels;
use App\Lib\JobMiddleware\WithJobState;
use App\Lib\Queue\TracksJob;
use App\Payment\Payment;
use Illuminate\Http\JsonResponse;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
class PaymentUpdateAction
{
use AsAction;
use TracksJob;
/**
* @param array<string, string> $attributes
*/
public function handle(Payment $payment, array $attributes): void
{
$payment->update($attributes);
}
/**
* @return array<string, string>
*/
public function rules(): array
{
return [
'nr' => 'required',
'subscription_id' => 'required|exists:subscriptions,id',
'status_id' => 'required|exists:statuses,id',
];
}
public function asController(Payment $payment, ActionRequest $request): JsonResponse
{
$this->startJob($payment, $request->validated());
return response()->json([]);
}
/**
* @param mixed $parameters
*/
public function jobState(WithJobState $jobState, ...$parameters): WithJobState
{
$member = $parameters[0]->member;
return $jobState
->before('Zahlung für ' . $member->fullname . ' wird aktualisiert')
->after('Zahlung für ' . $member->fullname . ' aktualisiert')
->failed('Fehler beim Aktualisieren der Zahlung für ' . $member->fullname)
->shouldReload(JobChannels::make()->add('member')->add('payment'));
}
}

View File

@ -1,9 +1,8 @@
<?php
namespace App\Invoice;
namespace App\Payment;
use App\Dashboard\Blocks\Block;
use App\Invoice\Models\InvoicePosition;
use App\Member\Member;
class MemberPaymentBlock extends Block
@ -13,15 +12,17 @@ class MemberPaymentBlock extends Block
*/
public function data(): array
{
$amount = InvoicePosition::whereHas('invoice', fn ($query) => $query->whereNeedsPayment())
->selectRaw('sum(price) AS price')
$amount = Payment::whereNeedsPayment()
->selectRaw('sum(subscription_children.amount) AS nr')
->join('subscriptions', 'subscriptions.id', 'payments.subscription_id')
->join('subscription_children', 'subscriptions.id', 'subscription_children.parent_id')
->first();
$members = Member::whereHasPendingPayment()->count();
return [
'members' => $members,
'total_members' => Member::count(),
'amount' => number_format((int) $amount->price / 100, 2, ',', '.') . ' €',
'amount' => number_format((int) $amount->nr / 100, 2, ',', '.').' €',
];
}

76
app/Payment/Payment.php Normal file
View File

@ -0,0 +1,76 @@
<?php
namespace App\Payment;
use App\Member\Member;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Payment extends Model
{
use HasFactory;
public $fillable = ['member_id', 'subscription_id', 'nr', 'status_id', 'last_remembered_at'];
/**
* @return BelongsTo<Member, self>
*/
public function member(): BelongsTo
{
return $this->belongsTo(Member::class);
}
/**
* @return BelongsTo<Subscription, self>
*/
public function subscription(): BelongsTo
{
return $this->belongsTo(Subscription::class);
}
/**
* @return BelongsTo<Status, self>
*/
public function status(): BelongsTo
{
return $this->belongsTo(Status::class);
}
/**
* @param Builder<self> $query
*
* @return Builder<self>
*/
public function scopeWhereNeedsPayment(Builder $query): Builder
{
return $query->whereHas('status', function ($q) {
return $q->needsPayment();
});
}
/**
* @param Builder<self> $query
*
* @return Builder<self>
*/
public function scopeWhereNeedsBill(Builder $query): Builder
{
return $query->whereHas('status', function ($q) {
return $q->where('is_bill', true);
});
}
/**
* @param Builder<self> $query
*
* @return Builder<self>
*/
public function scopeWhereNeedsRemember(Builder $query): Builder
{
return $query->whereHas('status', function ($q) {
return $q->where('is_remember', true);
})->where(fn ($query) => $query->whereNull('last_remembered_at')->orWhere('last_remembered_at', '<=', now()->subMonths(3)));
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Payment;
use App\Invoice\Invoice;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class PaymentMail extends Mailable
{
use Queueable;
use SerializesModels;
public Invoice $invoice;
public string $filename;
public string $salutation;
/**
* Create a new message instance.
*
* @return void
*/
public function __construct(Invoice $invoice, string $filename)
{
$this->invoice = $invoice;
$this->filename = $filename;
$this->salutation = 'Liebe Familie '.$invoice->pages->first()->familyName;
}
/**
* Build the message.
*
* @return $this
*/
public function build()
{
return $this->markdown($this->invoice->mailView())
->attach($this->filename)
->replyTo('kasse@stamm-silva.de')
->subject($this->invoice->getSubject().' | DPSG Stamm Silva');
}
}

View File

@ -0,0 +1,55 @@
<?php
namespace App\Payment;
use App\Member\Member;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Payment
*/
class PaymentResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
*
* @return array
*/
public function toArray($request)
{
return [
'subscription_id' => $this->subscription_id,
'subscription' => new SubscriptionResource($this->whenLoaded('subscription')),
'status_name' => $this->status->name,
'status_id' => $this->status->id,
'nr' => $this->nr,
'id' => $this->id,
'is_accepted' => $this->status->isAccepted(),
'links' => [
'update' => route('payment.update', ['payment' => $this->getModel()]),
'destroy' => route('payment.destroy', ['payment' => $this->getModel()]),
]
];
}
/**
* @return array<string, mixed>
*/
public static function memberMeta(Member $member): array
{
return [
'statuses' => Status::forSelect(),
'subscriptions' => Subscription::forSelect(),
'default' => [
'nr' => '',
'subscription_id' => null,
'status_id' => null
],
'links' => [
'store' => route('member.payment.store', ['member' => $member]),
]
];
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace App\Payment;
use App\Http\Controllers\Controller;
use App\Invoice\BillKind;
use App\Invoice\DocumentFactory;
use App\Invoice\Queries\BillKindQuery;
use Illuminate\Contracts\Support\Responsable;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Inertia\Inertia;
use Inertia\Response as InertiaResponse;
use Zoomyboy\Tex\Tex;
class SendpaymentController extends Controller
{
public function create(): InertiaResponse
{
session()->put('menu', 'member');
session()->put('title', 'Rechnungen versenden');
return Inertia::render('sendpayment/VForm', [
'types' => app(ActionFactory::class)->allLinks(),
]);
}
/**
* @return Response|Responsable
*/
public function send(Request $request)
{
$invoice = app(DocumentFactory::class)->singleInvoice($request->type, new BillKindQuery(BillKind::POST));
if (is_null($invoice)) {
return response()->noContent();
}
$pdfFile = Tex::compile($invoice);
app(DocumentFactory::class)->afterSingle($invoice);
return $pdfFile;
}
}

49
app/Payment/Status.php Normal file
View File

@ -0,0 +1,49 @@
<?php
namespace App\Payment;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Status extends Model
{
use HasFactory;
public $fillable = ['name', 'is_bill', 'is_remember'];
public $timestamps = false;
public $casts = [
'is_bill' => 'boolean',
'is_remember' => 'boolean',
];
public static function default(): int
{
return static::where('is_bill', true)->where('is_remember', true)->first()->id;
}
public function isAccepted(): bool
{
return false === $this->is_bill && false === $this->is_remember;
}
// ---------------------------------- Scopes -----------------------------------
/**
* @param Builder<self> $query
* @return Builder<self>
*/
public function scopeNeedsPayment(Builder $query): Builder
{
return $query->where(function (Builder $query): Builder {
return $query->where('is_bill', true)->orWhere('is_remember', true);
});
}
/**
* @return array<int, array{name: string, id: int}>
*/
public static function forSelect(): array
{
return static::select('name', 'id')->get()->toArray();
}
}

View File

@ -15,7 +15,15 @@ class Subscription extends Model
/**
* @var array<int, string>
*/
public $fillable = ['name', 'fee_id'];
public $fillable = ['name', 'fee_id', 'split', 'for_promise'];
/**
* @var array<string, string>
*/
public $casts = [
'split' => 'boolean',
'for_promise' => 'boolean',
];
public function getAmount(): int
{

View File

@ -39,9 +39,12 @@ class SubscriptionController extends Controller
{
$subscriptionParams = $request->validate([
'name' => 'required|max:255',
'split' => 'present|boolean',
'fee_id' => 'required|exists:fees,id',
'for_promise' => 'present|boolean',
], [], [
'fee_id' => 'Nami-Beitrag',
'for_promise' => 'Für Versprechen benutzen',
]);
$children = $request->validate([
@ -73,9 +76,12 @@ class SubscriptionController extends Controller
{
$subscriptionParams = $request->validate([
'name' => 'required|max:255',
'split' => 'present|boolean',
'fee_id' => 'required|exists:fees,id',
'for_promise' => 'present|boolean',
], [], [
'fee_id' => 'Nami-Beitrag',
'for_promise' => 'Für Versprechen benutzen',
]);
$subscription->update($subscriptionParams);
$children = $request->validate([

View File

@ -26,9 +26,11 @@ class SubscriptionResource extends JsonResource
'name' => $this->name,
'fee_id' => $this->fee_id,
'fee_name' => $this->fee->name,
'amount_human' => number_format($this->getAmount() / 100, 2, ',', '.') . ' €',
'amount_human' => number_format($this->getAmount() / 100, 2, ',', '.').' €',
'amount' => $this->getAmount(),
'split' => $this->split,
'children' => SubscriptionChildResource::collection($this->whenLoaded('children')),
'for_promise' => $this->for_promise,
];
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Pdf;
use App\Http\Controllers\Controller;
use App\Invoice\DocumentFactory;
use App\Invoice\Queries\SingleMemberQuery;
use App\Member\Member;
use Illuminate\Contracts\Support\Responsable;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Zoomyboy\Tex\Tex;
class MemberPdfController extends Controller
{
/**
* @return Response|Responsable
*/
public function __invoke(Request $request, Member $member)
{
$invoice = app(DocumentFactory::class)->singleInvoice($request->type, new SingleMemberQuery($member));
return null === $invoice
? response()->noContent()
: Tex::compile($invoice);
}
}

1146
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,55 +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,
'usage' => $this->faker->words(4, true),
'mail_email' => $this->faker->safeEmail(),
];
}
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,43 +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),
'price' => $this->faker->numberBetween(1000, 2000),
];
}
public function description(string $description): self
{
return $this->state(['description' => $description]);
}
public function price(int $price): self
{
return $this->state(['price' => $price]);
}
public function withMember(): self
{
return $this->state(['member_id' => Member::factory()->defaults()->create()->id]);
}
}

View File

@ -8,8 +8,10 @@ use App\Group;
use App\Invoice\BillKind;
use App\Member\Member;
use App\Nationality;
use App\Payment\Payment;
use App\Payment\Subscription;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Database\Eloquent\Model;
/**
* @extends Factory<Member>
@ -80,14 +82,17 @@ class MemberFactory extends Factory
return $this->state(['nami_id' => $namiId]);
}
public function sameFamilyAs(Member $member): self
/**
* @param array<int, callable> $payments
*/
public function withPayments(array $payments): self
{
return $this->state([
'firstname' => $member->firstname . 'a',
'lastname' => $member->lastname,
'address' => $member->address,
'zip' => $member->zip,
'location' => $member->location,
]);
return $this->afterCreating(function (Model $model) use ($payments): void {
foreach ($payments as $paymentClosure) {
$factory = Payment::factory()->for($model);
$factory = call_user_func($paymentClosure, $factory);
$factory->create();
}
});
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace Database\Factories\Payment;
use App\Fee;
use App\Payment\Payment;
use App\Payment\Status;
use App\Payment\Subscription;
use Illuminate\Database\Eloquent\Factories\Factory;
use Tests\RequestFactories\Child;
/**
* @extends Factory<Payment>
*/
class PaymentFactory extends Factory
{
protected $model = Payment::class;
public function definition(): array
{
return [
'nr' => $this->faker->year,
'subscription_id' => Subscription::factory()->create()->id,
'status_id' => Status::factory()->create()->id,
'last_remembered_at' => $this->faker->dateTime,
];
}
public function notPaid(): self
{
return $this->for(Status::whereName('Nicht bezahlt')->first());
}
public function paid(): self
{
return $this->for(Status::whereName('Rechnung beglichen')->first());
}
public function nr(string $nr): self
{
return $this->state(['nr' => $nr]);
}
/**
* @param array<int, Child> $children
* @param array<string, mixed> $state
*/
public function subscription(string $name, array $children, array $state = []): self
{
return $this->for(
Subscription::factory()->state(['name' => $name])->for(Fee::factory())->children($children)->state($state)
);
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace Database\Factories\Payment;
use App\Payment\Status;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Status>
*/
class StatusFactory extends Factory
{
public $model = Status::class;
/**
* Define the model's default state.
*
* @return array
*/
public function definition()
{
return [
'name' => $this->faker->sentence,
'is_bill' => $this->faker->boolean,
'is_remember' => $this->faker->boolean,
];
}
}

View File

@ -20,6 +20,7 @@ class SubscriptionFactory extends Factory
return [
'name' => $this->faker->word,
'fee_id' => Fee::factory()->createOne()->id,
'for_promise' => false,
];
}
@ -41,4 +42,9 @@ class SubscriptionFactory extends Factory
return $instance;
}
public function forPromise(): self
{
return $this->state(['for_promise' => true]);
}
}

View File

@ -21,6 +21,10 @@ class CreatePaymentsTable extends Migration
$table->boolean('is_remember');
});
Status::create(['name' => 'Nicht bezahlt', 'is_bill' => true, 'is_remember' => true]);
Status::create(['name' => 'Rechnung gestellt', 'is_bill' => false, 'is_remember' => true]);
Status::create(['name' => 'Rechnung beglichen', 'is_bill' => false, 'is_remember' => false]);
Schema::create('payments', function (Blueprint $table) {
$table->id();
$table->string('nr');

View File

@ -1,32 +0,0 @@
<?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::table('payments', function (Blueprint $table) {
$table->json('invoice_data')->nullable();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('payments', function (Blueprint $table) {
$table->dropColumn('invoice_data');
});
}
};

View File

@ -1,122 +0,0 @@
<?php
use App\Invoice\BillKind;
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->string('status');
$table->date('sent_at')->nullable();
$table->string('via');
$table->string('usage');
$table->string('mail_email')->nullable();
$table->datetime('last_remembered_at')->nullable();
$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();
});
foreach (DB::table('subscriptions')->get() as $subscription) {
$children = DB::table('subscription_children')->where('parent_id', $subscription->id)->get();
if ($subscription->split === 1) {
foreach ($children as $child) {
$newName = 'Beitrag {year} für {name} (' . $child->name . ')';
DB::table('subscription_children')->where('id', $child->id)->update(['name' => $newName]);
}
} else {
DB::table('subscription_children')->where('parent_id', $subscription->id)->delete();
DB::table('subscription_children')->insert([
'id' => Str::uuid()->toString(),
'name' => 'Beitrag {year} für {name} (' . $subscription->name . ')',
'amount' => $children->sum('amount'),
'parent_id' => $subscription->id,
]);
}
}
$paymentGroups = DB::table('payments')->where('status_id', 2)->get()->groupBy(function ($payment) {
$member = DB::table('members')->where('id', $payment->member_id)->first();
return $member->lastname . $member->address . $member->location . $member->zip;
});
foreach ($paymentGroups as $payments) {
$member = DB::table('members')->where('id', $payments->first()->member_id)->first();
$invoiceId = DB::table('invoices')->insertGetId([
'to' => json_encode([
'name' => 'Familie ' . $member->lastname,
'address' => $member->address,
'zip' => $member->zip,
'location' => $member->location,
]),
'greeting' => 'Liebe Familie ' . $member->lastname,
'status' => 'Rechnung gestellt',
'via' => BillKind::fromValue($member->bill_kind)->value,
'usage' => 'Mitgliedsbeitrag für ' . $member->lastname,
'mail_email' => $member->email_parents ?: $member->email,
'last_remembered_at' => $payments->first()->last_remembered_at,
'sent_at' => $payments->first()->last_remembered_at,
]);
foreach ($payments as $payment) {
$subscription = DB::table('subscriptions')->where('id', $payment->subscription_id)->first();
$subscriptionChildren = DB::table('subscription_children')->where('parent_id', $subscription->id)->get();
$paymentMember = DB::table('members')->where('id', $payment->member_id)->first();
foreach ($subscriptionChildren as $child) {
DB::table('invoice_positions')->insert([
'invoice_id' => $invoiceId,
'description' => str($child->name)->replace('{name}', $paymentMember->firstname . ' ' . $paymentMember->lastname)->replace('{year}', $payment->nr),
'price' => $child->amount,
'member_id' => $member->id,
]);
}
}
}
Schema::dropIfExists('payments');
Schema::table('subscriptions', function (Blueprint $table) {
$table->dropColumn('split');
$table->dropColumn('for_promise');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('subscriptions', function (Blueprint $table) {
$table->boolean('split')->default(false);
$table->boolean('for_promise')->default(false);
});
Schema::dropIfExists('invoice_positions');
Schema::dropIfExists('invoices');
Schema::create('payments', function ($table) {
$table->id();
$table->string('nr');
$table->integer('subscription_id');
$table->json('invoice_data');
});
}
};

View File

@ -30,7 +30,6 @@ services:
REDIS_HOST: redis
volumes:
- ./data/storage:/app/storage/app
- ./data/plugins:/app/plugins
- ./data/cookies:/app/packages/laravel-nami/.cookies
horizon:
@ -51,7 +50,6 @@ services:
REDIS_HOST: redis
volumes:
- ./data/storage:/app/storage/app
- ./data/plugins:/app/plugins
- ./data/cookies:/app/packages/laravel-nami/.cookies
schedule:
@ -72,7 +70,6 @@ services:
REDIS_HOST: redis
volumes:
- ./data/storage:/app/storage/app
- ./data/plugins:/app/plugins
- ./data/cookies:/app/packages/laravel-nami/.cookies
db:

13
package-lock.json generated
View File

@ -27,7 +27,6 @@
"devDependencies": {
"autoprefixer": "^10.4.14",
"axios": "^1.4.0",
"dayjs": "^1.11.10",
"eslint": "^8.43.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-vue": "^8.7.1",
@ -1546,12 +1545,6 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz",
"integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ=="
},
"node_modules/dayjs": {
"version": "1.11.10",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz",
"integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==",
"dev": true
},
"node_modules/debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
@ -5172,12 +5165,6 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz",
"integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ=="
},
"dayjs": {
"version": "1.11.10",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz",
"integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==",
"dev": true
},
"debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",

View File

@ -14,7 +14,6 @@
"devDependencies": {
"autoprefixer": "^10.4.14",
"axios": "^1.4.0",
"dayjs": "^1.11.10",
"eslint": "^8.43.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-vue": "^8.7.1",

@ -1 +1 @@
Subproject commit af626f5d3a14a365e97dc6437025a0b1da6b42bc
Subproject commit c5ea29af1bb1591238bb037da93739f5bd874334

@ -1 +1 @@
Subproject commit b4dbd7d3125aca2c16ca9f99ec81c12a46a18e3b
Subproject commit 6f162102ef7ceca41822d18c3e694abd926f550b

View File

@ -4,9 +4,6 @@ includes:
parameters:
stubFiles:
- tests/stub/phpstan/TestResponse.stub
paths:
- app
- tests
@ -116,6 +113,11 @@ parameters:
count: 1
path: app/Member/Member.php
-
message: "#^Method App\\\\Member\\\\Member\\:\\:createPayment\\(\\) has parameter \\$attributes with no value type specified in iterable type array\\.$#"
count: 1
path: app/Member/Member.php
-
message: "#^Unsafe usage of new static\\(\\)\\.$#"
count: 1
@ -131,6 +133,11 @@ parameters:
count: 1
path: app/Membership/MembershipResource.php
-
message: "#^Method App\\\\Payment\\\\PaymentResource\\:\\:toArray\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: app/Payment/PaymentResource.php
-
message: "#^Method App\\\\Payment\\\\SubscriptionResource\\:\\:toArray\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
@ -171,6 +178,11 @@ parameters:
count: 1
path: database/factories/NationalityFactory.php
-
message: "#^Method Database\\\\Factories\\\\Payment\\\\StatusFactory\\:\\:definition\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: database/factories/Payment/StatusFactory.php
-
message: "#^Call to an undefined method Phake\\\\Proxies\\\\StubberProxy.*#"
@ -292,6 +304,91 @@ parameters:
count: 1
path: packages/laravel-nami/src/Gender.php
-
message: "#^Method Zoomyboy\\\\LaravelNami\\\\Logger\\:\\:__construct\\(\\) has parameter \\$options with no type specified\\.$#"
count: 1
path: packages/laravel-nami/src/Logger.php
-
message: "#^Method Zoomyboy\\\\LaravelNami\\\\Logger\\:\\:__construct\\(\\) has parameter \\$response with no type specified\\.$#"
count: 1
path: packages/laravel-nami/src/Logger.php
-
message: "#^Method Zoomyboy\\\\LaravelNami\\\\Logger\\:\\:__construct\\(\\) has parameter \\$title with no type specified\\.$#"
count: 1
path: packages/laravel-nami/src/Logger.php
-
message: "#^Method Zoomyboy\\\\LaravelNami\\\\Logger\\:\\:__construct\\(\\) has parameter \\$url with no type specified\\.$#"
count: 1
path: packages/laravel-nami/src/Logger.php
-
message: "#^Method Zoomyboy\\\\LaravelNami\\\\Logger\\:\\:fromHttp\\(\\) has no return type specified\\.$#"
count: 1
path: packages/laravel-nami/src/Logger.php
-
message: "#^Method Zoomyboy\\\\LaravelNami\\\\Logger\\:\\:http\\(\\) has no return type specified\\.$#"
count: 1
path: packages/laravel-nami/src/Logger.php
-
message: "#^Method Zoomyboy\\\\LaravelNami\\\\Logger\\:\\:http\\(\\) has parameter \\$options with no type specified\\.$#"
count: 1
path: packages/laravel-nami/src/Logger.php
-
message: "#^Method Zoomyboy\\\\LaravelNami\\\\Logger\\:\\:http\\(\\) has parameter \\$response with no type specified\\.$#"
count: 1
path: packages/laravel-nami/src/Logger.php
-
message: "#^Method Zoomyboy\\\\LaravelNami\\\\Logger\\:\\:http\\(\\) has parameter \\$title with no type specified\\.$#"
count: 1
path: packages/laravel-nami/src/Logger.php
-
message: "#^Method Zoomyboy\\\\LaravelNami\\\\Logger\\:\\:http\\(\\) has parameter \\$url with no type specified\\.$#"
count: 1
path: packages/laravel-nami/src/Logger.php
-
message: "#^Method Zoomyboy\\\\LaravelNami\\\\Logger\\:\\:level\\(\\) has no return type specified\\.$#"
count: 1
path: packages/laravel-nami/src/Logger.php
-
message: "#^Property Zoomyboy\\\\LaravelNami\\\\Logger\\:\\:\\$errors has no type specified\\.$#"
count: 1
path: packages/laravel-nami/src/Logger.php
-
message: "#^Property Zoomyboy\\\\LaravelNami\\\\Logger\\:\\:\\$options has no type specified\\.$#"
count: 1
path: packages/laravel-nami/src/Logger.php
-
message: "#^Property Zoomyboy\\\\LaravelNami\\\\Logger\\:\\:\\$response has no type specified\\.$#"
count: 1
path: packages/laravel-nami/src/Logger.php
-
message: "#^Property Zoomyboy\\\\LaravelNami\\\\Logger\\:\\:\\$title has no type specified\\.$#"
count: 1
path: packages/laravel-nami/src/Logger.php
-
message: "#^Property Zoomyboy\\\\LaravelNami\\\\Logger\\:\\:\\$url has no type specified\\.$#"
count: 1
path: packages/laravel-nami/src/Logger.php
-
message: "#^Unsafe usage of new static\\(\\)\\.$#"
count: 1
path: packages/laravel-nami/src/Logger.php
-
message: "#^Method Zoomyboy\\\\LaravelNami\\\\LoginException\\:\\:setReason\\(\\) has no return type specified\\.$#"
count: 1
@ -477,6 +574,11 @@ parameters:
count: 1
path: app/Contribution/ContributionFactory.php
-
message: "#^Return type of call to method Illuminate\\\\Support\\\\Collection\\<int,class\\-string\\<App\\\\Invoice\\\\Invoice\\>\\>\\:\\:map\\(\\) contains unresolvable type\\.$#"
count: 1
path: app/Payment/ActionFactory.php
-
message: "#^Return type of call to method Illuminate\\\\Support\\\\Collection\\<int,class\\-string\\<App\\\\Setting\\\\LocalSettings\\>\\>\\:\\:map\\(\\) contains unresolvable type\\.$#"
count: 1

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,40 +1,18 @@
<template>
<button v-if="$attrs.onClick" v-tooltip="menuStore.tooltipsVisible ? slotContent : ''" class="btn label" v-bind="$attrs" :class="colors[color]">
<button class="btn label" v-bind="$attrs" :class="colors[color]" v-tooltip="menuStore.tooltipsVisible ? slotContent : ''" v-if="$attrs.onClick">
<ui-sprite v-show="icon" class="w-3 h-3 xl:mr-2" :src="icon"></ui-sprite>
<span class="hidden xl:inline">
<slot></slot>
</span>
<span class="hidden xl:inline"><slot></slot></span>
</button>
<i-link v-if="!$attrs.onClick && !asA" v-tooltip="menuStore.tooltipsVisible ? slotContent : ''" :href="href" class="btn label" v-bind="$attrs" :class="colors[color]">
<i-link :href="href" class="btn label" v-bind="$attrs" :class="colors[color]" v-tooltip="menuStore.tooltipsVisible ? slotContent : ''" v-else>
<ui-sprite v-show="icon" class="w-3 h-3 xl:mr-2" :src="icon"></ui-sprite>
<span class="hidden xl:inline">
<slot></slot>
</span>
<span class="hidden xl:inline"><slot></slot></span>
</i-link>
<a v-if="asA" v-tooltip="menuStore.tooltipsVisible ? slotContent : ''" :href="href" target="_BLANK" class="btn label" v-bind="$attrs" :class="colors[color]">
<ui-sprite v-show="icon" class="w-3 h-3 xl:mr-2" :src="icon"></ui-sprite>
<span class="hidden xl:inline">
<slot></slot>
</span>
</a>
</template>
<script>
import {menuStore} from '../../stores/menuStore.js';
export default {
props: {
asA: {
type: Boolean,
default: () => false,
},
href: {
required: false,
default: () => '#',
},
icon: {},
color: {},
},
data: function () {
return {
menuStore: menuStore(),
@ -45,11 +23,19 @@ export default {
},
};
},
props: {
href: {
required: false,
default: () => '#',
},
icon: {},
color: {},
},
computed: {
slotContent() {
return this.$slots.default()[0].children;
},
}
},
};
</script>

View File

@ -1,10 +1,10 @@
<template>
<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')">
<ui-sprite src="close" class="text-zinc-400 w-6 h-6"></ui-sprite>
</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">
<slot></slot>
</div>
@ -15,14 +15,7 @@
<script>
export default {
props: {
heading: {
type: String,
default: () => '',
},
innerWidth: {
default: () => 'max-w-xl',
type: String,
},
heading: {},
},
};
</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 useQueueEvents from './useQueueEvents.js';
export function useIndex(props, siteName) {
const axios = inject('axios');
const {startListener, stopListener} = useQueueEvents(siteName, () => reload(false));
const rawProps = JSON.parse(JSON.stringify(props));
const inner = {
@ -57,6 +56,20 @@ export function useIndex(props, siteName) {
reload(true);
}
function requestCallback(successMessage, failureMessage) {
return {
onSuccess: () => {
this.$success(successMessage);
reload(false);
},
onFailure: () => {
this.$error(failureMessage);
reload(false);
},
preserveState: true,
};
}
startListener();
onBeforeUnmount(() => stopListener());
@ -66,12 +79,12 @@ export function useIndex(props, siteName) {
can,
getFilter,
setFilter,
requestCallback,
meta: inner.meta,
filterString,
router,
toFilterString,
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="/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="/invoice" menu="invoice" icon="moneypaper">Rechnungen</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="/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,121 +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>
<page-toolbar-button :href="meta.links.masspdf" color="primary" icon="plus" as-a>Post-Briefe abrufen</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 col-span-full">
<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" label="Adresse" class="col-span-full" 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>
<f-text id="mail_email" v-model="single.mail_email" name="mail_email" label="E-Mail-Adresse" class="col-span-full"></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>
<f-text id="usage" v-model="single.usage" name="usage" label="Verwendungszweck" 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="`Anschauen`" :href="invoice.links.pdf" target="_BLANK" class="inline-flex btn btn-info btn-sm"><ui-sprite src="eye"></ui-sprite></a>
<a v-tooltip="`Erinnerung anschauen`" :href="invoice.links.rememberpdf" target="_BLANK" class="ml-2 inline-flex btn btn-info btn-sm"><ui-sprite src="document"></ui-sprite></a>
<a v-tooltip="`Bearbeiten`" href="#" class="ml-2 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

@ -1,35 +0,0 @@
<template>
<page-header title="Zahlungen" @close="$emit('close')"> </page-header>
<div class="grow">
<table class="custom-table custom-table-light custom-table-sm text-sm">
<thead>
<th>Beschreibung</th>
<th>Status</th>
<th>Beitrag</th>
</thead>
<tr v-for="(position, index) in data" :key="index">
<td v-text="position.description"></td>
<td v-text="position.invoice.status"></td>
<td v-text="position.price_human"></td>
</tr>
</table>
</div>
</template>
<script setup>
defineEmits(['close']);
import { useApiIndex } from '../../composables/useApiIndex.js';
const props = defineProps({
url: {
type: String,
required: true,
},
});
const { data, reload } = useApiIndex(props.url, 'payment');
await reload();
</script>

View File

@ -1,25 +1,24 @@
<template>
<page-header title="Mitgliedschaften" @close="$emit('close')">
<template #toolbar>
<page-toolbar-button v-if="single === null" color="primary" icon="plus" @click.prevent="create">Neue
Mitgliedschaft</page-toolbar-button>
<page-toolbar-button v-if="single !== null" color="primary" icon="undo"
@click.prevent="single = null">Zurück</page-toolbar-button>
<page-toolbar-button v-if="single === null" color="primary" icon="plus" @click.prevent="create">Neue Mitgliedschaft</page-toolbar-button>
<page-toolbar-button v-if="single !== null" color="primary" icon="undo" @click.prevent="single = null">Zurück</page-toolbar-button>
</template>
</page-header>
<form v-if="single" class="p-6 grid gap-4 justify-start" @submit.prevent="submit">
<f-select id="group_id" v-model="single.group_id" name="group_id" :options="meta.groups" label="Gruppierung"
required></f-select>
<f-select id="activity_id" v-model="single.activity_id" name="activity_id" :options="meta.activities"
label="Tätigkeit" required></f-select>
<f-select v-if="single.activity_id" id="subactivity_id" :model-value="single.subactivity_id" name="subactivity_id"
:options="meta.subactivities[single.activity_id]" label="Untertätigkeit"
@update:modelValue="setSubactivityId(single, $event)"></f-select>
<f-switch v-if="displayPromisedAt" id="has_promise" :model-value="single.promised_at !== null"
label="Hat Versprechen" @update:modelValue="setPromisedAtSwitch(single, $event)"></f-switch>
<f-text v-show="displayPromisedAt && single.promised_at !== null" id="promised_at" v-model="single.promised_at"
type="date" label="Versprechensdatum" size="sm"></f-text>
<f-select id="group_id" v-model="single.group_id" name="group_id" :options="meta.groups" label="Gruppierung" required></f-select>
<f-select id="activity_id" v-model="single.activity_id" name="activity_id" :options="meta.activities" label="Tätigkeit" required></f-select>
<f-select
v-if="single.activity_id"
id="subactivity_id"
v-model="single.subactivity_id"
name="subactivity_id"
:options="meta.subactivities[single.activity_id]"
label="Untertätigkeit"
></f-select>
<f-switch id="has_promise" :model-value="single.promised_at !== null" label="Hat Versprechen" @update:modelValue="single.promised_at = $event ? '2000-02-02' : null"></f-switch>
<f-text v-show="single.promised_at !== null" id="promised_at" v-model="single.promised_at" type="date" label="Versprechensdatum" size="sm"></f-text>
<button type="submit" class="btn btn-primary">Absenden</button>
</form>
@ -39,10 +38,8 @@
<td v-text="membership.human_date"></td>
<td><ui-boolean-display :value="membership.is_active" dark></ui-boolean-display></td>
<td class="flex space-x-1">
<a href="#" class="inline-flex btn btn-warning btn-sm" @click.prevent="edit(membership)"><ui-sprite
src="pencil"></ui-sprite></a>
<a href="#" class="inline-flex btn btn-danger btn-sm" @click.prevent="remove(membership)"><ui-sprite
src="trash"></ui-sprite></a>
<a href="#" class="inline-flex btn btn-warning btn-sm" @click.prevent="edit(membership)"><ui-sprite src="pencil"></ui-sprite></a>
<a href="#" class="inline-flex btn btn-danger btn-sm" @click.prevent="remove(membership)"><ui-sprite src="trash"></ui-sprite></a>
</td>
</tr>
</table>
@ -50,11 +47,8 @@
</template>
<script setup>
import { computed } from 'vue';
import dayjs from 'dayjs';
defineEmits(['close']);
import { useApiIndex } from '../../composables/useApiIndex.js';
import {useApiIndex} from '../../composables/useApiIndex.js';
const props = defineProps({
url: {
@ -62,25 +56,7 @@ const props = defineProps({
required: true,
},
});
const { data, meta, reload, single, create, edit, submit, remove } = useApiIndex(props.url, 'membership');
function setPromisedAtSwitch(single, value) {
single.promised_at = value ? dayjs().format('YYYY-MM-DD') : null;
}
const displayPromisedAt = computed(function () {
if (!single.value || !single.value.activity_id || !single.value.subactivity_id) {
return false;
}
return meta.value.subactivities[single.value.activity_id].find((s) => s.id === single.value.subactivity_id).is_age_group;
});
function setSubactivityId(single, value) {
single.subactivity_id = value;
return displayPromisedAt;
}
const {data, meta, reload, single, create, edit, submit, remove} = useApiIndex(props.url, 'membership');
await reload();
</script>

View File

@ -0,0 +1,66 @@
<template>
<page-header title="Zahlungen" @close="$emit('close')">
<template #toolbar>
<page-toolbar-button v-if="single === null" color="primary" icon="plus" @click.prevent="create">Neue
Zahlung</page-toolbar-button>
<page-toolbar-button v-if="single !== null" color="primary" icon="undo"
@click.prevent="cancel">Zurück</page-toolbar-button>
</template>
</page-header>
<form v-if="single" class="p-6 grid gap-4 justify-start" @submit.prevent="submit">
<f-text id="nr" v-model="single.nr" label="Jahr" required></f-text>
<f-select id="subscription_id" v-model="single.subscription_id" name="subscription_id" :options="meta.subscriptions"
label="Beitrag" required></f-select>
<f-select id="status_id" v-model="single.status_id" name="status_id" :options="meta.statuses" label="Status"
required></f-select>
<button type="submit" class="btn btn-primary">Absenden</button>
</form>
<div v-else class="grow">
<table class="custom-table custom-table-light custom-table-sm text-sm">
<thead>
<th>Nr</th>
<th>Status</th>
<th>Beitrag</th>
<th></th>
</thead>
<tr v-for="(payment, index) in data" :key="index">
<td v-text="payment.nr"></td>
<td v-text="payment.status_name"></td>
<td v-text="payment.subscription.name"></td>
<td class="flex">
<a href="#" class="inline-flex btn btn-warning btn-sm" @click.prevent="edit(payment)"><ui-sprite
src="pencil"></ui-sprite></a>
<button v-show="!payment.is_accepted" href="#" class="inline-flex btn btn-success btn-sm"
@click.prevent="accept(payment)"><ui-sprite src="check"></ui-sprite></button>
<button class="inline-flex btn btn-danger btn-sm" @click.prevent="remove(payment)"><ui-sprite
src="trash"></ui-sprite></button>
</td>
</tr>
</table>
</div>
</template>
<script setup>
defineEmits(['close']);
import { useApiIndex } from '../../composables/useApiIndex.js';
const props = defineProps({
url: {
type: String,
required: true,
},
});
const { axios, data, meta, reload, cancel, single, create, edit, submit, remove } = useApiIndex(props.url, 'payment');
async function accept(payment) {
await axios.patch(payment.links.update, { ...payment, status_id: 3 });
await reload();
}
await reload();
</script>

View File

@ -45,7 +45,7 @@
</ui-box>
<ui-box heading="Zahlungen" class="area-payments">
<payments :value="inner.invoicePositions"></payments>
<payments :value="inner.payments"></payments>
</ui-box>
<ui-box heading="Karte" container-class="grow" class="area-map hidden xl:flex">
@ -59,6 +59,7 @@
import {defineAsyncComponent} from 'vue';
export default {
props: {
data: {},
meta: {},

View File

@ -1,21 +1,17 @@
<template>
<page-layout page-class="pb-6">
<template #toolbar>
<page-toolbar-button :href="meta.links.create" color="primary" icon="plus">Mitglied
anlegen</page-toolbar-button>
<page-toolbar-button v-if="hasModule('bill')" :href="meta.links.allpayment" color="primary"
icon="invoice">Rechnungen erstellen</page-toolbar-button>
<page-toolbar-button :href="meta.links.create" color="primary" icon="plus">Mitglied anlegen</page-toolbar-button>
<page-toolbar-button v-if="hasModule('bill')" :href="meta.links.allpayment" color="primary" icon="invoice">Rechnungen erstellen</page-toolbar-button>
<page-toolbar-button v-if="hasModule('bill')" :href="meta.links.sendpayment" color="info" icon="envelope">Rechnungen versenden</page-toolbar-button>
</template>
<ui-popup v-if="deleting !== null" heading="Mitglied löschen?" @close="deleting.reject()">
<div>
<p class="mt-4">Das Mitglied "{{ deleting.member.fullname }}" löschen?</p>
<p class="mt-2">Alle Zuordnungen (Ausbildungen, Rechnungen, Zahlungen, Tätigkeiten) werden ebenfalls
entfernt.</p>
<ui-note v-if="!deleting.member.has_nami" class="mt-5" type="warning"> Dieses Mitglied ist nicht in NaMi
vorhanden und wird daher nur in der AdReMa gelöscht werden. </ui-note>
<p class="mt-2">Alle Zuordnungen (Ausbildungen, Rechnungen, Zahlungen, Tätigkeiten) werden ebenfalls entfernt.</p>
<ui-note v-if="!deleting.member.has_nami" class="mt-5" type="warning"> Dieses Mitglied ist nicht in NaMi vorhanden und wird daher nur in der AdReMa gelöscht werden. </ui-note>
<ui-note v-if="deleting.member.has_nami" class="mt-5" type="danger">
Dieses Mitglied ist in NaMi vorhanden und wird daher in NaMi abgemeldet werden. Sofern
"Datenweiterverwendung" eingeschaltet ist, wird das Mitglied auf inaktiv gesetzt.
Dieses Mitglied ist in NaMi vorhanden und wird daher in NaMi abgemeldet werden. Sofern "Datenweiterverwendung" eingeschaltet ist, wird das Mitglied auf inaktiv gesetzt.
</ui-note>
<div class="grid grid-cols-2 gap-3 mt-6">
<a href="#" class="text-center btn btn-danger" @click.prevent="deleting.resolve">Mitglied loschen</a>
@ -24,22 +20,45 @@
</div>
</ui-popup>
<page-filter breakpoint="xl">
<f-text id="search" :model-value="getFilter('search')" name="search" label="Suchen …" size="sm"
@update:model-value="setFilter('search', $event)"></f-text>
<f-switch v-show="hasModule('bill')" id="ausstand" :model-value="getFilter('ausstand')" label="Nur Ausstände"
size="sm" @update:model-value="setFilter('ausstand', $event)"></f-switch>
<f-multipleselect id="group_ids" :options="meta.groups" :model-value="getFilter('group_ids')"
label="Gruppierungen" size="sm" name="group_ids"
@update:model-value="setFilter('group_ids', $event)"></f-multipleselect>
<f-select v-show="hasModule('bill')" id="billKinds" name="billKinds" :options="meta.billKinds"
:model-value="getFilter('bill_kind')" label="Rechnung" size="sm"
@update:model-value="setFilter('bill_kind', $event)"></f-select>
<f-multipleselect id="activity_ids" :options="meta.filterActivities" :model-value="getFilter('activity_ids')"
label="Tätigkeiten" size="sm" name="activity_ids"
@update:model-value="setFilter('activity_ids', $event)"></f-multipleselect>
<f-multipleselect id="subactivity_ids" :options="meta.filterSubactivities"
:model-value="getFilter('subactivity_ids')" label="Untertätigkeiten" size="sm" name="subactivity_ids"
@update:model-value="setFilter('subactivity_ids', $event)"></f-multipleselect>
<f-text id="search" :model-value="getFilter('search')" name="search" label="Suchen …" size="sm" @update:model-value="setFilter('search', $event)"></f-text>
<f-switch v-show="hasModule('bill')" id="ausstand" :model-value="getFilter('ausstand')" label="Nur Ausstände" size="sm" @update:model-value="setFilter('ausstand', $event)"></f-switch>
<f-multipleselect
id="group_ids"
:options="meta.groups"
:model-value="getFilter('group_ids')"
label="Gruppierungen"
size="sm"
name="group_ids"
@update:model-value="setFilter('group_ids', $event)"
></f-multipleselect>
<f-select
v-show="hasModule('bill')"
id="billKinds"
name="billKinds"
:options="meta.billKinds"
:model-value="getFilter('bill_kind')"
label="Rechnung"
size="sm"
@update:model-value="setFilter('bill_kind', $event)"
></f-select>
<f-multipleselect
id="activity_ids"
:options="meta.filterActivities"
:model-value="getFilter('activity_ids')"
label="Tätigkeiten"
size="sm"
name="activity_ids"
@update:model-value="setFilter('activity_ids', $event)"
></f-multipleselect>
<f-multipleselect
id="subactivity_ids"
:options="meta.filterSubactivities"
:model-value="getFilter('subactivity_ids')"
label="Untertätigkeiten"
size="sm"
name="subactivity_ids"
@update:model-value="setFilter('subactivity_ids', $event)"
></f-multipleselect>
<button class="btn btn-primary label mr-2" @click.prevent="exportMembers">
<ui-sprite class="w-3 h-3 xl:mr-2" src="save"></ui-sprite>
<span class="hidden xl:inline">Exportieren</span>
@ -88,14 +107,11 @@
<div class="text-xs text-gray-200" v-text="member.full_address"></div>
<div class="flex items-center mt-1 space-x-4">
<tags :member="member"></tags>
<ui-label v-show="hasModule('bill')" class="text-gray-100 block" :value="member.pending_payment"
fallback=""></ui-label>
<ui-label v-show="hasModule('bill')" class="text-gray-100 block" :value="member.pending_payment" fallback=""></ui-label>
</div>
<actions class="mt-2" :member="member" @sidebar="openSidebar($event, member)" @remove="remove(member)">
</actions>
<actions class="mt-2" :member="member" @sidebar="openSidebar($event, member)" @remove="remove(member)"> </actions>
<div class="absolute right-0 top-0 h-full flex items-center mr-2">
<i-link v-tooltip="`Details`" :href="member.links.show"><ui-sprite src="chevron"
class="w-6 h-6 text-teal-100 -rotate-90"></ui-sprite></i-link>
<i-link v-tooltip="`Details`" :href="member.links.show"><ui-sprite src="chevron" class="w-6 h-6 text-teal-100 -rotate-90"></ui-sprite></i-link>
</div>
</ui-box>
</div>
@ -105,30 +121,27 @@
</div>
<ui-sidebar v-if="single !== null" @close="closeSidebar">
<member-invoice-positions v-if="single.type === 'invoicePosition'"
:url="single.model.links.invoiceposition_index" @close="closeSidebar"></member-invoice-positions>
<member-memberships v-if="single.type === 'membership'" :url="single.model.links.membership_index"
@close="closeSidebar"></member-memberships>
<member-courses v-if="single.type === 'courses'" :url="single.model.links.course_index"
@close="closeSidebar"></member-courses>
<member-payments v-if="single.type === 'payment'" :url="single.model.links.payment_index" @close="closeSidebar"></member-payments>
<member-memberships v-if="single.type === 'membership'" :url="single.model.links.membership_index" @close="closeSidebar"></member-memberships>
<member-courses v-if="single.type === 'courses'" :url="single.model.links.course_index" @close="closeSidebar"></member-courses>
</ui-sidebar>
</page-layout>
</template>
<script setup>
import MemberInvoicePositions from './MemberInvoicePositions.vue';
import MemberPayments from './MemberPayments.vue';
import MemberMemberships from './MemberMemberships.vue';
import MemberCourses from './MemberCourses.vue';
import Tags from './Tags.vue';
import Actions from './index/Actions.vue';
import { indexProps, useIndex } from '../../composables/useIndex.js';
import { ref, defineProps } from 'vue';
import {indexProps, useIndex} from '../../composables/useIndex.js';
import {ref, defineProps} from 'vue';
const single = ref(null);
const deleting = ref(null);
const props = defineProps(indexProps);
var { router, data, meta, getFilter, setFilter, filterString, reloadPage } = useIndex(props.data, 'member');
var {router, data, meta, getFilter, setFilter, filterString, reloadPage} = useIndex(props.data, 'member');
function exportMembers() {
window.open(`/member-export?filter=${filterString.value}`);
@ -136,7 +149,7 @@ function exportMembers() {
async function remove(member) {
new Promise((resolve, reject) => {
deleting.value = { resolve, reject, member };
deleting.value = {resolve, reject, member};
})
.then(() => {
router.delete(`/member/${member.id}`);

View File

@ -2,21 +2,21 @@
<div>
<table cellspacing="0" cellpadding="0" border="0" class="hidden md:table custom-table custom-table-sm text-sm">
<thead>
<th>Beschreibung</th>
<th>Nr</th>
<th>Beitrag</th>
<th>Status</th>
</thead>
<tr v-for="(position, index) in inner" :key="index">
<td v-text="position.description"></td>
<td v-text="position.price_human"></td>
<td v-text="position.invoice.status"></td>
<tr v-for="(payment, index) in inner" :key="index">
<td v-text="payment.nr"></td>
<td v-text="`${payment.subscription.name} (${payment.subscription.amount_human})`"></td>
<td v-text="payment.status_name"></td>
</tr>
</table>
<div class="md:hidden grid gap-3">
<ui-box v-for="(position, index) in inner" :key="index" class="relative" :heading="position.description" second>
<div class="text-xs text-gray-200" v-text="position.price_human"></div>
<div class="text-xs text-gray-200" v-text="position.invoice.status"></div>
<ui-box class="relative" :heading="payment.nr" v-for="(payment, index) in inner" :key="index" second>
<div class="text-xs text-gray-200" v-text="`${payment.subscription.name} (${payment.subscription.amount_human})`"></div>
<div class="text-xs text-gray-200" v-text="payment.status_name"></div>
</ui-box>
</div>
</div>
@ -24,15 +24,16 @@
<script>
export default {
props: {
value: {},
},
data: function () {
return {
inner: [],
};
},
props: {
value: {},
},
created() {
this.inner = this.value;
},

View File

@ -1,19 +1,14 @@
<template>
<div class="flex space-x-1">
<i-link v-tooltip="`Details`" :href="member.links.show" class="inline-flex btn btn-primary btn-sm"><ui-sprite
src="eye"></ui-sprite></i-link>
<i-link v-tooltip="`Bearbeiten`" :href="member.links.edit" class="inline-flex btn btn-warning btn-sm"><ui-sprite
src="pencil"></ui-sprite></i-link>
<a v-show="hasModule('bill')" v-tooltip="`Zahlungen`" href="#" class="inline-flex btn btn-info btn-sm"
@click.prevent="$emit('sidebar', 'invoicePosition')"><ui-sprite src="money"></ui-sprite></a>
<a v-show="hasModule('courses')" v-tooltip="`Ausbildungen`" href="#" class="inline-flex btn btn-info btn-sm"
@click.prevent="$emit('sidebar', 'courses')"><ui-sprite src="course"></ui-sprite></a>
<a v-tooltip="`Mitgliedschaften`" href="#" class="inline-flex btn btn-info btn-sm"
@click.prevent="$emit('sidebar', 'membership')"><ui-sprite src="user"></ui-sprite></a>
<a v-show="member.efz_link" v-tooltip="`EFZ Formular`" :href="member.efz_link"
class="inline-flex btn btn-info btn-sm"><ui-sprite src="report"></ui-sprite></a>
<a v-tooltip="`Entfernen`" href="#" class="inline-flex btn btn-danger btn-sm"
@click.prevent="$emit('remove')"><ui-sprite src="trash"></ui-sprite></a>
<i-link v-tooltip="`Details`" :href="member.links.show" class="inline-flex btn btn-primary btn-sm"><ui-sprite src="eye"></ui-sprite></i-link>
<i-link v-tooltip="`Bearbeiten`" :href="member.links.edit" class="inline-flex btn btn-warning btn-sm"><ui-sprite src="pencil"></ui-sprite></i-link>
<a v-show="hasModule('bill')" v-tooltip="`Zahlungen`" href="#" class="inline-flex btn btn-info btn-sm" @click.prevent="$emit('sidebar', 'payment')"><ui-sprite src="money"></ui-sprite></a>
<a v-show="hasModule('courses')" v-tooltip="`Ausbildungen`" href="#" class="inline-flex btn btn-info btn-sm" @click.prevent="$emit('sidebar', 'courses')"
><ui-sprite src="course"></ui-sprite
></a>
<a v-tooltip="`Mitgliedschaften`" href="#" class="inline-flex btn btn-info btn-sm" @click.prevent="$emit('sidebar', 'membership')"><ui-sprite src="user"></ui-sprite></a>
<a v-show="member.efz_link" v-tooltip="`EFZ Formular`" :href="member.efz_link" class="inline-flex btn btn-info btn-sm"><ui-sprite src="report"></ui-sprite></a>
<a v-tooltip="`Entfernen`" href="#" class="inline-flex btn btn-danger btn-sm" @click.prevent="$emit('remove')"><ui-sprite src="trash"></ui-sprite></a>
</div>
</template>
@ -22,6 +17,6 @@ defineProps({
member: {
type: Object,
required: true,
},
}
});
</script>

View File

@ -0,0 +1,30 @@
<template>
<page-layout>
<div class="grid grid-cols-2">
<div class="p-6" v-for="(type, index) in types" :key="index">
<p class="text-white" v-for="(paragraph, index) in type.text" :key="index" v-text="paragraph"></p>
<a :href="type.link.href" target="_BLANK" class="btn btn-primary mt-3 inline-block" v-text="type.link.label"></a>
</div>
</div>
</page-layout>
</template>
<script>
export default {
data: function () {
return {
inner: {},
};
},
props: {
types: {},
},
methods: {
submit() {
this.$inertia.post(`/allpayment`, this.inner);
},
},
};
</script>

View File

@ -10,23 +10,21 @@
<ui-box heading="Beitrag">
<div class="grid gap-4 sm:grid-cols-2">
<f-text id="name" v-model="inner.name" label="Name" size="sm" required></f-text>
<f-select id="fee_id" v-model="inner.fee_id" name="fee_id" :options="fees" label="Nami-Beitrag"
size="sm" required></f-select>
<f-select id="fee_id" name="fee_id" :options="fees" v-model="inner.fee_id" label="Nami-Beitrag" size="sm" required></f-select>
<f-switch id="split" label="Rechnung aufsplitten" v-model="inner.split" size="sm"></f-switch>
<f-switch id="for_promise" label="Für Versprechen benutzen" v-model="inner.for_promise" size="sm"></f-switch>
</div>
</ui-box>
<ui-box heading="Positionen">
<div class="flex flex-col space-y-4">
<div v-for="(pos, index) in inner.children" :key="index" class="flex space-x-2 items-end">
<f-text :id="`name-${index}`" v-model="pos.name" label="Name" size="sm" required></f-text>
<f-text :id="`amount-${index}`" v-model="pos.amount" label="Beitrag" size="sm" mode="area"
required></f-text>
<a href="#" class="btn btn-sm btn-danger icon flex-none"
@click.prevent="inner.children.splice(index, 1)">
<f-text :id="`amount-${index}`" v-model="pos.amount" label="Beitrag" size="sm" mode="area" required></f-text>
<a href="#" @click.prevent="inner.children.splice(index, 1)" class="btn btn-sm btn-danger icon flex-none">
<ui-sprite src="trash" class="w-5 h-5"></ui-sprite>
</a>
</div>
<a href="#" class="btn btn-sm flex btn-primary flex self-start mt-4"
@click.prevent="inner.children.push({ name: '', amount: 0 })">
<a href="#" @click.prevent="inner.children.push({name: '', amount: 0})" class="btn btn-sm flex btn-primary flex self-start mt-4">
<ui-sprite src="plus" class="w-5 h-5"></ui-sprite>
Position hinzufügen
</a>
@ -38,6 +36,11 @@
<script>
export default {
data: function () {
return {
inner: {...this.data},
};
},
props: {
data: {},
@ -45,11 +48,6 @@ export default {
mode: {},
meta: {},
},
data: function () {
return {
inner: { ...this.data },
};
},
methods: {
submit() {

View File

@ -1,5 +1,5 @@
@component('mail::message')
# {{ $invoice->greeting }},
# {{ $salutation }},
Im Anhang findet ihr die aktuelle Rechnung des Stammes Silva für das laufende Jahr. Bitte begleicht diese bis zum angegebenen Datum.

View File

@ -1,5 +1,5 @@
@component('mail::message')
# {{ $invoice->greeting }},
# {{ $salutation }},
Hiermit möchten wir euch an die noch ausstehenden Mitgliedsbeiträge des Stammes Silva für das laufende Jahr erinnern. Bitte begleicht diese bis zum angegebenen Datum.

View File

@ -1,6 +1,6 @@
\documentclass[silvaletter,12pt]{scrlttr2}
\setkomavar{subject}{<<< $getSubject >>>}
\setkomavar{subject}{<<< $subject >>>}
\setkomavar{fromname}[<<<$settings->from>>>]{<<<$settings->from_long>>>}
\setkomavar{frommobilephone}[Mobiltelefon: ]{<<<$settings->mobile>>>}
\setkomavar{fromemail}[E-Mail: ]{<<<$settings->email>>>}
@ -11,16 +11,17 @@
\setkomavar{fromlogo}{\includegraphics[width=2cm]{logo.png}} % stammeslogo
\begin{document}
\begin{letter}{<<< $toName >>>\\<<< $toAddress >>>\\<<< $toZip >>> <<< $toLocation >>>}
@foreach($pages as $page)
\begin{letter}{Familie <<< $page->familyName >>>\\<<< $page->address >>>\\<<< $page->zip >>> <<< $page->location >>>}
\sffamily
\gdef\TotalHT{0}
\opening{<<< $greeting >>>,}
\opening{Liebe Familie <<< $page->familyName >>>,}
Hiermit stellen wir Ihnen den aktuellen Mitgliedsbeitrag für den \usekomavar*{fromname} und die DPSG in Rechnung. Dieser setzt sich wie folgt zusammen:
\begin{center}
\begin{tabular}{@{}p{0.8\textwidth}|r}
@foreach($positions as $desc => $price)
@foreach($page->positions as $desc => $price)
\product{<<< $desc >>>}{<<< $price >>>}
@endforeach
\hline
@ -34,14 +35,15 @@
Kontoinhaber: & <<<$settings->from_long>>> \\
IBAN: & <<<$settings->iban>>> \\
Bic: & <<<$settings->bic>>> \\
Verwendungszweck: & <<<$usage>>>
Verwendungszweck: & <<<$page->usage>>>
\end{tabular}
Bitte nehmen Sie zur Kenntnis, dass der für jedes Mitglied obligatorische Versicherungsschutz über die DPSG nur dann für Ihr Kind / Ihre Kinder gilt, wenn der Mitgliedsbeitrag bezahlt wurde. Wenn dies nicht geschieht, müssen wir Ihr Kind / Ihre Kinder von allen Pfadfinderaktionen ausschließen. Dazu gehören sowohl die Gruppenstunden sowie Tagesaktionen als auch mehrtägige Lager.
Bei Fragen zur Rechnung können Sie mich auch persönlich erreichen.
\closing{Viele Grüße \\ Der Stammesvorstand}
\end{letter}
@endforeach
\end{document}

View File

@ -1,6 +1,6 @@
\documentclass[silvaletter,12pt]{scrlttr2}
\setkomavar{subject}{<<< $getSubject >>>}
\setkomavar{subject}{<<< $subject >>>}
\setkomavar{fromname}[<<<$settings->from>>>]{<<<$settings->from_long>>>}
\setkomavar{frommobilephone}[Mobiltelefon: ]{<<<$settings->mobile>>>}
\setkomavar{fromemail}[E-Mail: ]{<<<$settings->email>>>}
@ -11,16 +11,17 @@
\setkomavar{fromlogo}{\includegraphics[width=2cm]{logo.png}} % stammeslogo
\begin{document}
\begin{letter}{<<< $toName >>>\\<<< $toAddress >>>\\<<< $toZip >>> <<< $toLocation >>>}
@foreach($pages as $page)
\begin{letter}{Familie <<< $page->familyName >>>\\<<< $page->address >>>\\<<< $page->zip >>> <<< $page->location >>>}
\sffamily
\gdef\TotalHT{0}
\opening{<<< $greeting >>>,}
\opening{Liebe Familie <<< $page->familyName >>>,}
Ihr Mitgliedbeitrag ist noch ausstehend. Dieser setzt sich wie folgt zusammen:
\begin{center}
\begin{tabular}{@{}p{0.8\textwidth}|r}
@foreach($positions as $desc => $price)
@foreach($page->positions as $desc => $price)
\product{<<< $desc >>>}{<<< $price >>>}
@endforeach
\hline
@ -34,14 +35,15 @@
Kontoinhaber: & <<<$settings->from_long>>> \\
IBAN: & <<<$settings->iban>>> \\
Bic: & <<<$settings->bic>>> \\
Verwendungszweck: & <<<$usage>>>
Verwendungszweck: & <<<$page->usage>>>
\end{tabular}
Bitte nehmen Sie zur Kenntnis, dass der für jedes Mitglied obligatorische Versicherungsschutz über die DPSG nur dann für Ihr Kind / Ihre Kinder gilt, wenn der Mitgliedsbeitrag bezahlt wurde. Wenn dies nicht geschieht, müssen wir Ihr Kind / Ihre Kinder von allen Pfadfinderaktionen ausschließen. Dazu gehören sowohl die Gruppenstunden sowie Tagesaktionen als auch mehrtägige Lager.
Bei Fragen zur Rechnung können Sie mich auch persönlich erreichen.
\closing{Viele Grüße \\ Der Stammesvorstand}
\end{letter}
@endforeach
\end{document}

View File

@ -15,7 +15,6 @@ 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;
@ -25,14 +24,6 @@ use App\Initialize\Actions\InitializeFormAction;
use App\Initialize\Actions\NamiGetSearchLayerAction;
use App\Initialize\Actions\NamiLoginCheckAction;
use App\Initialize\Actions\NamiSearchAction;
use App\Invoice\Actions\DisplayPdfAction;
use App\Invoice\Actions\DisplayRememberpdfAction;
use App\Invoice\Actions\InvoiceDestroyAction;
use App\Invoice\Actions\InvoiceIndexAction;
use App\Invoice\Actions\InvoiceUpdateAction;
use App\Invoice\Actions\MassPostPdfAction;
use App\Invoice\Actions\MassStoreAction;
use App\Invoice\Actions\PaymentPositionIndexAction;
use App\Maildispatcher\Actions\CreateAction;
use App\Maildispatcher\Actions\DestroyAction;
use App\Maildispatcher\Actions\EditAction;
@ -53,7 +44,15 @@ use App\Membership\Actions\MembershipDestroyAction;
use App\Membership\Actions\MembershipStoreAction;
use App\Membership\Actions\MembershipUpdateAction;
use App\Membership\Actions\StoreForGroupAction;
use App\Payment\Actions\AllpaymentPageAction;
use App\Payment\Actions\AllpaymentStoreAction;
use App\Payment\Actions\IndexAction as PaymentIndexAction;
use App\Payment\Actions\PaymentDestroyAction;
use App\Payment\Actions\PaymentStoreAction;
use App\Payment\Actions\PaymentUpdateAction;
use App\Payment\SendpaymentController;
use App\Payment\SubscriptionController;
use App\Pdf\MemberPdfController;
Route::group(['namespace' => 'App\\Http\\Controllers'], function (): void {
Auth::routes(['register' => false]);
@ -69,7 +68,13 @@ Route::group(['middleware' => 'auth:web'], function (): void {
Route::resource('member', MemberController::class)->except('show', 'destroy');
Route::delete('/member/{member}', MemberDeleteAction::class);
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::get('/member/{member}/pdf', MemberPdfController::class)
->name('member.singlepdf');
Route::get('/sendpayment', [SendpaymentController::class, 'create'])->name('sendpayment.create');
Route::get('/sendpayment/pdf', [SendpaymentController::class, 'send'])->name('sendpayment.pdf');
Route::get('/member/{member}/efz', ShowEfzDocumentAction::class)->name('efz');
Route::get('/member/{member}/resync', MemberResyncAction::class)->name('member.resync');
Route::get('member-export', ExportAction::class)->name('member-export');
@ -102,21 +107,11 @@ Route::group(['middleware' => 'auth:web'], function (): void {
// ----------------------------------- group -----------------------------------
Route::get('/group', ListAction::class)->name('group.index');
// -------------------------------- allpayment ---------------------------------
Route::post('/invoice/mass-store', MassStoreAction::class)->name('invoice.mass-store');
// ---------------------------------- invoice ----------------------------------
Route::get('/invoice', InvoiceIndexAction::class)->name('invoice.index');
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');
Route::get('/invoice/{invoice}/pdf', DisplayPdfAction::class)->name('invoice.pdf');
Route::get('/invoice/{invoice}/rememberpdf', DisplayRememberpdfAction::class)->name('invoice.rememberpdf');
Route::get('/invoice/masspdf', MassPostPdfAction::class)->name('invoice.masspdf');
// ----------------------------- invoice-position ------------------------------
Route::get('/member/{member}/invoice-position', PaymentPositionIndexAction::class)->name('member.invoice-position.index');
// ---------------------------------- payment ----------------------------------
Route::get('/member/{member}/payment', PaymentIndexAction::class)->name('member.payment.index');
Route::post('/member/{member}/payment', PaymentStoreAction::class)->name('member.payment.store');
Route::patch('/payment/{payment}', PaymentUpdateAction::class)->name('payment.update');
Route::delete('/payment/{payment}', PaymentDestroyAction::class)->name('payment.destroy');
// --------------------------------- membership --------------------------------
Route::get('/member/{member}/membership', MembershipIndexAction::class)->name('member.membership.index');

View File

@ -3,8 +3,6 @@
namespace Tests\EndToEnd;
use App\Group;
use App\Invoice\Models\Invoice;
use App\Invoice\Models\InvoicePosition;
use App\Member\Member;
use App\Payment\Payment;
use Illuminate\Foundation\Testing\DatabaseMigrations;
@ -59,7 +57,9 @@ class MemberIndexTest extends TestCase
$this->withoutExceptionHandling()->login()->loginNami();
$group = Group::factory()->create();
Member::factory()->defaults()->for($group)
->has(InvoicePosition::factory()->for(Invoice::factory()))
->has(Payment::factory()->notPaid()->subscription('tollerbeitrag', [
new Child('a', 5400),
]))
->create(['firstname' => '::firstname::']);
Member::factory()->defaults()->for($group)->create(['firstname' => '::firstname::']);

View File

@ -0,0 +1,181 @@
<?php
namespace Tests\Feature\Invoice;
use App\Invoice\BillDocument;
use App\Invoice\DocumentFactory;
use App\Invoice\InvoiceSettings;
use App\Invoice\Queries\InvoiceMemberQuery;
use App\Invoice\Queries\SingleMemberQuery;
use App\Invoice\RememberDocument;
use App\Member\Member;
use App\Payment\Payment;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\RequestFactories\Child;
use Tests\TestCase;
use Zoomyboy\Tex\Tex;
class DocumentFactoryTest extends TestCase
{
use DatabaseTransactions;
public function testItDisplaysMemberInformation(): void
{
$member = Member::factory()
->defaults()
->state([
'firstname' => '::firstname::',
'lastname' => '::lastname::',
'address' => '::street::',
'zip' => '::zip::',
'location' => '::location::',
])
->has(Payment::factory()->notPaid()->nr('1995')->subscription('::subName::', [
new Child('a', 1000),
new Child('a', 500),
]))
->create();
$invoice = app(DocumentFactory::class)->singleInvoice(BillDocument::class, $this->query($member));
$invoice->assertHasAllContent([
'Rechnung',
'15.00',
'::subName:: 1995 für ::firstname:: ::lastname::',
'Mitgliedsbeitrag für ::lastname::',
'Familie ::lastname::\\\\::street::\\\\::zip:: ::location::',
]);
}
public function testItDisplaysSplitPayments(): void
{
$member = Member::factory()
->defaults()
->state([
'firstname' => '::firstname::',
'lastname' => '::lastname::',
])
->has(Payment::factory()->notPaid()->nr('1995')->subscription('::subName::', [
new Child('a', 1000),
new Child('b', 500),
], ['split' => true]))
->create();
$invoice = app(DocumentFactory::class)->singleInvoice(BillDocument::class, $this->query($member));
$invoice->assertHasAllContent([
'Rechnung',
'10.00',
'5.00',
'::subName:: (a) 1995 für ::firstname:: ::lastname::',
'::subName:: (b) 1995 für ::firstname:: ::lastname::',
'Mitgliedsbeitrag für ::lastname::',
]);
}
public function testBillSetsFilename(): void
{
$member = Member::factory()
->defaults()
->state(['lastname' => '::lastname::'])
->has(Payment::factory()->notPaid()->nr('1995'))
->create();
$invoice = app(DocumentFactory::class)->singleInvoice(BillDocument::class, $this->query($member));
$this->assertEquals('rechnung-fur-lastname.pdf', $invoice->compiledFilename());
}
public function testRememberSetsFilename(): void
{
$member = Member::factory()
->defaults()
->state(['lastname' => '::lastname::'])
->has(Payment::factory()->notPaid()->state(['last_remembered_at' => now()->subMonths(6)]))
->create();
$invoice = app(DocumentFactory::class)->singleInvoice(RememberDocument::class, $this->query($member));
$this->assertEquals('zahlungserinnerung-fur-lastname.pdf', $invoice->compiledFilename());
}
public function testItCreatesOneFileForFamilyMembers(): void
{
$firstMember = Member::factory()
->defaults()
->state(['firstname' => 'Max1', 'lastname' => '::lastname::', 'address' => '::address::', 'zip' => '12345', 'location' => '::location::'])
->has(Payment::factory()->notPaid()->nr('nr1'))
->create();
Member::factory()
->defaults()
->state(['firstname' => 'Max2', 'lastname' => '::lastname::', 'address' => '::address::', 'zip' => '12345', 'location' => '::location::'])
->has(Payment::factory()->notPaid()->nr('nr2'))
->create();
$invoice = app(DocumentFactory::class)->singleInvoice(BillDocument::class, $this->query($firstMember));
$invoice->assertHasAllContent(['Max1', 'Max2', 'nr1', 'nr2']);
}
/**
* @testWith ["App\\Invoice\\BillDocument"]
* ["App\\Invoice\\RememberDocument"]
*/
public function testItDisplaysSettings(string $type): void
{
InvoiceSettings::fake([
'from_long' => 'langer Stammesname',
'from' => 'Stammeskurz',
'mobile' => '+49 176 55555',
'email' => 'max@muster.de',
'website' => 'https://example.com',
'address' => 'Musterstr 4',
'place' => 'Münster',
'zip' => '12345',
'iban' => 'DE444',
'bic' => 'SOLSSSSS',
]);
$member = Member::factory()
->defaults()
->has(Payment::factory()->notPaid()->nr('nr2'))
->create();
$invoice = app(DocumentFactory::class)->singleInvoice($type, $this->query($member));
$invoice->assertHasAllContent([
'langer Stammesname',
'Stammeskurz',
'+49 176 55555',
'max@muster.de',
'https://example.com',
'Musterstr 4',
'Münster',
'12345',
'DE444',
'SOLSSSSS',
]);
}
public function testItGeneratesAPdf(): void
{
Tex::fake();
$member = Member::factory()
->defaults()
->has(Payment::factory()->notPaid())
->create(['lastname' => 'lastname']);
$this->withoutExceptionHandling();
$this->login()->init()->loginNami();
$response = $this->call('GET', "/member/{$member->id}/pdf", [
'type' => BillDocument::class,
]);
$this->assertEquals('application/pdf', $response->headers->get('content-type'));
$this->assertEquals('inline; filename="rechnung-fur-lastname.pdf"', $response->headers->get('content-disposition'));
}
private function query(Member $member): InvoiceMemberQuery
{
return new SingleMemberQuery($member);
}
}

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()->withMember(), 'positions')->create();
$this->delete(route('invoice.destroy', ['invoice' => $invoice]))->assertOk();
$this->assertDatabaseCount('invoices', 0);
$this->assertDatabaseCount('invoice_positions', 0);
}
}

View File

@ -1,85 +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)->withMember(), 'positions')
->to(ReceiverRequestFactory::new()->name('Familie Blabla'))
->sentAt(now()->subDay())
->via(BillKind::POST)
->status(InvoiceStatus::SENT)
->create(['usage' => 'Usa', 'mail_email' => 'a@b.de']);
$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.mail_email', 'a@b.de')
->assertInertiaPath('data.data.0.usage', 'Usa')
->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.pdf', route('invoice.pdf', ['invoice' => $invoice]))
->assertInertiaPath('data.data.0.links.rememberpdf', route('invoice.rememberpdf', ['invoice' => $invoice]))
->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.links.masspdf', route('invoice.masspdf'))
->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,
'usage' => '',
'mail_email' => '',
])
->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

@ -1,39 +0,0 @@
<?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,
'member_id' => Member::factory()->defaults()->create()->id,
'id' => null,
];
}
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]);
}
public function id(int $id): self
{
return $this->state(['id' => $id]);
}
}

View File

@ -1,55 +0,0 @@
<?php
namespace Tests\Feature\Invoice;
use App\Invoice\BillKind;
use App\Invoice\Enums\InvoiceStatus;
use Worksome\RequestFactories\RequestFactory;
class InvoiceRequestFactory extends RequestFactory
{
/** @var array<int, InvoicePositionRequestFactory> */
public $positions = [];
public function definition(): array
{
return [
'to' => ReceiverRequestFactory::new(),
'greeting' => 'Hallo Familie',
'status' => InvoiceStatus::NEW->value,
'via' => BillKind::EMAIL->value,
'positions' => [],
'usage' => $this->faker->words(4, true),
];
}
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
{
$this->positions[] = $factory;
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

@ -4,16 +4,14 @@ namespace Tests\Feature\Invoice;
use App\Invoice\Actions\InvoiceSendAction;
use App\Invoice\BillDocument;
use App\Invoice\BillKind;
use App\Invoice\Enums\InvoiceStatus;
use App\Invoice\Mails\BillMail;
use App\Invoice\Mails\RememberMail;
use App\Invoice\Models\Invoice;
use App\Invoice\Models\InvoicePosition;
use App\Invoice\RememberDocument;
use App\Member\Member;
use App\Payment\Payment;
use App\Payment\PaymentMail;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Tests\RequestFactories\Child;
use Tests\TestCase;
use Zoomyboy\Tex\Tex;
@ -21,72 +19,42 @@ class InvoiceSendActionTest extends TestCase
{
use DatabaseTransactions;
public function testItSendsInvoices(): void
public Member $member;
public function setUp(): void
{
parent::setUp();
Storage::fake('temp');
$this->withoutExceptionHandling();
$this->login()->loginNami();
$this->member = Member::factory()
->defaults()
->has(Payment::factory()->notPaid()->nr('1997')->subscription('tollerbeitrag', [
new Child('a', 5400),
]))
->emailBillKind()
->create(['firstname' => 'Lah', 'lastname' => 'Mom', 'email' => 'peter@example.com']);
}
public function testItCanCreatePdfPayments(): void
{
Mail::fake();
Artisan::call('invoice:send');
Mail::assertSent(PaymentMail::class, fn ($mail) => Storage::disk('temp')->path('rechnung-fur-mom.pdf') === $mail->filename && Storage::disk('temp')->exists('rechnung-fur-mom.pdf'));
}
public function testItCanCompileAttachment(): void
{
Mail::fake();
Tex::spy();
Storage::fake('temp');
$this->withoutExceptionHandling()->login()->loginNami();
$invoice = Invoice::factory()
->to(ReceiverRequestFactory::new()->name('Familie Muster'))
->has(InvoicePosition::factory()->withMember(), 'positions')
->via(BillKind::EMAIL)
->create(['mail_email' => 'max@muster.de']);
InvoiceSendAction::run();
Mail::assertSent(BillMail::class, fn ($mail) => $mail->build() && $mail->hasTo('max@muster.de', 'Familie Muster') && Storage::disk('temp')->path('rechnung-fur-familie-muster.pdf') === $mail->filename && Storage::disk('temp')->exists('rechnung-fur-familie-muster.pdf'));
Tex::assertCompiled(BillDocument::class, fn ($document) => 'Familie Muster' === $document->toName);
$this->assertEquals(InvoiceStatus::SENT, $invoice->fresh()->status);
$this->assertEquals(now()->format('Y-m-d'), $invoice->fresh()->sent_at->format('Y-m-d'));
}
public function testItRemembersInvoices(): void
{
Mail::fake();
Tex::spy();
Storage::fake('temp');
$this->withoutExceptionHandling()->login()->loginNami();
$invoice = Invoice::factory()
->to(ReceiverRequestFactory::new()->name('Familie Muster'))
->has(InvoicePosition::factory()->withMember(), 'positions')
->via(BillKind::EMAIL)
->status(InvoiceStatus::SENT)
->create(['sent_at' => now()->subMonths(6), 'mail_email' => 'max@muster.de', 'last_remembered_at' => now()->subMonths(6)]);
InvoiceSendAction::run();
Mail::assertSent(RememberMail::class, fn ($mail) => $mail->build() && $mail->hasTo('max@muster.de', 'Familie Muster') && Storage::disk('temp')->path('zahlungserinnerung-fur-familie-muster.pdf') === $mail->filename && Storage::disk('temp')->exists('zahlungserinnerung-fur-familie-muster.pdf'));
Tex::assertCompiled(RememberDocument::class, fn ($document) => 'Familie Muster' === $document->toName);
$this->assertEquals(now()->format('Y-m-d'), $invoice->fresh()->last_remembered_at->format('Y-m-d'));
}
public function testItDoesntRememberWhenNotDue(): void
{
Mail::fake();
$this->withoutExceptionHandling()->login()->loginNami();
Invoice::factory()
->has(InvoicePosition::factory()->withMember(), 'positions')
->via(BillKind::EMAIL)
->status(InvoiceStatus::SENT)
->create(['sent_at' => now()->subMonths(6), 'last_remembered_at' => now()->subMonth()]);
InvoiceSendAction::run();
Mail::assertNotSent(RememberMail::class);
}
public function testItDoesntSendPostInvoices(): void
{
Mail::fake();
$this->withoutExceptionHandling()->login()->loginNami();
Invoice::factory()
->has(InvoicePosition::factory()->withMember(), 'positions')
->via(BillKind::POST)
->create();
InvoiceSendAction::run();
Mail::assertNotSent(BillMail::class);
Tex::assertCompiled(BillDocument::class, fn ($document) => 'Mom' === $document->pages->first()->familyName
&& $document->pages->first()->getPositions() === ['tollerbeitrag 1997 für Lah Mom' => '54.00']
);
}
}

View File

@ -1,127 +0,0 @@
<?php
namespace Tests\Feature\Invoice;
use App\Invoice\BillKind;
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'))
->status(InvoiceStatus::PAID)
->via(BillKind::POST)
->state([
'greeting' => 'Hallo Familie',
])
->position(InvoicePositionRequestFactory::new()->description('Beitrag Abc')->price(3250)->member($member))
->create(['mail_email' => 'a@b.de'])
);
$response->assertOk();
$this->assertDatabaseHas('invoices', [
'greeting' => 'Hallo Familie',
'via' => BillKind::POST->value,
'status' => InvoiceStatus::PAID->value,
'mail_email' => 'a@b.de',
]);
$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.']
];
yield [
['via' => ''],
['via' => 'Rechnungsweg ist erforderlich.']
];
yield [
['via' => 'lala'],
['via' => 'Der gewählte Wert für Rechnungsweg ist ungültig.']
];
yield [
['usage' => ''],
['usage' => 'Verwendungszweck 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()))
->via(BillKind::POST)
->state($input)
->create()
);
$response->assertJsonValidationErrors($errors);
}
}

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()->withMember(), '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()->withMember(), '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,61 +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 Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;
class MassPostPdfActionTest extends TestCase
{
use DatabaseTransactions;
public function testItDoesntDisplayPdfWhenNoMembersFound(): void
{
$this->withoutExceptionHandling()->login()->loginNami();
$this->get(route('invoice.masspdf'))->assertStatus(204);
}
public function testItDoesntDisplayPdfWhenAllInvoicesPaid(): void
{
$this->withoutExceptionHandling()->login()->loginNami();
Invoice::factory()->has(InvoicePosition::factory()->withMember(), 'positions')->via(BillKind::POST)->status(InvoiceStatus::PAID)->create();
$this->get(route('invoice.masspdf'))->assertStatus(204);
}
public function testItDoesntDisplayEmailBills(): void
{
$this->withoutExceptionHandling()->login()->loginNami();
Invoice::factory()->has(InvoicePosition::factory()->withMember(), 'positions')->via(BillKind::EMAIL)->create();
$this->get(route('invoice.masspdf'))->assertStatus(204);
}
public function testItMergesRememberAndBill(): void
{
$this->withoutExceptionHandling()->login()->loginNami();
$invoice1 = Invoice::factory()->has(InvoicePosition::factory()->withMember(), 'positions')->status(InvoiceStatus::NEW)
->via(BillKind::POST)
->create();
$invoice2 = Invoice::factory()->has(InvoicePosition::factory()->withMember(), 'positions')->status(InvoiceStatus::SENT)
->via(BillKind::POST)
->create(['sent_at' => now()->subMonths(10), 'last_remembered_at' => now()->subMonths(4)]);
$this->get(route('invoice.masspdf'))->assertPdfPageCount(2);
$this->assertEquals(InvoiceStatus::SENT, $invoice1->fresh()->status);
$this->assertEquals(now()->format('Y-m-d'), $invoice1->fresh()->last_remembered_at->format('Y-m-d'));
$this->assertEquals(now()->format('Y-m-d'), $invoice1->fresh()->sent_at->format('Y-m-d'));
$this->assertEquals(InvoiceStatus::SENT, $invoice2->fresh()->status);
$this->assertEquals(now()->format('Y-m-d'), $invoice2->fresh()->last_remembered_at->format('Y-m-d'));
$this->assertEquals(now()->subMonths(10)->format('Y-m-d'), $invoice2->fresh()->sent_at->format('Y-m-d'));
}
}

Some files were not shown because too many files have changed in this diff Show More