php-commerce/Services/InvoiceService.php
Snider 4e4337e412 feat(commerce): implement RFC.md — billing, subscriptions, Stripe + BTCPay, Commerce Matrix (#845)
Extends prior #860 DunningService with the full RFC.md surface.

Lands across 44 modified/new files:
* Contracts/PaymentGatewayContract.php — implemented by both
  Services/StripeGateway.php and Services/BTCPayGateway.php
* Boot.php — provider bindings + route groups + Commerce Matrix training
  mode middleware
* Services/WebhookService.php — DB::transaction wrapping + ProcessWebhookEvent
  job dispatched ->afterCommit; idempotency via webhook_events unique
  (gateway, event_id) — duplicates rejected silently
* Jobs/ProcessWebhookEvent.php
* DTOs/ — readonly PHP 8.2+ classes per RFC.dto.md
* Services/SubscriptionStateMachine.php — active → suspended (failed
  payment) → cancelled → expired transitions
* Services/ProrationService.php — credit unused old plan time, charge
  new plan remainder, applied via CreditNote + Invoice
* DunningService extended — 1d/3d/7d/14d retry config + cancel
* Migrations — guarded migrations for missing short-name billing tables
  (orders/payments/invoices) + RFC compatibility columns
* routes/api.php — /v1/* endpoints
* Checkout success/cancel routes
* Commerce Matrix training-mode endpoint + record-permissions logic
* Console/Commands — RFC.commands.md signatures
* Events per RFC.events.md
* Models extended

php -l clean. composer validate passes. pest unrunnable in sandbox.

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=845
2026-04-25 22:55:51 +01:00

295 lines
8.9 KiB
PHP

<?php
declare(strict_types=1);
namespace Core\Mod\Commerce\Services;
use Barryvdh\DomPDF\Facade\Pdf;
use Core\Mod\Commerce\Mail\InvoiceGenerated;
use Core\Mod\Commerce\Models\Invoice;
use Core\Mod\Commerce\Models\InvoiceItem;
use Core\Mod\Commerce\Models\Order;
use Core\Mod\Commerce\Models\Payment;
use Core\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* Invoice generation and management service.
*/
class InvoiceService
{
public function __construct(
protected TaxService $taxService,
) {}
/**
* Create an invoice from an order.
*/
public function createFromOrder(Order $order, ?Payment $payment = null): Invoice
{
$amountDue = $payment ? 0 : $order->total;
// Resolve workspace ID from polymorphic orderable (Workspace or User)
$workspaceId = $order->workspace_id;
$invoice = Invoice::create([
'workspace_id' => $workspaceId,
'order_id' => $order->id,
'payment_id' => $payment?->id,
'invoice_number' => Invoice::generateInvoiceNumber(),
'status' => $payment ? 'paid' : 'pending',
'subtotal' => $order->subtotal,
'discount_amount' => $order->discount_amount ?? 0,
'tax_amount' => $order->tax_amount ?? 0,
'tax_rate' => $order->tax_rate ?? 0,
'tax_country' => $order->tax_country,
'total' => $order->total,
'amount_paid' => $payment ? $order->total : 0,
'amount_due' => $amountDue,
'currency' => $order->currency,
'billing_name' => $order->billing_name,
'billing_email' => $order->billing_email,
'billing_address' => $order->billing_address,
'issue_date' => now(),
'due_date' => now()->addDays(config('commerce.billing.invoice_due_days', 14)),
'paid_at' => $payment ? now() : null,
]);
// Copy line items from order
foreach ($order->items as $orderItem) {
InvoiceItem::create([
'invoice_id' => $invoice->id,
'order_item_id' => $orderItem->id,
'description' => $orderItem->description,
'quantity' => $orderItem->quantity,
'unit_price' => $orderItem->unit_price,
'line_total' => $orderItem->line_total,
'tax_rate' => $order->tax_rate ?? 0,
'tax_amount' => ($orderItem->line_total - $orderItem->unit_price * $orderItem->quantity),
]);
}
return $invoice;
}
public function generateFromOrder(Order $order): Invoice
{
return $this->createFromOrder($order);
}
public function generateFromSubscription(\Core\Mod\Commerce\Models\Subscription $subscription): Invoice
{
$workspace = $subscription->workspace;
if (! $workspace) {
throw new \InvalidArgumentException('Cannot generate an invoice for a subscription without a workspace.');
}
$package = $subscription->workspacePackage?->package;
$billingCycle = $subscription->billing_cycle ?? 'monthly';
$amount = $package ? (float) $package->getPrice($billingCycle) : 0.0;
return $this->createForRenewal(
$workspace,
$amount,
$package ? "{$package->name} subscription renewal" : 'Subscription renewal'
);
}
/**
* Create an invoice for a subscription renewal.
*/
public function createForRenewal(
Workspace $workspace,
float $amount,
string $description,
?Payment $payment = null
): Invoice {
$taxResult = $this->taxService->calculate($workspace, $amount);
$total = $amount + $taxResult->taxAmount;
$amountDue = $payment ? 0 : $total;
$invoice = Invoice::create([
'workspace_id' => $workspace->id,
'invoice_number' => Invoice::generateInvoiceNumber(),
'status' => $payment ? 'paid' : 'pending',
'subtotal' => $amount,
'discount_amount' => 0,
'tax_amount' => $taxResult->taxAmount,
'tax_rate' => $taxResult->taxRate,
'tax_country' => $taxResult->jurisdiction,
'total' => $total,
'amount_paid' => $payment ? $total : 0,
'amount_due' => $amountDue,
'currency' => config('commerce.currency', 'GBP'),
'billing_name' => $workspace->billing_name,
'billing_email' => $workspace->billing_email,
'billing_address' => $workspace->getBillingAddress(),
'issue_date' => now(),
'due_date' => now()->addDays(config('commerce.billing.invoice_due_days', 14)),
'paid_at' => $payment ? now() : null,
'payment_id' => $payment?->id,
]);
InvoiceItem::create([
'invoice_id' => $invoice->id,
'description' => $description,
'quantity' => 1,
'unit_price' => $amount,
'line_total' => $total,
'tax_rate' => $taxResult->taxRate,
'tax_amount' => $taxResult->taxAmount,
]);
return $invoice;
}
/**
* Mark invoice as paid.
*/
public function markAsPaid(Invoice $invoice, Payment $payment): void
{
$invoice->markAsPaid($payment);
}
public function markPaid(Invoice $invoice, Payment $payment): void
{
$this->markAsPaid($invoice, $payment);
}
public function markOverdue(Invoice $invoice): void
{
$invoice->update(['status' => 'overdue']);
}
/**
* Mark invoice as void.
*/
public function void(Invoice $invoice): void
{
$invoice->void();
}
/**
* Generate PDF for an invoice.
*/
public function generatePdf(Invoice $invoice): string
{
$invoice->load(['workspace', 'items']);
$pdf = Pdf::loadView('commerce::pdf.invoice', [
'invoice' => $invoice,
'business' => config('commerce.tax.business'),
]);
$filename = $this->getPdfPath($invoice);
Storage::disk(config('commerce.pdf.storage_disk', 'local'))
->put($filename, $pdf->output());
// Update invoice with PDF path
$invoice->update(['pdf_path' => $filename]);
return $filename;
}
/**
* Get or generate PDF for invoice.
*/
public function getPdf(Invoice $invoice): string
{
if ($invoice->pdf_path && Storage::disk(config('commerce.pdf.storage_disk', 'local'))->exists($invoice->pdf_path)) {
return $invoice->pdf_path;
}
return $this->generatePdf($invoice);
}
/**
* Get PDF download response.
*/
public function downloadPdf(Invoice $invoice): StreamedResponse
{
$path = $this->getPdf($invoice);
return Storage::disk(config('commerce.pdf.storage_disk', 'local'))
->download($path, "invoice-{$invoice->invoice_number}.pdf");
}
/**
* Get PDF path for an invoice.
*/
protected function getPdfPath(Invoice $invoice): string
{
$basePath = config('commerce.pdf.storage_path', 'invoices');
return "{$basePath}/{$invoice->workspace_id}/{$invoice->invoice_number}.pdf";
}
/**
* Send invoice email.
*/
public function sendEmail(Invoice $invoice): void
{
if (! config('commerce.billing.send_invoice_emails', true)) {
return;
}
// Generate PDF if not exists
$this->getPdf($invoice);
// Determine recipient email
$recipientEmail = $invoice->billing_email
?? $invoice->workspace?->billing_email
?? $invoice->workspace?->owner()?->email;
if (! $recipientEmail) {
return;
}
Mail::to($recipientEmail)->queue(new InvoiceGenerated($invoice));
}
public function sendByEmail(Invoice $invoice): void
{
$this->sendEmail($invoice);
}
/**
* Get invoices for a workspace.
*/
public function getForWorkspace(Workspace $workspace, int $limit = 25): LengthAwarePaginator
{
return $workspace->invoices()
->with('items')
->latest()
->paginate($limit);
}
/**
* Get unpaid invoices for a workspace.
*/
public function getUnpaidForWorkspace(Workspace $workspace): Collection
{
return $workspace->invoices()
->pending()
->where('due_date', '>=', now())
->get();
}
/**
* Get overdue invoices for a workspace.
*/
public function getOverdueForWorkspace(Workspace $workspace): Collection
{
return $workspace->invoices()
->pending()
->where('due_date', '<', now())
->get();
}
}