'datetime:Y-m-d', 'due_at' => LeenooksCarbon::class, 'reminders' => CollectionOrNull::class, '_paid_at' => 'datetime:Y-m-d', ]; public const BILL_WEEKLY = 0; public const BILL_MONTHLY = 1; public const BILL_QUARTERLY = 2; public const BILL_SEMI_YEARLY = 3; public const BILL_YEARLY = 4; public const BILL_TWOYEARS = 5; public const BILL_THREEYEARS = 6; public const BILL_FOURYEARS = 7; public const BILL_FIVEYEARS = 8; /* Our available billing periods */ public const billing_periods = [ self::BILL_WEEKLY => [ 'name' => 'Weekly', 'interval' => 0.25, ], self::BILL_MONTHLY => [ 'name' => 'Monthly', 'interval' => 1, ], self::BILL_QUARTERLY => [ 'name' => 'Quarterly', 'interval' => 3, ], self::BILL_SEMI_YEARLY => [ 'name' => 'Semi-Annually', 'interval' => 6, ], self::BILL_YEARLY => [ 'name' => 'Annually', 'interval' => 12, ], self::BILL_TWOYEARS => [ 'name' => 'Two years', 'interval' => 24, ], self::BILL_THREEYEARS => [ 'name' => 'Three Years', 'interval' => 36, ], self::BILL_FOURYEARS => [ 'name' => 'Four Years', 'interval' => 48, ], SELF::BILL_FIVEYEARS => [ 'name' => 'Five Years', 'interval' => 60, ], ]; // Our related items that need to be updated when we call pushNew() protected $pushable = ['items_active']; protected $with = [ 'items_active:id,start_at,stop_at,quantity,price_base,discount_amt,item_type,product_id,service_id,invoice_id', 'items_active.taxes:id,invoice_item_id,amount,tax_id', 'items_active.product:id', 'items_active.product.translate:id,product_id,name_short,name_detail', 'payment_items_active:id,amount,payment_id,invoice_id', 'payment_items_active.payment:id,paid_at', ]; /* STATIC METHODS */ public static function boot() { parent::boot(); static::created(function($model) { // Send an email to an admin that the invoice was created $uo = User::where('email',config('osb.admin'))->sole(); Mail::to($uo->email) ->send(new InvoiceGeneratedAdmin($model)); // @todo Queue an email to the user }); } /** * This works out what multiplier to use to change billing periods * * @param int $source * @param int $target * @return float */ public static function billing_change(int $source,int $target): float { return Arr::get(self::billing_periods,$target.'.interval')/Arr::get(self::billing_periods,$source.'.interval'); } /** * Return the name for the billing interval * * @param int $interval * @return string */ public static function billing_name(int $interval): string { $interval = collect(self::billing_periods)->get($interval); return Arr::get($interval,'name','Unknown'); } /** * Return the number of months in the billing interval * * @param int $interval * @return int */ public static function billing_period(int $interval): int { $interval = collect(self::billing_periods)->get($interval); return Arr::get($interval,'interval',0); } /** * Given a contract in months, this will calculate the number of billing intervals required * * @param int $contract_term * @param int $source * @return int */ public static function billing_term(int $contract_term,int $source): int { return ceil(($contract_term ?: 1)/(Arr::get(self::billing_periods,$source.'.interval') ?: 1)); } /** * Work out the time period for a particular date and invoice period * * @param \Leenooks\Carbon $date * @param int $interval * @param bool $strict * @return Collection * @throws \Exception */ public static function invoice_period(Carbon $date,int $interval,bool $strict): Collection { $date_start = $date->clone(); $date_end = $date->clone(); switch ($interval) { case self::BILL_WEEKLY: $result = collect([ 'start' => $strict ? $date_start->startOfWeek() : $date_start, 'end'=> $strict ? $date_end->endOfWeek() : $date_end->addWeek()->subDay() ]); break; case self::BILL_MONTHLY: $result = collect([ 'start' => $strict ? $date_start->startOfMonth() : $date_start, 'end' => $strict ? $date_end->endOfMonth() : $date_end->addMonth()->subDay() ]); break; case self::BILL_QUARTERLY: $result = collect([ 'start' => $strict// The service charges ? $date_start->startOfQuarter() : $date_start, 'end' => $strict ? $date_end->endOfQuarter() : $date_end->addQuarter()->subDay() ]); break; case self::BILL_SEMI_YEARLY: $result = collect([ 'start' => $strict ? $date_start->startOfHalf() : $date_start, 'end' => $strict ? $date_end->endOfHalf() : $date_end->addQuarters(2)->subDay() ]); break; case self::BILL_YEARLY: $result = collect([ 'start' => $strict ? $date_start->startOfYear() : $date_start, 'end' => $strict ? $date_end->endOfYear() : $date_end->addYear()->subDay() ]); break; case self::BILL_TWOYEARS: if (! $strict) { $result = collect([ 'start' => $date_start, 'end' => $date_end->addYears(2)->subDay(), ]); } else { $data_end = $date_end->addYears(2)->subDay()->endOfYear(); // Make sure we end on an even year if ($data_end->clone()->addDay()->year%2) $data_end = $data_end->subYear(); $result = collect([ 'start' => $data_end->clone()->subYears(2)->addDay(), 'end' => $data_end, ]); } break; // NOTE: price_recur_strict ignored case self::BILL_THREEYEARS: $result = collect([ 'start' => $date_start, 'end' => $date_end->addYears(3)->subDay(), ]); break; // NOTE: price_recur_strict ignored case self::BILL_FOURYEARS: $result = collect([ 'start' => $date_start, 'end' => $date_end->addYears(4)->subDay(), ]); break; // NOTE: price_recur_strict ignored case self::BILL_FIVEYEARS: $result = collect([ 'start' => $date_start, 'end' => $date_end->addYears(5)->subDay(), ]); break; default: throw new \Exception('Unknown recur_schedule: '.$interval); } return $result; } /** * @param \Leenooks\Carbon $start Start Date * @param Carbon $end End Date * @param Collection $period * @return float * @throws \Exception */ public static function invoice_quantity(Carbon $start,Carbon $end,Collection $period): float { if ($start->lessThan(Arr::get($period,'start')) || $end->greaterThan(Arr::get($period,'end'))) throw new \Exception('Billing Period differ'); $d = Arr::get($period,'start')->diffInDays(Arr::get($period,'end')); if (! $d) throw new \Exception('Start and End period dates cannot be the same'); return round(($d-Arr::get($period,'start')->diffInDays($start)-$end->diffInDays(Arr::get($period,'end')))/$d,2); } /* INTERFACES */ /** * Invoice Local ID * * @return string */ public function getLIDAttribute(): string { return sprintf('%06s',$this->id); } /** * Invoice System ID * * @return string */ public function getSIDAttribute(): string { return sprintf('%02s-%04s-%s',$this->site_id,$this->account_id,$this->getLIDAttribute()); } /* RELATIONS */ /** * Account this invoice belongs to */ public function account() { return $this->belongsTo(Account::class); } /** * Items on this invoice belongs to */ public function items() { return $this->hasMany(InvoiceItem::class) ->with(['taxes','product']); } /** * Active items on this invoice belongs to */ public function items_active() { return $this->items() ->where('active',TRUE); } /** * Payments applied to this invoice */ public function payments() { return $this->hasManyThrough(Payment::class,PaymentItem::class,NULL,'id',NULL,'payment_id') ->where('payments.active',TRUE); } /** * Payment items attached to this invoice */ public function payment_items() { return $this->hasMany(PaymentItem::class); } public function payment_items_active() { return $this->payment_items() ->where('payment_items.active',TRUE); } /** * 3rd party provider details to this invoice (eg: accounting providers) */ public function providers() { return $this->belongsToMany(ProviderOauth::class,'invoice__provider') ->where('invoice__provider.site_id',$this->site_id) ->withPivot('ref','synctoken','created_at','updated_at'); } /* SCOPES */ /** * Search for a record * * @param $query * @param string $term * @return mixed */ public function scopeSearch($query,string $term) { return $query->where('id','like','%'.$term.'%'); } /* ATTRIBUTES */ /** * Balance due on an invoice * @return float */ public function getDueAttribute(): float { return sprintf('%3.2f',$this->getTotalAttribute()-$this->getPaidAttribute()); } /** * Total of payments received for this invoice * excluding pending payments * * @return float */ public function getPaidAttribute(): float { return $this->payment_items_active->sum('amount'); } /** * Get the date that the invoice was paid in full. * We assume the last payment received pays it in full, if its fully paid. * * @return Carbon|null */ public function getPaidDateAttribute(): ?Carbon { // If the invoice still has a due balance, its not paid if ($this->getDueAttribute()) return NULL; $o = $this ->payments ->filter(fn($item)=>(! $item->pending_status)) ->last(); return $o?->paid_at; } /** * Total of pending payments received for this invoice * * @return mixed */ public function getPaidPendingAttribute(): float { return $this->payment_items ->filter(fn($item)=>$item->payment->pending_status) ->sum('amount'); } /** * Get invoice subtotal before taxes * * @return float */ public function getSubTotalAttribute(): float { return $this->items_active->sum('sub_total'); } /** * Get the invoices taxes total * * @return float */ public function getTaxTotalAttribute(): float { return $this->items_active->sum('tax'); } /** * Invoice total due * * @return float */ public function getTotalAttribute(): float { return $this->getSubTotalAttribute()+$this->getTaxTotalAttribute(); } /* METHODS */ /** * Return a download link for non-auth downloads * * @return string */ public function download_link(): string { // Re-use an existing code $io = Invite::where('for',$this->account->user->email)->first(); $tokendate = ($x=Carbon::now()->addDays(21)) > ($y=$this->due_at->addDays(21)) ? $x : $y; // Extend the expire date if ($io && ($tokendate > $io->valid_until)) { $io->valid_until = $tokendate; $io->save(); } $code = (! $io) ? Doorman::generate() ->for($this->account->user->email) ->uses(0) ->expiresOn($tokendate) ->make() ->first() ->code : $io->code; return url('u/invoice',[$this->id,'email',$code]); } /** * Return all the items on an invoice for a particular service and product * * @param Product $po * @param Service $so * @return Collection */ public function product_service_items(Product $po,Service $so): Collection { return $this ->items_active ->filter(fn($item)=>($item->product_id === $po->id) && ($item->service_id === $so->id)) ->sortBy('item_type'); } /** * @param string $key * @return array * @todo Ugly hack to update reminders */ public function reminders(string $key): array { $r = $this->reminders; if (! Arr::get($r,$key)) { $r[$key] = time(); } return $r; } /** * Automatically set our due_at at save time. * * @param array $options * @return bool * @todo Change this to a saving event */ public function save(array $options = []) { // Automatically set the date_due attribute for new records. if (! $this->exists AND ! $this->due_at) { $this->due_at = $this->items->min('start_at'); // @todo This 7 days should be sysetm configurable if (($x=Carbon::now()->addDay(7)) > $this->due_at) $this->due_at = $x; } return parent::save($options); } /** * Record the invoice being sent * * @return int */ public function send(): int { $result = Mail::to($this->account->user->email) ->send(new InvoiceEmail($this)); $this->print_status = TRUE; if ($this->reminders->has('sent')) $this->reminders->put('sent',collect($this->reminders->get('sent'))); else $this->reminders->put('sent',collect()); $this->reminders->get('sent')->push(Carbon::now()); return $result; } /** * Group the invoice items by product ID, returning the number of products and total * * @return Collection */ public function summary_products(): Collection { $return = collect(); foreach ($this->items_active->groupBy('product_id') as $id => $o) { if (! $id) { $po = new Product; $po->translate = new ProductTranslate; $po->translate->name_detail = 'Miscellanious'; } else { $po = $o->first()->product; } $po->count = count($o->pluck('service_id')->unique()); $return->push([ 'product' => $po, 'services' => $o->pluck('service_id')->unique(), 'sub_total' => $o->sum('sub_total'), 'tax_total' => $o->sum('tax'), 'total' => $o->sum('total'), ]); } return $return->sortBy('product.name'); } public function summary_other(): Collection { $result = collect(); foreach ($this->items_active->whereNull('service_id') as $o) { dd($o); $result->push([ 'description' => 'Account Items', 'sub_total' => $o->sub_total, 'tax_total' => $o->tax, 'total' => $o->total, ]); } return $result; } }