From c68825d8a644b74ab925838c049be907a6b11543 Mon Sep 17 00:00:00 2001 From: Hillel Coren Date: Thu, 31 Aug 2017 15:55:15 +0300 Subject: [PATCH] Support importing Stripe customer cards --- app/Constants.php | 2 + app/Http/Requests/CreateCustomerRequest.php | 33 ++++++++++++ app/Http/Requests/CustomerRequest.php | 8 +++ app/Models/AccountGatewayToken.php | 26 +++++++++ app/Models/Client.php | 10 ++-- app/Models/PaymentMethod.php | 15 ++++++ app/Models/PaymentType.php | 33 ++++++++++++ app/Ninja/Import/BaseTransformer.php | 32 +++++++++++ .../Import/Stripe/CustomerTransformer.php | 54 +++++++++++++++++++ .../PaymentDrivers/BasePaymentDriver.php | 33 ------------ .../PaymentDrivers/BraintreePaymentDriver.php | 3 +- .../PaymentDrivers/StripePaymentDriver.php | 3 +- .../PaymentDrivers/WePayPaymentDriver.php | 3 +- app/Ninja/Repositories/ContactRepository.php | 7 +++ app/Ninja/Repositories/CustomerRepository.php | 42 +++++++++++++++ app/Policies/CustomerPolicy.php | 7 +++ app/Providers/AuthServiceProvider.php | 1 + app/Services/ImportService.php | 50 ++++++++++++++++- resources/lang/en/texts.php | 4 ++ .../views/accounts/import_export.blade.php | 3 +- 20 files changed, 325 insertions(+), 44 deletions(-) create mode 100644 app/Http/Requests/CreateCustomerRequest.php create mode 100644 app/Http/Requests/CustomerRequest.php create mode 100644 app/Ninja/Import/Stripe/CustomerTransformer.php create mode 100644 app/Ninja/Repositories/CustomerRepository.php create mode 100644 app/Policies/CustomerPolicy.php diff --git a/app/Constants.php b/app/Constants.php index f986319fcdef..1cb133869d66 100644 --- a/app/Constants.php +++ b/app/Constants.php @@ -38,6 +38,7 @@ if (! defined('APP_NAME')) { define('ENTITY_EXPENSE_CATEGORY', 'expense_category'); define('ENTITY_PROJECT', 'project'); define('ENTITY_RECURRING_EXPENSE', 'recurring_expense'); + define('ENTITY_CUSTOMER', 'customer'); define('INVOICE_TYPE_STANDARD', 1); define('INVOICE_TYPE_QUOTE', 2); @@ -169,6 +170,7 @@ if (! defined('APP_NAME')) { define('IMPORT_INVOICEABLE', 'Invoiceable'); define('IMPORT_INVOICEPLANE', 'InvoicePlane'); define('IMPORT_HARVEST', 'Harvest'); + define('IMPORT_STRIPE', 'Stripe'); define('MAX_NUM_CLIENTS', 100); define('MAX_NUM_CLIENTS_PRO', 20000); diff --git a/app/Http/Requests/CreateCustomerRequest.php b/app/Http/Requests/CreateCustomerRequest.php new file mode 100644 index 000000000000..3f490a1a106e --- /dev/null +++ b/app/Http/Requests/CreateCustomerRequest.php @@ -0,0 +1,33 @@ +user()->can('create', ENTITY_CUSTOMER); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules() + { + $rules = [ + 'token' => 'required', + 'client_id' => 'required', + 'contact_id' => 'required', + 'payment_method.source_reference' => 'required', + ]; + + return $rules; + } +} diff --git a/app/Http/Requests/CustomerRequest.php b/app/Http/Requests/CustomerRequest.php new file mode 100644 index 000000000000..d3f7dc69f528 --- /dev/null +++ b/app/Http/Requests/CustomerRequest.php @@ -0,0 +1,8 @@ +belongsTo('App\Models\AccountGateway'); } + /** + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function contact() + { + return $this->belongsTo('App\Models\Contact'); + } + /** * @return \Illuminate\Database\Eloquent\Relations\HasOne */ @@ -49,6 +67,14 @@ class AccountGatewayToken extends Eloquent return $this->hasOne('App\Models\PaymentMethod', 'id', 'default_payment_method_id'); } + /** + * @return mixed + */ + public function getEntityType() + { + return ENTITY_CUSTOMER; + } + /** * @return mixed */ diff --git a/app/Models/Client.php b/app/Models/Client.php index 4a6d8486e67e..4ff71cee43f0 100644 --- a/app/Models/Client.php +++ b/app/Models/Client.php @@ -94,16 +94,16 @@ class Client extends EntityModel { return [ 'first' => 'contact_first_name', - 'last' => 'contact_last_name', + 'last^last4' => 'contact_last_name', 'email' => 'contact_email', 'work|office' => 'work_phone', 'mobile|phone' => 'contact_phone', - 'name|organization' => 'name', - 'apt|street2|address2' => 'address2', - 'street|address|address1' => 'address1', + 'name|organization|description^card' => 'name', + 'apt|street2|address2|line2' => 'address2', + 'street|address1|line1^avs' => 'address1', 'city' => 'city', 'state|province' => 'state', - 'zip|postal|code' => 'postal_code', + 'zip|postal|code^avs' => 'postal_code', 'country' => 'country', 'public' => 'public_notes', 'private|note' => 'private_notes', diff --git a/app/Models/PaymentMethod.php b/app/Models/PaymentMethod.php index 0f97cf96b64c..76dce0891bf0 100644 --- a/app/Models/PaymentMethod.php +++ b/app/Models/PaymentMethod.php @@ -21,11 +21,26 @@ class PaymentMethod extends EntityModel * @var array */ protected $dates = ['deleted_at']; + /** * @var array */ protected $hidden = ['id']; + /** + * @var array + */ + protected $fillable = [ + 'contact_id', + 'payment_type_id', + 'source_reference', + 'last4', + 'expiration', + 'email', + 'currency_id', + ]; + + /** * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ diff --git a/app/Models/PaymentType.php b/app/Models/PaymentType.php index 850e3d34668c..f1c2d7a70843 100644 --- a/app/Models/PaymentType.php +++ b/app/Models/PaymentType.php @@ -21,4 +21,37 @@ class PaymentType extends Eloquent { return $this->belongsTo('App\Models\GatewayType'); } + + public static function resolveAlias($cardName) + { + $cardTypes = [ + 'visa' => PAYMENT_TYPE_VISA, + 'americanexpress' => PAYMENT_TYPE_AMERICAN_EXPRESS, + 'amex' => PAYMENT_TYPE_AMERICAN_EXPRESS, + 'mastercard' => PAYMENT_TYPE_MASTERCARD, + 'discover' => PAYMENT_TYPE_DISCOVER, + 'jcb' => PAYMENT_TYPE_JCB, + 'dinersclub' => PAYMENT_TYPE_DINERS, + 'carteblanche' => PAYMENT_TYPE_CARTE_BLANCHE, + 'chinaunionpay' => PAYMENT_TYPE_UNIONPAY, + 'unionpay' => PAYMENT_TYPE_UNIONPAY, + 'laser' => PAYMENT_TYPE_LASER, + 'maestro' => PAYMENT_TYPE_MAESTRO, + 'solo' => PAYMENT_TYPE_SOLO, + 'switch' => PAYMENT_TYPE_SWITCH, + ]; + + $cardName = strtolower(str_replace([' ', '-', '_'], '', $cardName)); + + if (empty($cardTypes[$cardName]) && 1 == preg_match('/^('.implode('|', array_keys($cardTypes)).')/', $cardName, $matches)) { + // Some gateways return extra stuff after the card name + $cardName = $matches[1]; + } + + if (! empty($cardTypes[$cardName])) { + return $cardTypes[$cardName]; + } else { + return PAYMENT_TYPE_CREDIT_CARD_OTHER; + } + } } diff --git a/app/Ninja/Import/BaseTransformer.php b/app/Ninja/Import/BaseTransformer.php index 240a36f137a8..63435b45d3bb 100644 --- a/app/Ninja/Import/BaseTransformer.php +++ b/app/Ninja/Import/BaseTransformer.php @@ -114,6 +114,38 @@ class BaseTransformer extends TransformerAbstract return $product->$field ?: $default; } + /** + * @param $name + * + * @return null + */ + public function getContact($email) + { + $email = trim(strtolower($email)); + + if (! isset($this->maps['contact'][$email])) { + return false; + } + + return $this->maps['contact'][$email]; + } + + /** + * @param $name + * + * @return null + */ + public function getCustomer($key) + { + $key = trim($key); + + if (! isset($this->maps['customer'][$key])) { + return false; + } + + return $this->maps['customer'][$key]; + } + /** * @param $name * diff --git a/app/Ninja/Import/Stripe/CustomerTransformer.php b/app/Ninja/Import/Stripe/CustomerTransformer.php new file mode 100644 index 000000000000..2fe75bffee51 --- /dev/null +++ b/app/Ninja/Import/Stripe/CustomerTransformer.php @@ -0,0 +1,54 @@ +getContact($data->email)) { + return false; + } + + $account = auth()->user()->account; + $accountGateway = $account->getGatewayConfig(GATEWAY_STRIPE); + + if (! $accountGateway) { + return false; + } + + if ($this->getCustomer($data->id) || $this->getCustomer($data->email)) { + return false; + } + + return new Item($data, function ($data) use ($account, $contact, $accountGateway) { + return [ + 'contact_id' => $contact->id, + 'client_id' => $contact->client_id, + 'account_gateway_id' => $accountGateway->id, + 'token' => $data->id, + 'payment_method' => [ + 'contact_id' => $contact->id, + 'payment_type_id' => PaymentType::resolveAlias($data->card_brand), + 'source_reference' => $data->card_id, + 'last4' => $data->card_last4, + 'expiration' => $data->card_exp_year . '-' . $data->card_exp_month . '-01', + 'email' => $contact->email, + 'currency_id' => $account->getCurrencyId(), + ] + ]; + }); + } +} diff --git a/app/Ninja/PaymentDrivers/BasePaymentDriver.php b/app/Ninja/PaymentDrivers/BasePaymentDriver.php index b92b30c16be9..d247c2269067 100644 --- a/app/Ninja/PaymentDrivers/BasePaymentDriver.php +++ b/app/Ninja/PaymentDrivers/BasePaymentDriver.php @@ -994,39 +994,6 @@ class BasePaymentDriver return $url; } - protected function parseCardType($cardName) - { - $cardTypes = [ - 'visa' => PAYMENT_TYPE_VISA, - 'americanexpress' => PAYMENT_TYPE_AMERICAN_EXPRESS, - 'amex' => PAYMENT_TYPE_AMERICAN_EXPRESS, - 'mastercard' => PAYMENT_TYPE_MASTERCARD, - 'discover' => PAYMENT_TYPE_DISCOVER, - 'jcb' => PAYMENT_TYPE_JCB, - 'dinersclub' => PAYMENT_TYPE_DINERS, - 'carteblanche' => PAYMENT_TYPE_CARTE_BLANCHE, - 'chinaunionpay' => PAYMENT_TYPE_UNIONPAY, - 'unionpay' => PAYMENT_TYPE_UNIONPAY, - 'laser' => PAYMENT_TYPE_LASER, - 'maestro' => PAYMENT_TYPE_MAESTRO, - 'solo' => PAYMENT_TYPE_SOLO, - 'switch' => PAYMENT_TYPE_SWITCH, - ]; - - $cardName = strtolower(str_replace([' ', '-', '_'], '', $cardName)); - - if (empty($cardTypes[$cardName]) && 1 == preg_match('/^('.implode('|', array_keys($cardTypes)).')/', $cardName, $matches)) { - // Some gateways return extra stuff after the card name - $cardName = $matches[1]; - } - - if (! empty($cardTypes[$cardName])) { - return $cardTypes[$cardName]; - } else { - return PAYMENT_TYPE_CREDIT_CARD_OTHER; - } - } - public function handleWebHook($input) { throw new Exception('Unsupported gateway'); diff --git a/app/Ninja/PaymentDrivers/BraintreePaymentDriver.php b/app/Ninja/PaymentDrivers/BraintreePaymentDriver.php index 3b3396f3b9ab..ca9486514c62 100644 --- a/app/Ninja/PaymentDrivers/BraintreePaymentDriver.php +++ b/app/Ninja/PaymentDrivers/BraintreePaymentDriver.php @@ -7,6 +7,7 @@ use Exception; use Session; use Utils; use App\Models\GatewayType; +use App\Models\PaymentType; class BraintreePaymentDriver extends BasePaymentDriver { @@ -158,7 +159,7 @@ class BraintreePaymentDriver extends BasePaymentDriver $paymentMethod->source_reference = $response->token; if ($this->isGatewayType(GATEWAY_TYPE_CREDIT_CARD)) { - $paymentMethod->payment_type_id = $this->parseCardType($response->cardType); + $paymentMethod->payment_type_id = PaymentType::parseCardType($response->cardType); $paymentMethod->last4 = $response->last4; $paymentMethod->expiration = $response->expirationYear . '-' . $response->expirationMonth . '-01'; } elseif ($this->isGatewayType(GATEWAY_TYPE_PAYPAL)) { diff --git a/app/Ninja/PaymentDrivers/StripePaymentDriver.php b/app/Ninja/PaymentDrivers/StripePaymentDriver.php index fc93b1325069..78fa7cd0be49 100644 --- a/app/Ninja/PaymentDrivers/StripePaymentDriver.php +++ b/app/Ninja/PaymentDrivers/StripePaymentDriver.php @@ -6,6 +6,7 @@ use App\Models\Payment; use App\Models\PaymentMethod; use Cache; use Exception; +use App\Models\PaymentType; class StripePaymentDriver extends BasePaymentDriver { @@ -189,7 +190,7 @@ class StripePaymentDriver extends BasePaymentDriver // In that case we'd use GATEWAY_TYPE_TOKEN even though we're creating the credit card if ($this->isGatewayType(GATEWAY_TYPE_CREDIT_CARD) || $this->isGatewayType(GATEWAY_TYPE_TOKEN)) { $paymentMethod->expiration = $source['exp_year'] . '-' . $source['exp_month'] . '-01'; - $paymentMethod->payment_type_id = $this->parseCardType($source['brand']); + $paymentMethod->payment_type_id = PaymentType::parseCardType($source['brand']); } elseif ($this->isGatewayType(GATEWAY_TYPE_BANK_TRANSFER)) { $paymentMethod->routing_number = $source['routing_number']; $paymentMethod->payment_type_id = PAYMENT_TYPE_ACH; diff --git a/app/Ninja/PaymentDrivers/WePayPaymentDriver.php b/app/Ninja/PaymentDrivers/WePayPaymentDriver.php index e7000048c1cd..838b63439877 100644 --- a/app/Ninja/PaymentDrivers/WePayPaymentDriver.php +++ b/app/Ninja/PaymentDrivers/WePayPaymentDriver.php @@ -7,6 +7,7 @@ use App\Models\PaymentMethod; use Exception; use Session; use Utils; +use App\Models\PaymentType; class WePayPaymentDriver extends BasePaymentDriver { @@ -159,7 +160,7 @@ class WePayPaymentDriver extends BasePaymentDriver } } else { $paymentMethod->last4 = $source->last_four; - $paymentMethod->payment_type_id = $this->parseCardType($source->credit_card_name); + $paymentMethod->payment_type_id = PaymentType::parseCardType($source->credit_card_name); $paymentMethod->expiration = $source->expiration_year . '-' . $source->expiration_month . '-01'; $paymentMethod->source_reference = $source->credit_card_id; } diff --git a/app/Ninja/Repositories/ContactRepository.php b/app/Ninja/Repositories/ContactRepository.php index 7a72fda3e2d3..992712cfa22b 100644 --- a/app/Ninja/Repositories/ContactRepository.php +++ b/app/Ninja/Repositories/ContactRepository.php @@ -6,6 +6,13 @@ use App\Models\Contact; class ContactRepository extends BaseRepository { + public function all() + { + return Contact::scope() + ->withTrashed() + ->get(); + } + public function save($data, $contact = false) { $publicId = isset($data['public_id']) ? $data['public_id'] : false; diff --git a/app/Ninja/Repositories/CustomerRepository.php b/app/Ninja/Repositories/CustomerRepository.php new file mode 100644 index 000000000000..d5eb58de4641 --- /dev/null +++ b/app/Ninja/Repositories/CustomerRepository.php @@ -0,0 +1,42 @@ +user()->account_id) + ->with(['contact']) + ->get(); + } + + public function save($data) + { + $account = auth()->user()->account; + + $customer = new AccountGatewayToken(); + $customer->account_id = $account->id; + $customer->fill($data); + $customer->save(); + + $paymentMethod = PaymentMethod::createNew(); + $paymentMethod->account_gateway_token_id = $customer->id; + $paymentMethod->fill($data['payment_method']); + $paymentMethod->save(); + + $customer->default_payment_method_id = $paymentMethod->id; + $customer->save(); + + return $customer; + } +} diff --git a/app/Policies/CustomerPolicy.php b/app/Policies/CustomerPolicy.php new file mode 100644 index 000000000000..f0ee286cfc69 --- /dev/null +++ b/app/Policies/CustomerPolicy.php @@ -0,0 +1,7 @@ + \App\Policies\BankAccountPolicy::class, \App\Models\PaymentTerm::class => \App\Policies\PaymentTermPolicy::class, \App\Models\Project::class => \App\Policies\ProjectPolicy::class, + \App\Models\AccountGatewayToken::class => \App\Policies\CustomerPolicy::class, ]; /** diff --git a/app/Services/ImportService.php b/app/Services/ImportService.php index 53433b7ee72c..39229a7a8b90 100644 --- a/app/Services/ImportService.php +++ b/app/Services/ImportService.php @@ -3,6 +3,7 @@ namespace App\Services; use App\Models\Client; +use App\Models\Contact; use App\Models\EntityModel; use App\Models\Expense; use App\Models\ExpenseCategory; @@ -10,8 +11,10 @@ use App\Models\Invoice; use App\Models\Payment; use App\Models\Product; use App\Models\Vendor; +use App\Models\AccountGatewayToken; use App\Ninja\Import\BaseTransformer; use App\Ninja\Repositories\ClientRepository; +use App\Ninja\Repositories\CustomerRepository; use App\Ninja\Repositories\ContactRepository; use App\Ninja\Repositories\ExpenseCategoryRepository; use App\Ninja\Repositories\ExpenseRepository; @@ -53,6 +56,11 @@ class ImportService */ protected $clientRepo; + /** + * @var CustomerRepository + */ + protected $customerRepo; + /** * @var ContactRepository */ @@ -90,6 +98,7 @@ class ImportService ENTITY_TASK, ENTITY_PRODUCT, ENTITY_EXPENSE, + ENTITY_CUSTOMER, ]; /** @@ -104,6 +113,7 @@ class ImportService IMPORT_INVOICEPLANE, IMPORT_NUTCACHE, IMPORT_RONIN, + IMPORT_STRIPE, IMPORT_WAVE, IMPORT_ZOHO, ]; @@ -113,6 +123,7 @@ class ImportService * * @param Manager $manager * @param ClientRepository $clientRepo + * @param CustomerRepository $customerRepo * @param InvoiceRepository $invoiceRepo * @param PaymentRepository $paymentRepo * @param ContactRepository $contactRepo @@ -121,6 +132,7 @@ class ImportService public function __construct( Manager $manager, ClientRepository $clientRepo, + CustomerRepository $customerRepo, InvoiceRepository $invoiceRepo, PaymentRepository $paymentRepo, ContactRepository $contactRepo, @@ -134,6 +146,7 @@ class ImportService $this->fractal->setSerializer(new ArraySerializer()); $this->clientRepo = $clientRepo; + $this->customerRepo = $customerRepo; $this->invoiceRepo = $invoiceRepo; $this->paymentRepo = $paymentRepo; $this->contactRepo = $contactRepo; @@ -428,8 +441,10 @@ class ImportService $entity = $this->{"{$entityType}Repo"}->save($data); // update the entity maps - $mapFunction = 'add' . ucwords($entity->getEntityType()) . 'ToMaps'; - $this->$mapFunction($entity); + if ($entityType != ENTITY_CUSTOMER) { + $mapFunction = 'add' . ucwords($entity->getEntityType()) . 'ToMaps'; + $this->$mapFunction($entity); + } // if the invoice is paid we'll also create a payment record if ($entityType === ENTITY_INVOICE && isset($data['paid']) && $data['paid'] > 0) { @@ -836,6 +851,8 @@ class ImportService $this->maps = [ 'client' => [], + 'contact' => [], + 'customer' => [], 'invoice' => [], 'invoice_client' => [], 'product' => [], @@ -855,6 +872,16 @@ class ImportService $this->addClientToMaps($client); } + $customers = $this->customerRepo->all(); + foreach ($customers as $customer) { + $this->addCustomerToMaps($customer); + } + + $contacts = $this->contactRepo->all(); + foreach ($contacts as $contact) { + $this->addContactToMaps($contact); + } + $invoices = $this->invoiceRepo->all(); foreach ($invoices as $invoice) { $this->addInvoiceToMaps($invoice); @@ -921,6 +948,25 @@ class ImportService } } + /** + * @param Customer $customer + */ + private function addCustomerToMaps(AccountGatewayToken $customer) + { + $this->maps['customer'][$customer->token] = $customer; + $this->maps['customer'][$customer->contact->email] = $customer; + } + + /** + * @param Product $product + */ + private function addContactToMaps(Contact $contact) + { + if ($key = strtolower(trim($contact->email))) { + $this->maps['contact'][$key] = $contact; + } + } + /** * @param Product $product */ diff --git a/resources/lang/en/texts.php b/resources/lang/en/texts.php index 7e7585fb06d0..49f2012dac28 100644 --- a/resources/lang/en/texts.php +++ b/resources/lang/en/texts.php @@ -2426,6 +2426,10 @@ $LANG = array( 'include_errors_help' => 'Include :link from storage/logs/laravel-error.log', 'recent_errors' => 'recent errors', 'add_item' => 'Add Item', + 'customer' => 'Customer', + 'customers' => 'Customers', + 'created_customer' => 'Successfully created customer', + 'created_customers' => 'Successfully created :count customers', ); diff --git a/resources/views/accounts/import_export.blade.php b/resources/views/accounts/import_export.blade.php index dbb640045dc3..1052fcbb3c34 100644 --- a/resources/views/accounts/import_export.blade.php +++ b/resources/views/accounts/import_export.blade.php @@ -34,7 +34,8 @@
@foreach (\App\Services\ImportService::$entityTypes as $entityType) {!! Former::file($entityType) - ->addGroupClass("import-file {$entityType}-file") !!} + ->addGroupClass("import-file {$entityType}-file") + ->label(Utils::pluralizeEntityType($entityType)) !!} @endforeach