AsCollection::class, 'invoice_next_at' => LeenooksCarbon::class, // @todo Can this be removed, since start_at provides this functionality 'stop_at' => LeenooksCarbon::class, 'start_at' => LeenooksCarbon::class, ]; protected $with = [ 'type', ]; public const INACTIVE_STATUS = [ 'CANCELLED', 'ORDER-REJECTED', 'ORDER-CANCELLED', ]; /** * Valid status shows the applicable next status for an action on a service * Each status can be * * The structure of each item is: * => [ * 'next' == next possible levels, initiated by who (array). * who = 'role','system' * 'enter_method' == the method run when we enter this stage - could be used to send email for example, if this method doesnt complete successfully, we dont enter this stage * + If it returns NULL, An error condition * + If it returns FALSE, the service cannot enter this stage * + If it returns TRUE, the service has successfully changed to this stage * + If it returns a VIEW/REDIRECT, that resulting page will handle the status change * 'exit_method' == the method that determines that the order can leave this status (true = can, false = cant) * + If it returns NULL, An error condition * + If it returns FALSE, the service CANNOT leave this stage * + If it returns TRUE, the service CAN leave this stage * it will receive the next method as an argument * 'title' == title shown on the menu, for the user to choose * ] * So when an order goes to the next state, the exit method will be tested immediately (if there is only 1 exit method for the user) * to see if it can proceed further if not, it'll wait here for user/admin intervention * * @var array * @todo This needs an overhaul, its not implemented correctly. */ private const ACTION_PROGRESS = [ // Order Submitted @todo redo 'ORDER-SUBMIT' => [ 'fail'=>FALSE, // Progress to next stages by who 'next'=>[ 'ORDER-ACCEPT'=>['customer'], 'SETUP-PAYMENT-WAIT'=>['reseller','wholesaler'] ], // Manual or System moves to the next stage 'system'=>TRUE, 'method'=>'action_order_submit', 'title'=>'Order Submit', ], // Client accepts order, if performed by RW @todo redo 'ORDER-ACCEPT' => [ 'fail'=>FALSE, 'next'=>[ 'SETUP-PAYMENT-WAIT'=>['customer'], ], 'system'=>FALSE, 'method'=>'action_order_accept', 'title'=>'Client Accept Order', ], // If the product has a setup, collect payment information @todo redo 'SETUP-PAYMENT-WAIT' => [ 'fail'=>FALSE, 'next'=>[ 'PAYMENT-WAIT'=>['customer'], ], 'system'=>FALSE, 'method'=>'action_setup_payment_wait', 'title'=>'Setup Payment', ], // @todo redo 'PAYMENT-WAIT' => [ 'fail'=>FALSE, 'next'=>[ 'PAYMENT-CHECK'=>['reseller','wholesaler'], ], 'system'=>FALSE, 'method'=>'action_payment_wait', 'title'=>'Service Payment', ], // @todo redo 'PAYMENT-CHECK' => [ 'fail'=>'ORDER-HOLD', 'next'=>[ 'ORDER-SENT'=>['wholesaler'], ], 'system'=>TRUE, 'method'=>'action_payment_check', 'title'=>'Validate Payment Method', ], // Order On Hold (Reason) @todo redo 'ORDER-HOLD' => ['release'=>'ORDER-SUBMIT','update_reference'=>'ORDER-SENT'], // Order Rejected (Reason) @todo redo 'ORDER-REJECTED' => [], // Order Cancelled @todo redo 'ORDER-CANCELLED' => [], // Order Sent to Supplier @todo redo 'ORDER-SENT' => [ 'fail'=>'ORDER-HOLD', 'next'=>[ 'ORDERED'=>['wholesaler'], ], 'system'=>FALSE, 'method'=>'action_order_sent', 'title'=>'Send Order', ], // Order Confirmed by Supplier @todo redo 'ORDERED' => [ 'fail'=>false, 'next'=>[ 'PROVISION-HOLD'=>['wholesaler'], 'PROVISION-PLANNED'=>['wholesaler'], 'PROVISIONED'=>['wholesaler'], ], 'system'=>FALSE, 'method'=>'action_ordered', 'title'=>'Service Ordered', ], // Service confirmed by supplier, optional connection date @todo redo 'PROVISION-PLANNED' => [ 'fail'=>false, 'next'=>[ 'PROVISIONED'=>['wholesaler'], ], 'system'=>FALSE, 'method'=>'action_provision_planned', 'title'=>'Provision Planned', ], // Service has been provisioned by supplier @todo redo 'PROVISIONED' => [ 'fail'=>false, 'next'=>[ 'ACTIVE'=>['wholesaler'], ], 'system'=>FALSE, 'method'=>'action_provisioned', 'title'=>'Provisioned', ], // Service is Active 'ACTIVE' => [ 'next'=>[ 'CANCEL-REQUEST'=>['customer'], 'CHANGE-REQUEST'=>['customer'], ], 'exit'=>'action_active_exit', 'title'=>'Service Active', ], // Service to be Upgraded 'CANCEL-CANCEL' => [ 'next'=>[ 'ACTIVE'=>['wholesaler'], ], 'enter_method'=>'action_cancel_cancel', 'title'=>'Cancel Cancellation Request', ], // Service to be Cancelled 'CANCEL-REQUEST' => [ 'next'=>[ 'CANCEL-CANCEL'=>['wholesaler'], 'CANCEL-PENDING'=>['wholesaler'], ], 'enter_method'=>'action_request_enter_redirect', 'exit_method'=>'action_cancel_request_exit', 'title'=>'Cancel Request', ], // Service Cancellation being processed 'CANCEL-PENDING' => [ 'next'=>[ 'CANCELLED'=>['wholesaler'], ], 'enter_method'=>'action_cancel_pending_enter', 'exit_method'=>'action_cancel_pending_exit', 'title'=>'Cancel Pending', ], 'CANCELLED'=> [ 'title'=>'Service Cancelled', 'enter_method'=>'action_cancelled', ], // Service to be Upgraded 'CHANGE-CANCEL' => [ 'next'=>[ 'ACTIVE'=>['wholesaler'], ], 'enter_method'=>'action_change_cancel', 'title'=>'Cancel Change Request', ], // Service to be Upgraded 'CHANGE-REQUEST' => [ 'next'=>[ 'CHANGE-PENDING'=>['wholesaler'], 'CHANGE-CANCEL'=>['wholesaler'], ], 'enter_method'=>'action_request_enter_redirect', 'title'=>'Change Service', ], // Service is waiting on a supplier to activate a change 'CHANGE-PENDING' => [ 'next'=>[ 'ACTIVE'=>['wholesaler'], ], 'enter_method'=>'action_request_enter_redirect', 'title'=>'Activate Change', ], ]; /* STATIC */ /** * List of services that are being changed * * Services are being changed, when they are active, but their order status is not active * * @param User $uo * @return Collection */ public static function movements(User $uo): Collection { return (new self) ->ServiceActive() ->AccountUserAuthorised(NULL,$uo) ->where('order_status','!=','ACTIVE') ->with(['account','product']) ->get(); } /* INTERFACES */ /** * Service Local ID * * @return string */ public function getLIDattribute(): string { return sprintf('%05s',$this->id); } /** * Services System ID * * @return string */ public function getSIDAttribute(): string { return sprintf('%02s-%04s.%s',$this->site_id,$this->account_id,$this->getLIDattribute()); } /* RELATIONS */ /** * Account the service belongs to */ public function account() { return $this->belongsTo(Account::class); } /** * Return automatic billing details */ public function billing() { return $this->hasOne(AccountBilling::class); } /** * Return Charges associated with this Service */ public function charges() { return $this->hasMany(Charge::class) ->orderBy('created_at'); } /** * Return only the active charges */ public function charges_active() { return $this->charges() ->ServiceActive(); } /** * Return only the charges not yet processed */ public function charges_pending() { return $this->charges() ->pending(); } /** * Product changes for this service */ public function changes() { return $this->belongsToMany(Product::class,'service__change','service_id','product_id','id','id') ->where('service__change.site_id',$this->site_id) ->withPivot(['ordered_at','effective_at','ordered_by','active','complete','notes']) ->withTimestamps(); } /** * @deprecated use invoiced_items */ public function invoice_items($active=TRUE) { Log::alert('Call to deprecated functon '.__METHOD__); return $this->invoiced_items_active(); } /** * Invoices that this service is itemised on */ public function invoiced_items() { return $this->hasMany(InvoiceItem::class) ->with(['taxes']); } /** * Invoices that this service is itemised on that is active */ public function invoiced_items_active() { return $this->invoiced_items() ->where('active',TRUE); } /** * Return the extra charged items for this service (ie: item_type != 0) */ public function invoiced_extra_items() { return $this->hasMany(InvoiceItem::class) ->where('item_type','<>',0) ->orderBy('start_at','desc'); } /** * Return active extra items charged */ public function invoiced_extra_items_active() { return $this->invoiced_extra_items() ->where('active',TRUE); } /** * Return the service charged items for this service (ie: item_type == 0) */ public function invoiced_service_items() { return $this->hasMany(InvoiceItem::class) ->where('item_type','=',0) ->whereNotNull('start_at') ->orderBy('start_at','desc'); } /** * Return active service items charged */ public function invoiced_service_items_active() { return $this->invoiced_service_items() ->where('active',TRUE); } public function invoiced_service_items_active_recent() { return $this->invoiced_service_items_active() ->limit(10); } /** * User that ordered the service */ public function orderedby() { return $this->belongsTo(User::class); } /** * Product of the service */ public function product() { return $this->belongsTo(Product::class); } /** * Return a child model with details of the service */ public function type() { return $this->morphTo(null,'model','id','service_id'); } /* SCOPES */ /** * Only query active categories * @deprecated use ScopeServiceActive */ public function scopeActive($query) { throw new \Exception('deprecated'); return $query->where( fn($query)=> $query->where($this->getTable().'.active',TRUE) ->orWhereNotIn('order_status',self::INACTIVE_STATUS) ); } /** * Find inactive services. * * @param $query * @return mixed * @deprecated use ScopeServiceInactive */ public function scopeInactive($query) { dd('deprecated'); return $query->where( fn($query)=> $query->where($this->getTable().'.active',FALSE) ->orWhereIn('order_status',self::INACTIVE_STATUS) ); } /** * Search for a record * * @param $query * @param string $term * @return mixed */ public function scopeSearch($query,string $term) { $t = '%'.$term.'%'; return $query->select('services.*') ->where('services.id','like',$t) ->leftJoin('service_broadband',['service_broadband.service_id'=>'services.id']) ->orWhere('service_broadband.service_number','ilike',$t) ->orWhere('service_broadband.service_address','ilike',$t) ->orWhere('service_broadband.ipaddress','ilike',$t) ->leftJoin('service_phone',['service_phone.service_id'=>'services.id']) ->orWhere('service_phone.service_number','ilike',$t) ->orWhere('service_phone.service_address','ilike',$t) ->leftJoin('service_domain',['service_domain.service_id'=>'services.id']) ->orWhere('service_domain.domain_name','ilike',$t) ->leftJoin('service_email',['service_email.service_id'=>'services.id']) ->orWhere('service_email.domain_name','ilike',$t) ->leftJoin('service_host',['service_host.service_id'=>'services.id']) ->orWhere('service_host.domain_name','ilike',$t); } /* ATTRIBUTES */ /** * How much do we charge for this service, base on the current recur schedule * price in the DB overrides the base price used * * @return float */ public function getBillingChargeAttribute(): float { return $this->account->taxed($this->billing_charge()); } /** * Determine a monthly price for a service, even if it is billed at a different frequency * * @return float * @throws Exception */ public function getBillingChargeNormalisedAttribute(): float { return number_format($this->getBillingChargeAttribute()*Invoice::billing_change($this->getBillingIntervalAttribute(),$this->offering->billing_interval),2); } /** * Return the service billing period * * @return int */ public function getBillingIntervalAttribute(): int { return $this->recur_schedule ?: $this->product->getBillingIntervalAttribute(); } /** * Return a human friendly name for the billing interval * * @return string */ public function getBillingIntervalStringAttribute(): string { return Invoice::billing_name($this->getBillingIntervalAttribute()); } /** * The date the contract ends * * Service contracts end the later of the start_date + contract_term or the expire date. * * @return Carbon|null */ public function getContractEndAttribute(): ?Carbon { // If we have no start date or expire date, then NULL; if (! $this->start_at && ! $this->type->expire_at) return NULL; // If we dont have a start date, use the expire date if (! $this->start_at) return $this->type->expire_at; $end = $this->start_at->addMonths($this->getContractTermAttribute()); // If we dont have an expire date, use the start date + contract_term if (! $this->type->expire_at) return $end; // We have both, so it's the later of the two. return ($end < $this->type->expire_at) ? $this->type->expire_at : $end; } /** * This function will determine the minimum contract term for a service, which is the maximum of * supplied->contract_term, or the product->type->contract_term; * * @return int */ public function getContractTermAttribute(): int { return max($this->supplied->contract_term,$this->product->type->contract_term); } /** * Return the date for the next invoice * * In priority order, the next is either * + The next day after it was last invoiced to (stop_at) * + The earlier of invoice_next_at and start_at dates * + Today * * @return Carbon */ public function getInvoiceNextAttribute(): ?Carbon { $last = $this->getInvoicedToAttribute(); if ($this->stop_at && $last->greaterThan($this->stop_at)) return NULL; return $last ? $last->addDay() : (min($this->start_at,$this->invoice_next_at) ?: Carbon::now()); } /** * Get the date that the service has been invoiced to * * @return Carbon|null */ public function getInvoicedToAttribute(): ?Carbon { return ($x=$this->invoiced_service_items_active_recent)->count() ? $x->first()->stop_at : NULL; } /** * The full name for a service, comprised of the short name and the description * * @return string */ public function getNameAttribute(): string { return $this->getNameShortAttribute().(($x=$this->getNameDetailAttribute()) ? ': '.$x : ''); } /** * Return the short name for the service. * * EG: * + For ADSL, this would be the phone number, * + For Hosting, this would be the domain name, etc */ public function getNameShortAttribute() { return $this->type->getServiceNameAttribute() ? $this->type->getServiceNameAttribute() : 'SID:'.$this->sid; } /** * Return the service description. * For: * + Broadband, this is the service address * + Domains, blank * + Hosting, blank * + SSL, blank * * @return string */ public function getNameDetailAttribute() { return ($this->type->getServiceDescriptionAttribute() !== NULL) ? $this->type->getServiceDescriptionAttribute() : 'No Description'; } /** * The product we supply for this service * * @return Type */ public function getOfferingAttribute(): Type { return $this->product->type; } public function getOrderInfoNotesAttribute(): ?string { return $this->orderInfo('notes'); } public function getOrderInfoReferenceAttribute(): ?string { return $this->orderInfo('reference'); } /** * Work out when this service has been paid to. * * @return Carbon|null */ public function getPaidToAttribute(): ?Carbon { // Last paid invoice $lastpaid = $this ->invoices() ->filter(fn($item)=>$item->_balance <= 0) ->last(); return $lastpaid ? $this->invoiced_service_items_active ->where('invoice_id',$lastpaid->id) ->where('type',0) ->max('stop_at') : NULL; } /** * Return the Service Status * * @return string */ public function getStatusAttribute(): string { return $this->active ? strtolower($this->order_status) : ((strtolower($this->order_status) === 'cancelled') ? 'cancelled' : 'inactive'); } /** * Return the product that supplies this service * ie: product/* * * @return Model */ public function getSuppliedAttribute(): Model { return $this->getOfferingAttribute()->supplied; } /* METHODS */ /** * Processing when service has been ordered. * * @return bool|null * @todo Check */ private function action_ordered(): ?bool { // N/A return TRUE; } /** * Process for an order when status ORDER-ACCEPT stage. * This method should have the client confirm/accept the order, if it was placed by a reseller/wholesaler. * * @return bool * @todo Check */ private function action_order_accept(): ?bool { // @todo TO IMPLEMENT return TRUE; } /** * Action method when status ORDER_SENT * This method redirects to a form, where updating the form will progress to the next stage. * * @todo Check */ private function action_order_sent(string $next) { // We can proceed to the ordered status if ($next == 'ORDERED' AND $this->order_info_reference) return TRUE; throw new HttpException(301,url('r/service/update',$this->id)); } /** * Action method when status ORDER_SUBMIT * * @return bool * @todo Check */ private function action_order_submit(): ?bool { // @todo TO IMPLEMENT return TRUE; } /** * Action when supplier has confirmed provisioning. * * @param string $next * @return bool * @todo Check */ private function action_provision_planned(string $next) { throw new HttpException(301,url('r/service/update',$this->id)); } /** * Process for an order when status SETUP-PAYMENT-WAIT stage. * This method should collect any setup fees payment. * * @return bool * @todo Check */ private function action_setup_payment_wait(): ?bool { // @todo TO IMPLEMENT return TRUE; } /** * Process for an order when status PAYMENT-CHECK stage. * This method should validate any payment details. * * @return bool * @todo Check */ private function action_payment_check(): ?bool { // @todo TO IMPLEMENT return TRUE; } /** * Process for an order when status PAYMENT-WAIT stage. * This method should collect any service payment details. * * @return bool * @todo Check */ private function action_payment_wait(): ?bool { // @todo TO IMPLEMENT return TRUE; } /** * Work out the next applicable actions for this service status, taking into account the user's role * * @notes * + Clients can only progress 1 step, if they are in the next step. * + Resellers/Wholesales can progress to the next Reseller/Wholesaler and any steps in between. * @return Collection */ public function actions(): Collection { $next = $this->getStageParameters($this->order_status)->get('next'); return $next ? $next->map(function($item,$key) { $authorized = FALSE; if ($x=Arr::get(self::ACTION_PROGRESS,$key)) foreach ($item as $role) { if ($this->isAuthorised($role)) { $authorized = TRUE; break; } } return $authorized ? $x['title'] : NULL; })->filter()->sort() : collect(); } /** * This service billing charge, pre-taxes * * @return float */ public function billing_charge(): float { // If recur_schedule is null, then we only bill this item once if (is_null($this->getBillingIntervalAttribute()) && $this->getInvoicedToAttribute()) $this->price = 0; return is_null($this->price) ? $this->product->getBaseChargeAttribute($this->getBillingIntervalAttribute(),$this->account->group) : $this->price; } /** * Get the stage parameters * * @param string $stage * @return Collection */ public function getStageParameters(string $stage): Collection { $result = Arr::get(self::ACTION_PROGRESS,$stage); $myrole = array_search(Auth::user()->role(),User::role_order); // If we have no valid next stage, return an empty collection. if (($myrole === FALSE) || (! $result)) return collect(); // Filter the result based on who we are $next = collect(); if (array_key_exists('next',$result) && count($result['next'])) { foreach ($result['next'] as $action => $roles) { // Can the current user do this role? $cando = FALSE; foreach ($roles as $role) { if ($myrole <= array_search($role,User::role_order)) { $cando = TRUE; break; } } if ($cando) $next->put($action,$roles); } $result['next'] = $next; } return collect($result); } /** * Return this service invoices */ public function invoices() { return $this->account ->invoiceSummary($this->invoiced_service_items_active->pluck('invoice_id')) ->get(); } /** * Determine if a service is active. It is active, if active=1, or the order_status is not in self::INACTIVE_STATUS[] * * @return bool */ public function isActive(): bool { return $this->attributes['active'] || ($this->order_status && (! in_array($this->order_status,self::INACTIVE_STATUS))); } /** * Determine if the current user has the role for this service * * @param string $role * @return bool * @todo Can we use the gates to achieve this? */ private function isAuthorised(string $role): bool { switch(Auth::user()->role()) { // Wholesalers are site admins, they can see everything case 'wholesaler': return TRUE; case 'reseller': switch ($role) { case 'wholesaler': return FALSE; // Check service is in the resellers/customers list case 'reseller': case 'customer': return TRUE; default: abort(500,'Unknown role for reseller: '.$role); } case 'customer': switch ($role) { case 'reseller': case 'wholesaler': return FALSE; // Check service is in the customers list case 'customer': return TRUE; default: abort(500,'Unknown role for customer: '.$role); } default: abort(500,'Unknown user role: ',Auth::user()->role()); } return FALSE; } /** * Do we bill for this service * * @return bool */ public function isBilled(): bool { return ! ($this->external_billing || $this->suspend_billing || ($this->price === 0)); } /** * Has the price for this service been overridden * * @return bool */ public function isChargeOverridden(): bool { return ! is_null($this->price); } /** * Identify if a service is being ordered, ie: not active yet nor cancelled * * @return bool */ public function isPending(): bool { return (! $this->active) && (! is_null($this->order_status)) && (! in_array($this->order_status,array_merge(self::INACTIVE_STATUS,['INACTIVE']))); } /** * Generate a collection of invoice_item objects that will be billed for the next invoice * * @param Carbon|null $billdate * @return Collection * @throws Exception */ public function next_invoice_items(Carbon $billdate=NULL): Collection { if ($this->wasCancelled() || (! $this->isBilled())) return collect(); $o = collect(); $invoiced_to = $this->getInvoiceNextAttribute(); // Connection charges are only charged once, so ignore if if we have already billed them if ((! $this->invoiced_items()->where('item_type',InvoiceItem::INVOICEITEM_SETUP)->count()) && (InvoiceItem::distinct('invoice_id')->where('service_id',$this->id)->count() < 2) && $this->product->getSetupChargeAttribute($this->getBillingIntervalAttribute(),$this->account->group)) { $ii = new InvoiceItem; $ii->active = TRUE; $ii->service_id = $this->id; $ii->product_id = $this->product_id; $ii->item_type = InvoiceItem::INVOICEITEM_SETUP; $ii->price_base = $this->product->getSetupChargeAttribute($this->getBillingIntervalAttribute(),$this->account->group); $ii->start_at = $this->invoice_next; $ii->stop_at = $this->invoice_next; $ii->quantity = 1; $ii->site_id = 1; // @todo $ii->addTaxes($this->account->country->taxes); $o->push($ii); } // The service charges if (is_null($billdate)) $billdate = $invoiced_to->clone()->addDays(config('osb.invoice_days')); while ($invoiced_to <= ($this->stop_at ?: $billdate)) { $ii = new InvoiceItem; $period = Invoice::invoice_period($invoiced_to,$this->getBillingIntervalAttribute(),(bool)$this->product->price_recur_strict); $ii->active = TRUE; $ii->service_id = $this->id; $ii->product_id = $this->product_id; $ii->item_type = InvoiceItem::INVOICEITEM_SERVICE; $ii->price_base = $this->billing_charge(); $ii->recur_schedule = $this->getBillingIntervalAttribute(); $ii->start_at = $invoiced_to; $ii->stop_at = ($this->stop_at && ($this->stop_at < Arr::get($period,'end'))) ? $this->stop_at : Arr::get($period,'end'); $ii->quantity = Invoice::invoice_quantity($ii->start_at,$ii->stop_at,$period); $ii->site_id = 1; // @todo $ii->addTaxes($this->account->country->taxes); $o->push($ii); $invoiced_to = $ii->stop_at ->clone() ->addDay() ->startOfDay(); } // Add additional charges foreach ($this->charges->filter(function($item) { return $item->unprocessed; }) as $oo) { $ii = new InvoiceItem; $ii->active = TRUE; $ii->service_id = $oo->service_id; $ii->product_id = $this->product_id; $ii->quantity = $oo->quantity; $ii->item_type = $oo->type; $ii->price_base = $oo->amount; $ii->start_at = $oo->start_at; $ii->stop_at = $oo->stop_at; $ii->module_id = 30; // @todo This shouldnt be hard coded $ii->module_ref = $oo->id; $ii->site_id = 1; // @todo $ii->addTaxes($this->account->country->taxes); $o->push($ii); } return $o; } /** * Extract data from the order_info column * * @param string $key * @return string|null * @todo This should be a json column, so we shouldnt need this by using order_info->key */ private function orderInfo(string $key): ?string { return $this->order_info ? $this->order_info->get($key) : NULL; } /** * Service that was cancelled or never provisioned * * @return bool */ public function wasCancelled(): bool { return in_array($this->order_status,self::INACTIVE_STATUS); } }