Compare commits

...

46 Commits

Author SHA1 Message Date
Philipp Lang ebf856252c Add event for mass store
continuous-integration/drone/push Build is failing Details
2023-12-20 23:38:09 +01:00
Philipp Lang cc650ebf45 Add plugins dir to docker 2023-12-20 23:06:28 +01:00
Philipp Lang 267aaa1776 Only display promised_at when is_age_group
continuous-integration/drone/push Build is failing Details
2023-12-20 22:58:26 +01:00
Philipp Lang 11fe883b5c Remove split payments
continuous-integration/drone/push Build is failing Details
2023-12-20 22:11:07 +01:00
Philipp Lang acf280346f Add migration for invoices 2023-12-20 21:45:37 +01:00
Philipp Lang 3a3b20ed72 Remove Sendpayment form
continuous-integration/drone/push Build is failing Details
2023-12-19 02:45:09 +01:00
Philipp Lang 5d5b08ed78 Remove ports for socketi
continuous-integration/drone/push Build is failing Details
2023-12-19 02:19:18 +01:00
Philipp Lang 3697885bca Fix views 2023-12-19 02:18:58 +01:00
Philipp Lang 2a6fd1152b Remove old payments 2023-12-19 02:00:42 +01:00
Philipp Lang 156b92f765 Add Remember mail for sending 2023-12-18 02:17:31 +01:00
Philipp Lang afbfdf7ca2 Remove mail_name 2023-12-18 01:56:58 +01:00
Philipp Lang be29a284d5 Fix Send invoices 2023-12-18 01:15:16 +01:00
Philipp Lang 68a654494d Add display remember pdf 2023-12-18 00:16:58 +01:00
Philipp Lang f5ad0d46fd Mod Stub 2023-12-18 00:06:04 +01:00
Philipp Lang 8d0d05463f Mod usage in MassStoreAction 2023-12-17 23:29:01 +01:00
Philipp Lang 19dea7a061 Add link to invoice PDF to frontend 2023-12-17 23:27:55 +01:00
Philipp Lang f4dc8b24bc Add usage to invoice 2023-12-17 23:00:52 +01:00
Philipp Lang b0534279b6 Add DisplayPdfAction for invoices 2023-12-17 22:33:29 +01:00
Philipp Lang 551c658fa3 Lint 2023-12-17 21:59:51 +01:00
Philipp Lang 07a0c22a69 Remove old actions 2023-12-17 21:24:16 +01:00
Philipp Lang 2e8c41d5d9 Mod InvoicePosition in member overview 2023-12-17 21:13:52 +01:00
Philipp Lang 703c74a9f4 Lint 2023-12-17 02:03:39 +01:00
Philipp Lang bf8f6c87a8 Fix MemberPaymentsBlock 2023-12-17 01:49:12 +01:00
Philipp Lang 0f80844d20 Lint
continuous-integration/drone/push Build is failing Details
2023-12-17 01:10:26 +01:00
Philipp Lang a7b25e9b5f Fix: Delete invoice positions when deleting member 2023-12-17 01:02:56 +01:00
Philipp Lang 451680bd70 Add remove for invoice 2023-12-17 00:55:31 +01:00
Philipp Lang c598508ceb Add update for invoices 2023-12-17 00:45:03 +01:00
Philipp Lang 0b9eb77e77 Add: Store invoice 2023-12-16 23:53:18 +01:00
Philipp Lang ebeb9bc0b0 Add member meta to InvoiceResource 2023-12-16 23:52:41 +01:00
Philipp Lang 5a87d3e7f6 remove requestCallback from useIndex 2023-12-16 23:52:03 +01:00
Philipp Lang 02cd70e0ca Add innerWidth for Popup component 2023-12-16 22:59:25 +01:00
Philipp Lang 457d433722 Add Mets for InvoiceResource 2023-12-16 22:30:56 +01:00
Philipp Lang e37d98f168 Close popup after sending massstore 2023-12-16 20:35:40 +01:00
Philipp Lang 380c18a70e Add meta for InvoiceIndex 2023-12-16 20:35:28 +01:00
Philipp Lang 5b6380b429 Add deprecated for Status 2023-12-16 20:35:06 +01:00
Philipp Lang ff2d725714 Lint 2023-12-16 20:29:17 +01:00
Philipp Lang b738c5e6ca Add views for invoice index 2023-12-16 13:08:17 +01:00
Philipp Lang a755d63197 Add track jobs to mass store action 2023-12-16 11:44:32 +01:00
Philipp Lang 2ecdf6e362 Lint 2023-12-16 11:36:49 +01:00
Philipp Lang 58798146d4 Add via to invoice 2023-12-16 11:18:00 +01:00
Philipp Lang 27cbf8bcd9 Add InvoiceIndexAction 2023-12-16 01:13:49 +01:00
Philipp Lang 15b62e59fc Mod AllPayment so that it creates an invoice 2023-12-16 00:30:36 +01:00
Philipp Lang 5c40b4e64d Add InvoiceStoreAction
continuous-integration/drone/push Build is failing Details
2023-12-13 00:35:39 +01:00
Philipp Lang ad8511874d Add payment show pdf
continuous-integration/drone/push Build is failing Details
2023-12-10 04:38:11 +01:00
Philipp Lang 566ed704a6 Add payment_data for payments 2023-12-10 04:38:11 +01:00
Philipp Lang fc1b647b54 Lint 2023-12-10 04:38:10 +01:00
122 changed files with 3381 additions and 2835 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

@ -0,0 +1,20 @@
<?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

@ -0,0 +1,20 @@
<?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

@ -0,0 +1,50 @@
<?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

@ -0,0 +1,23 @@
<?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

@ -0,0 +1,34 @@
<?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,10 +2,13 @@
namespace App\Invoice\Actions;
use App\Invoice\BillDocument;
use App\Invoice\BillKind;
use App\Invoice\DocumentFactory;
use App\Invoice\Queries\BillKindQuery;
use App\Payment\PaymentMail;
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 Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Lorisleiva\Actions\Concerns\AsAction;
@ -32,15 +35,18 @@ class InvoiceSendAction
*/
public function handle(): int
{
foreach (app(DocumentFactory::class)->getTypes() as $type) {
$invoices = app(DocumentFactory::class)->invoiceCollection($type, new BillKindQuery(BillKind::EMAIL));
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 ($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);
}
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);
}
return 0;

View File

@ -0,0 +1,25 @@
<?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

@ -0,0 +1,33 @@
<?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

@ -0,0 +1,40 @@
<?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

@ -0,0 +1,74 @@
<?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

@ -0,0 +1,31 @@
<?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,12 +5,8 @@ namespace App\Invoice;
use App\Payment\Payment;
use Illuminate\Database\Eloquent\Relations\HasMany;
class BillDocument extends Invoice
class BillDocument extends InvoiceDocument
{
public function linkLabel(): string
{
return 'Rechnung erstellen';
}
public function getSubject(): string
{
@ -21,42 +17,4 @@ class BillDocument extends Invoice
{
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

@ -1,67 +0,0 @@
<?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

@ -0,0 +1,28 @@
<?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

@ -0,0 +1,36 @@
<?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');
}
}

View File

@ -1,124 +0,0 @@
<?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

@ -0,0 +1,89 @@
<?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

@ -0,0 +1,36 @@
<?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

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

View File

@ -0,0 +1,126 @@
<?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

@ -0,0 +1,40 @@
<?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();
}
});
}
}

View File

@ -1,76 +0,0 @@
<?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

@ -1,23 +0,0 @@
<?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

@ -1,49 +0,0 @@
<?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

@ -1,22 +0,0 @@
<?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,12 +5,8 @@ namespace App\Invoice;
use App\Payment\Payment;
use Illuminate\Database\Eloquent\Relations\HasMany;
class RememberDocument extends Invoice
class RememberDocument extends InvoiceDocument
{
public function linkLabel(): string
{
return 'Erinnerung erstellen';
}
public function getSubject(): string
{
@ -21,42 +17,4 @@ class RememberDocument extends Invoice
{
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

@ -0,0 +1,31 @@
<?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

@ -0,0 +1,84 @@
<?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

@ -0,0 +1,40 @@
<?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);
$this->jobState(...[$jobState, ...$parameters])->beforeMessage->dispatch();
tap($this->jobState(...[$jobState, ...$parameters])->beforeMessage, fn ($beforeMessage) => $beforeMessage && $beforeMessage->dispatch());;
$parameters[] = $jobId;
static::dispatch(...$parameters);
}

View File

@ -18,13 +18,14 @@ class MemberShowAction
public function handle(Member $member): array
{
return [
'data' => new MemberResource($member
->load('memberships')
->load('payments.subscription.children')
->load('nationality')
->load('region')
->load('subscription')
->load('courses.course')
'data' => new MemberResource(
$member
->load('memberships')
->load('invoicePositions.invoice')
->load('nationality')
->load('region')
->load('subscription')
->load('courses.course')
),
'meta' => MemberResource::meta(),
];
@ -33,7 +34,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,13 +110,6 @@ 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
{
@ -213,6 +206,14 @@ class Member extends Model implements Geolocatable
]);
}
/**
* @return HasMany<InvoicePosition>
*/
public function invoicePositions(): HasMany
{
return $this->hasMany(InvoicePosition::class);
}
/**
* @return BelongsTo<Confession, self>
*/
@ -261,14 +262,6 @@ 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>
*/
@ -288,9 +281,11 @@ 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());
@ -315,11 +310,9 @@ class Member extends Model implements Geolocatable
public function scopeWithPendingPayment(Builder $query): Builder
{
return $query->addSelect([
'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'),
'pending_payment' => InvoicePosition::selectRaw('SUM(price)')
->whereColumn('invoice_positions.member_id', 'members.id')
->whereHas('invoice', fn ($query) => $query->whereNeedsPayment())
]);
}
@ -330,9 +323,7 @@ class Member extends Model implements Geolocatable
*/
public function scopeWhereHasPendingPayment(Builder $query): Builder
{
return $query->whereHas('payments', function (Builder $q): void {
$q->whereNeedsPayment();
});
return $query->whereHas('invoicePositions', fn ($q) => $q->whereHas('invoice', fn ($q) => $q->whereNeedsPayment()));
}
/**
@ -342,8 +333,8 @@ class Member extends Model implements Geolocatable
*/
public function scopeWhereAusstand(Builder $query): Builder
{
return $query->whereHas('payments', function ($q) {
return $q->whereHas('status', fn ($q) => $q->where('is_remember', true));
return $query->whereHas('invoicePositions', function ($q) {
return $q->whereHas('invoice', fn ($query) => $query->whereNeedsPayment());
});
}
@ -357,18 +348,6 @@ 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
*
@ -506,6 +485,14 @@ 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,14 +8,13 @@ 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;
@ -72,11 +71,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,
@ -109,7 +108,7 @@ class MemberResource extends JsonResource
'group_name' => $this->group->name,
'links' => [
'membership_index' => route('member.membership.index', ['member' => $this->getModel()]),
'payment_index' => route('member.payment.index', ['member' => $this->getModel()]),
'invoiceposition_index' => route('member.invoice-position.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()]),
@ -157,8 +156,6 @@ 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])]),
'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])]),
'default' => [
'group_id' => $member->group_id,
'activity_id' => null,

View File

@ -1,25 +0,0 @@
<?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

@ -1,28 +0,0 @@
<?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

@ -1,83 +0,0 @@
<?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

@ -1,31 +0,0 @@
<?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

@ -1,42 +0,0 @@
<?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

@ -1,58 +0,0 @@
<?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

@ -1,58 +0,0 @@
<?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,76 +0,0 @@
<?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

@ -1,43 +0,0 @@
<?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

@ -1,55 +0,0 @@
<?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

@ -1,44 +0,0 @@
<?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;
}
}

View File

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

View File

@ -39,12 +39,9 @@ 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([
@ -76,12 +73,9 @@ 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,11 +26,9 @@ 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

@ -1,27 +0,0 @@
<?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

@ -0,0 +1,55 @@
<?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

@ -0,0 +1,43 @@
<?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,10 +8,8 @@ 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>
@ -82,17 +80,14 @@ class MemberFactory extends Factory
return $this->state(['nami_id' => $namiId]);
}
/**
* @param array<int, callable> $payments
*/
public function withPayments(array $payments): self
public function sameFamilyAs(Member $member): self
{
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();
}
});
return $this->state([
'firstname' => $member->firstname . 'a',
'lastname' => $member->lastname,
'address' => $member->address,
'zip' => $member->zip,
'location' => $member->location,
]);
}
}

View File

@ -1,54 +0,0 @@
<?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

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

View File

@ -21,10 +21,6 @@ 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

@ -0,0 +1,32 @@
<?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

@ -0,0 +1,122 @@
<?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,6 +30,7 @@ services:
REDIS_HOST: redis
volumes:
- ./data/storage:/app/storage/app
- ./data/plugins:/app/plugins
- ./data/cookies:/app/packages/laravel-nami/.cookies
horizon:
@ -50,6 +51,7 @@ services:
REDIS_HOST: redis
volumes:
- ./data/storage:/app/storage/app
- ./data/plugins:/app/plugins
- ./data/cookies:/app/packages/laravel-nami/.cookies
schedule:
@ -70,6 +72,7 @@ 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,6 +27,7 @@
"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",
@ -1545,6 +1546,12 @@
"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",
@ -5165,6 +5172,12 @@
"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,6 +14,7 @@
"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 c5ea29af1bb1591238bb037da93739f5bd874334
Subproject commit af626f5d3a14a365e97dc6437025a0b1da6b42bc

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

View File

@ -4,6 +4,9 @@ includes:
parameters:
stubFiles:
- tests/stub/phpstan/TestResponse.stub
paths:
- app
- tests
@ -113,11 +116,6 @@ 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
@ -133,11 +131,6 @@ 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
@ -178,11 +171,6 @@ 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.*#"
@ -304,91 +292,6 @@ 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
@ -574,11 +477,6 @@ 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

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -1,18 +1,40 @@
<template>
<button class="btn label" v-bind="$attrs" :class="colors[color]" v-tooltip="menuStore.tooltipsVisible ? slotContent : ''" v-if="$attrs.onClick">
<button v-if="$attrs.onClick" v-tooltip="menuStore.tooltipsVisible ? slotContent : ''" 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>
<span class="hidden xl:inline">
<slot></slot>
</span>
</button>
<i-link :href="href" class="btn label" v-bind="$attrs" :class="colors[color]" v-tooltip="menuStore.tooltipsVisible ? slotContent : ''" v-else>
<i-link v-if="!$attrs.onClick && !asA" v-tooltip="menuStore.tooltipsVisible ? slotContent : ''" :href="href" 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>
<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(),
@ -23,19 +45,11 @@ 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 max-w-xl">
<div class="relative rounded-lg p-8 bg-zinc-800 shadow-2xl shadow-black border border-zinc-700 border-solid w-full" :class="innerWidth">
<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 class="font-semibold text-primary-200 text-xl" v-html="heading"></h3>
<h3 v-if="heading" class="font-semibold text-primary-200 text-xl" v-html="heading"></h3>
<div class="text-primary-100">
<slot></slot>
</div>
@ -15,7 +15,14 @@
<script>
export default {
props: {
heading: {},
heading: {
type: String,
default: () => '',
},
innerWidth: {
default: () => 'max-w-xl',
type: String,
},
},
};
</script>

View File

@ -1,8 +1,9 @@
import {ref, computed, onBeforeUnmount} from 'vue';
import {ref, inject, 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 = {
@ -56,20 +57,6 @@ 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());
@ -79,12 +66,12 @@ export function useIndex(props, siteName) {
can,
getFilter,
setFilter,
requestCallback,
meta: inner.meta,
filterString,
router,
toFilterString,
reloadPage,
axios,
};
}

View File

@ -0,0 +1,108 @@
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,6 +11,7 @@
<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

@ -1,31 +0,0 @@
<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

@ -0,0 +1,121 @@
<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

@ -0,0 +1,35 @@
<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,24 +1,25 @@
<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"
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>
<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>
<button type="submit" class="btn btn-primary">Absenden</button>
</form>
@ -38,8 +39,10 @@
<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>
@ -47,8 +50,11 @@
</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: {
@ -56,7 +62,25 @@ const props = defineProps({
required: true,
},
});
const {data, meta, reload, single, create, edit, submit, remove} = useApiIndex(props.url, 'membership');
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;
}
await reload();
</script>

View File

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

View File

@ -1,17 +1,21 @@
<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 v-if="hasModule('bill')" :href="meta.links.sendpayment" color="info" icon="envelope">Rechnungen versenden</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>
</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>
@ -20,45 +24,22 @@
</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>
@ -107,11 +88,14 @@
<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>
@ -121,27 +105,30 @@
</div>
<ui-sidebar v-if="single !== null" @close="closeSidebar">
<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>
<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>
</ui-sidebar>
</page-layout>
</template>
<script setup>
import MemberPayments from './MemberPayments.vue';
import MemberInvoicePositions from './MemberInvoicePositions.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}`);
@ -149,7 +136,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>Nr</th>
<th>Beschreibung</th>
<th>Beitrag</th>
<th>Status</th>
</thead>
<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 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>
</table>
<div class="md:hidden grid gap-3">
<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 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>
</div>
</div>
@ -24,16 +24,15 @@
<script>
export default {
props: {
value: {},
},
data: function () {
return {
inner: [],
};
},
props: {
value: {},
},
created() {
this.inner = this.value;
},

View File

@ -1,14 +1,19 @@
<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', '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>
<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>
</div>
</template>
@ -17,6 +22,6 @@ defineProps({
member: {
type: Object,
required: true,
}
},
});
</script>

View File

@ -1,30 +0,0 @@
<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,21 +10,23 @@
<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" 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>
<f-select id="fee_id" v-model="inner.fee_id" name="fee_id" :options="fees" label="Nami-Beitrag"
size="sm" required></f-select>
</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="#" @click.prevent="inner.children.splice(index, 1)" class="btn btn-sm btn-danger icon flex-none">
<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)">
<ui-sprite src="trash" class="w-5 h-5"></ui-sprite>
</a>
</div>
<a href="#" @click.prevent="inner.children.push({name: '', amount: 0})" class="btn btn-sm flex btn-primary flex self-start mt-4">
<a href="#" class="btn btn-sm flex btn-primary flex self-start mt-4"
@click.prevent="inner.children.push({ name: '', amount: 0 })">
<ui-sprite src="plus" class="w-5 h-5"></ui-sprite>
Position hinzufügen
</a>
@ -36,11 +38,6 @@
<script>
export default {
data: function () {
return {
inner: {...this.data},
};
},
props: {
data: {},
@ -48,6 +45,11 @@ export default {
mode: {},
meta: {},
},
data: function () {
return {
inner: { ...this.data },
};
},
methods: {
submit() {

View File

@ -1,5 +1,5 @@
@component('mail::message')
# {{ $salutation }},
# {{ $invoice->greeting }},
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')
# {{ $salutation }},
# {{ $invoice->greeting }},
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}{<<< $subject >>>}
\setkomavar{subject}{<<< $getSubject >>>}
\setkomavar{fromname}[<<<$settings->from>>>]{<<<$settings->from_long>>>}
\setkomavar{frommobilephone}[Mobiltelefon: ]{<<<$settings->mobile>>>}
\setkomavar{fromemail}[E-Mail: ]{<<<$settings->email>>>}
@ -11,17 +11,16 @@
\setkomavar{fromlogo}{\includegraphics[width=2cm]{logo.png}} % stammeslogo
\begin{document}
@foreach($pages as $page)
\begin{letter}{Familie <<< $page->familyName >>>\\<<< $page->address >>>\\<<< $page->zip >>> <<< $page->location >>>}
\begin{letter}{<<< $toName >>>\\<<< $toAddress >>>\\<<< $toZip >>> <<< $toLocation >>>}
\sffamily
\gdef\TotalHT{0}
\opening{Liebe Familie <<< $page->familyName >>>,}
\opening{<<< $greeting >>>,}
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($page->positions as $desc => $price)
@foreach($positions as $desc => $price)
\product{<<< $desc >>>}{<<< $price >>>}
@endforeach
\hline
@ -35,7 +34,7 @@
Kontoinhaber: & <<<$settings->from_long>>> \\
IBAN: & <<<$settings->iban>>> \\
Bic: & <<<$settings->bic>>> \\
Verwendungszweck: & <<<$page->usage>>>
Verwendungszweck: & <<<$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.
@ -44,6 +43,5 @@
\closing{Viele Grüße \\ Der Stammesvorstand}
\end{letter}
@endforeach
\end{document}

View File

@ -1,6 +1,6 @@
\documentclass[silvaletter,12pt]{scrlttr2}
\setkomavar{subject}{<<< $subject >>>}
\setkomavar{subject}{<<< $getSubject >>>}
\setkomavar{fromname}[<<<$settings->from>>>]{<<<$settings->from_long>>>}
\setkomavar{frommobilephone}[Mobiltelefon: ]{<<<$settings->mobile>>>}
\setkomavar{fromemail}[E-Mail: ]{<<<$settings->email>>>}
@ -11,17 +11,16 @@
\setkomavar{fromlogo}{\includegraphics[width=2cm]{logo.png}} % stammeslogo
\begin{document}
@foreach($pages as $page)
\begin{letter}{Familie <<< $page->familyName >>>\\<<< $page->address >>>\\<<< $page->zip >>> <<< $page->location >>>}
\begin{letter}{<<< $toName >>>\\<<< $toAddress >>>\\<<< $toZip >>> <<< $toLocation >>>}
\sffamily
\gdef\TotalHT{0}
\opening{Liebe Familie <<< $page->familyName >>>,}
\opening{<<< $greeting >>>,}
Ihr Mitgliedbeitrag ist noch ausstehend. Dieser setzt sich wie folgt zusammen:
\begin{center}
\begin{tabular}{@{}p{0.8\textwidth}|r}
@foreach($page->positions as $desc => $price)
@foreach($positions as $desc => $price)
\product{<<< $desc >>>}{<<< $price >>>}
@endforeach
\hline
@ -35,7 +34,7 @@
Kontoinhaber: & <<<$settings->from_long>>> \\
IBAN: & <<<$settings->iban>>> \\
Bic: & <<<$settings->bic>>> \\
Verwendungszweck: & <<<$page->usage>>>
Verwendungszweck: & <<<$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.
@ -44,6 +43,5 @@
\closing{Viele Grüße \\ Der Stammesvorstand}
\end{letter}
@endforeach
\end{document}

View File

@ -15,6 +15,7 @@ use App\Contribution\Actions\ValidateAction as ContributionValidateAction;
use App\Course\Actions\CourseDestroyAction;
use App\Course\Actions\CourseIndexAction;
use App\Course\Actions\CourseStoreAction;
use App\Invoice\Actions\InvoiceStoreAction;
use App\Course\Actions\CourseUpdateAction;
use App\Dashboard\Actions\IndexAction as DashboardIndexAction;
use App\Efz\ShowEfzDocumentAction;
@ -24,6 +25,14 @@ 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;
@ -44,15 +53,7 @@ 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]);
@ -68,13 +69,7 @@ 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');
@ -107,11 +102,21 @@ Route::group(['middleware' => 'auth:web'], function (): void {
// ----------------------------------- group -----------------------------------
Route::get('/group', ListAction::class)->name('group.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');
// -------------------------------- 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');
// --------------------------------- membership --------------------------------
Route::get('/member/{member}/membership', MembershipIndexAction::class)->name('member.membership.index');

View File

@ -3,6 +3,8 @@
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;
@ -57,9 +59,7 @@ class MemberIndexTest extends TestCase
$this->withoutExceptionHandling()->login()->loginNami();
$group = Group::factory()->create();
Member::factory()->defaults()->for($group)
->has(Payment::factory()->notPaid()->subscription('tollerbeitrag', [
new Child('a', 5400),
]))
->has(InvoicePosition::factory()->for(Invoice::factory()))
->create(['firstname' => '::firstname::']);
Member::factory()->defaults()->for($group)->create(['firstname' => '::firstname::']);

View File

@ -1,181 +0,0 @@
<?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

@ -0,0 +1,24 @@
<?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

@ -0,0 +1,85 @@
<?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

@ -0,0 +1,39 @@
<?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

@ -0,0 +1,55 @@
<?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,14 +4,16 @@ namespace Tests\Feature\Invoice;
use App\Invoice\Actions\InvoiceSendAction;
use App\Invoice\BillDocument;
use App\Member\Member;
use App\Payment\Payment;
use App\Payment\PaymentMail;
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 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;
@ -19,42 +21,72 @@ class InvoiceSendActionTest extends TestCase
{
use DatabaseTransactions;
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
public function testItSendsInvoices(): 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();
Tex::assertCompiled(BillDocument::class, fn ($document) => 'Mom' === $document->pages->first()->familyName
&& $document->pages->first()->getPositions() === ['tollerbeitrag 1997 für Lah Mom' => '54.00']
);
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);
}
}

View File

@ -0,0 +1,127 @@
<?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

@ -0,0 +1,165 @@
<?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

@ -0,0 +1,61 @@
<?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