Support importing Stripe customer cards

This commit is contained in:
Hillel Coren 2017-08-31 15:55:15 +03:00
parent 63cddfe262
commit c68825d8a6
20 changed files with 325 additions and 44 deletions

View File

@ -38,6 +38,7 @@ if (! defined('APP_NAME')) {
define('ENTITY_EXPENSE_CATEGORY', 'expense_category'); define('ENTITY_EXPENSE_CATEGORY', 'expense_category');
define('ENTITY_PROJECT', 'project'); define('ENTITY_PROJECT', 'project');
define('ENTITY_RECURRING_EXPENSE', 'recurring_expense'); define('ENTITY_RECURRING_EXPENSE', 'recurring_expense');
define('ENTITY_CUSTOMER', 'customer');
define('INVOICE_TYPE_STANDARD', 1); define('INVOICE_TYPE_STANDARD', 1);
define('INVOICE_TYPE_QUOTE', 2); define('INVOICE_TYPE_QUOTE', 2);
@ -169,6 +170,7 @@ if (! defined('APP_NAME')) {
define('IMPORT_INVOICEABLE', 'Invoiceable'); define('IMPORT_INVOICEABLE', 'Invoiceable');
define('IMPORT_INVOICEPLANE', 'InvoicePlane'); define('IMPORT_INVOICEPLANE', 'InvoicePlane');
define('IMPORT_HARVEST', 'Harvest'); define('IMPORT_HARVEST', 'Harvest');
define('IMPORT_STRIPE', 'Stripe');
define('MAX_NUM_CLIENTS', 100); define('MAX_NUM_CLIENTS', 100);
define('MAX_NUM_CLIENTS_PRO', 20000); define('MAX_NUM_CLIENTS_PRO', 20000);

View File

@ -0,0 +1,33 @@
<?php
namespace App\Http\Requests;
class CreateCustomerRequest extends CustomerRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
return $this->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;
}
}

View File

@ -0,0 +1,8 @@
<?php
namespace App\Http\Requests;
class CustomerRequest extends EntityRequest
{
protected $entityType = ENTITY_CUSTOMER;
}

View File

@ -25,6 +25,16 @@ class AccountGatewayToken extends Eloquent
*/ */
protected $casts = []; protected $casts = [];
/**
* @var array
*/
protected $fillable = [
'contact_id',
'account_gateway_id',
'client_id',
'token',
];
/** /**
* @return \Illuminate\Database\Eloquent\Relations\HasMany * @return \Illuminate\Database\Eloquent\Relations\HasMany
*/ */
@ -41,6 +51,14 @@ class AccountGatewayToken extends Eloquent
return $this->belongsTo('App\Models\AccountGateway'); return $this->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 * @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 $this->hasOne('App\Models\PaymentMethod', 'id', 'default_payment_method_id');
} }
/**
* @return mixed
*/
public function getEntityType()
{
return ENTITY_CUSTOMER;
}
/** /**
* @return mixed * @return mixed
*/ */

View File

@ -94,16 +94,16 @@ class Client extends EntityModel
{ {
return [ return [
'first' => 'contact_first_name', 'first' => 'contact_first_name',
'last' => 'contact_last_name', 'last^last4' => 'contact_last_name',
'email' => 'contact_email', 'email' => 'contact_email',
'work|office' => 'work_phone', 'work|office' => 'work_phone',
'mobile|phone' => 'contact_phone', 'mobile|phone' => 'contact_phone',
'name|organization' => 'name', 'name|organization|description^card' => 'name',
'apt|street2|address2' => 'address2', 'apt|street2|address2|line2' => 'address2',
'street|address|address1' => 'address1', 'street|address1|line1^avs' => 'address1',
'city' => 'city', 'city' => 'city',
'state|province' => 'state', 'state|province' => 'state',
'zip|postal|code' => 'postal_code', 'zip|postal|code^avs' => 'postal_code',
'country' => 'country', 'country' => 'country',
'public' => 'public_notes', 'public' => 'public_notes',
'private|note' => 'private_notes', 'private|note' => 'private_notes',

View File

@ -21,11 +21,26 @@ class PaymentMethod extends EntityModel
* @var array * @var array
*/ */
protected $dates = ['deleted_at']; protected $dates = ['deleted_at'];
/** /**
* @var array * @var array
*/ */
protected $hidden = ['id']; 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 * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/ */

View File

@ -21,4 +21,37 @@ class PaymentType extends Eloquent
{ {
return $this->belongsTo('App\Models\GatewayType'); 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;
}
}
} }

View File

@ -114,6 +114,38 @@ class BaseTransformer extends TransformerAbstract
return $product->$field ?: $default; 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 * @param $name
* *

View File

@ -0,0 +1,54 @@
<?php
namespace App\Ninja\Import\Stripe;
use App\Ninja\Import\BaseTransformer;
use League\Fractal\Resource\Item;
use App\Models\PaymentType;
/**
* Class InvoiceTransformer.
*/
class CustomerTransformer extends BaseTransformer
{
/**
* @param $data
*
* @return bool|Item
*/
public function transform($data)
{
if (! $contact = $this->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(),
]
];
});
}
}

View File

@ -994,39 +994,6 @@ class BasePaymentDriver
return $url; 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) public function handleWebHook($input)
{ {
throw new Exception('Unsupported gateway'); throw new Exception('Unsupported gateway');

View File

@ -7,6 +7,7 @@ use Exception;
use Session; use Session;
use Utils; use Utils;
use App\Models\GatewayType; use App\Models\GatewayType;
use App\Models\PaymentType;
class BraintreePaymentDriver extends BasePaymentDriver class BraintreePaymentDriver extends BasePaymentDriver
{ {
@ -158,7 +159,7 @@ class BraintreePaymentDriver extends BasePaymentDriver
$paymentMethod->source_reference = $response->token; $paymentMethod->source_reference = $response->token;
if ($this->isGatewayType(GATEWAY_TYPE_CREDIT_CARD)) { 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->last4 = $response->last4;
$paymentMethod->expiration = $response->expirationYear . '-' . $response->expirationMonth . '-01'; $paymentMethod->expiration = $response->expirationYear . '-' . $response->expirationMonth . '-01';
} elseif ($this->isGatewayType(GATEWAY_TYPE_PAYPAL)) { } elseif ($this->isGatewayType(GATEWAY_TYPE_PAYPAL)) {

View File

@ -6,6 +6,7 @@ use App\Models\Payment;
use App\Models\PaymentMethod; use App\Models\PaymentMethod;
use Cache; use Cache;
use Exception; use Exception;
use App\Models\PaymentType;
class StripePaymentDriver extends BasePaymentDriver 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 // 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)) { if ($this->isGatewayType(GATEWAY_TYPE_CREDIT_CARD) || $this->isGatewayType(GATEWAY_TYPE_TOKEN)) {
$paymentMethod->expiration = $source['exp_year'] . '-' . $source['exp_month'] . '-01'; $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)) { } elseif ($this->isGatewayType(GATEWAY_TYPE_BANK_TRANSFER)) {
$paymentMethod->routing_number = $source['routing_number']; $paymentMethod->routing_number = $source['routing_number'];
$paymentMethod->payment_type_id = PAYMENT_TYPE_ACH; $paymentMethod->payment_type_id = PAYMENT_TYPE_ACH;

View File

@ -7,6 +7,7 @@ use App\Models\PaymentMethod;
use Exception; use Exception;
use Session; use Session;
use Utils; use Utils;
use App\Models\PaymentType;
class WePayPaymentDriver extends BasePaymentDriver class WePayPaymentDriver extends BasePaymentDriver
{ {
@ -159,7 +160,7 @@ class WePayPaymentDriver extends BasePaymentDriver
} }
} else { } else {
$paymentMethod->last4 = $source->last_four; $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->expiration = $source->expiration_year . '-' . $source->expiration_month . '-01';
$paymentMethod->source_reference = $source->credit_card_id; $paymentMethod->source_reference = $source->credit_card_id;
} }

View File

@ -6,6 +6,13 @@ use App\Models\Contact;
class ContactRepository extends BaseRepository class ContactRepository extends BaseRepository
{ {
public function all()
{
return Contact::scope()
->withTrashed()
->get();
}
public function save($data, $contact = false) public function save($data, $contact = false)
{ {
$publicId = isset($data['public_id']) ? $data['public_id'] : false; $publicId = isset($data['public_id']) ? $data['public_id'] : false;

View File

@ -0,0 +1,42 @@
<?php
namespace App\Ninja\Repositories;
use App\Models\PaymentMethod;
use App\Models\AccountGatewayToken;
use DB;
class CustomerRepository extends BaseRepository
{
public function getClassName()
{
return 'App\Models\AccountGatewayToken';
}
public function all()
{
return AccountGatewayToken::whereAccountId(auth()->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;
}
}

View File

@ -0,0 +1,7 @@
<?php
namespace App\Policies;
class CustomerPolicy extends EntityPolicy
{
}

View File

@ -31,6 +31,7 @@ class AuthServiceProvider extends ServiceProvider
\App\Models\BankAccount::class => \App\Policies\BankAccountPolicy::class, \App\Models\BankAccount::class => \App\Policies\BankAccountPolicy::class,
\App\Models\PaymentTerm::class => \App\Policies\PaymentTermPolicy::class, \App\Models\PaymentTerm::class => \App\Policies\PaymentTermPolicy::class,
\App\Models\Project::class => \App\Policies\ProjectPolicy::class, \App\Models\Project::class => \App\Policies\ProjectPolicy::class,
\App\Models\AccountGatewayToken::class => \App\Policies\CustomerPolicy::class,
]; ];
/** /**

View File

@ -3,6 +3,7 @@
namespace App\Services; namespace App\Services;
use App\Models\Client; use App\Models\Client;
use App\Models\Contact;
use App\Models\EntityModel; use App\Models\EntityModel;
use App\Models\Expense; use App\Models\Expense;
use App\Models\ExpenseCategory; use App\Models\ExpenseCategory;
@ -10,8 +11,10 @@ use App\Models\Invoice;
use App\Models\Payment; use App\Models\Payment;
use App\Models\Product; use App\Models\Product;
use App\Models\Vendor; use App\Models\Vendor;
use App\Models\AccountGatewayToken;
use App\Ninja\Import\BaseTransformer; use App\Ninja\Import\BaseTransformer;
use App\Ninja\Repositories\ClientRepository; use App\Ninja\Repositories\ClientRepository;
use App\Ninja\Repositories\CustomerRepository;
use App\Ninja\Repositories\ContactRepository; use App\Ninja\Repositories\ContactRepository;
use App\Ninja\Repositories\ExpenseCategoryRepository; use App\Ninja\Repositories\ExpenseCategoryRepository;
use App\Ninja\Repositories\ExpenseRepository; use App\Ninja\Repositories\ExpenseRepository;
@ -53,6 +56,11 @@ class ImportService
*/ */
protected $clientRepo; protected $clientRepo;
/**
* @var CustomerRepository
*/
protected $customerRepo;
/** /**
* @var ContactRepository * @var ContactRepository
*/ */
@ -90,6 +98,7 @@ class ImportService
ENTITY_TASK, ENTITY_TASK,
ENTITY_PRODUCT, ENTITY_PRODUCT,
ENTITY_EXPENSE, ENTITY_EXPENSE,
ENTITY_CUSTOMER,
]; ];
/** /**
@ -104,6 +113,7 @@ class ImportService
IMPORT_INVOICEPLANE, IMPORT_INVOICEPLANE,
IMPORT_NUTCACHE, IMPORT_NUTCACHE,
IMPORT_RONIN, IMPORT_RONIN,
IMPORT_STRIPE,
IMPORT_WAVE, IMPORT_WAVE,
IMPORT_ZOHO, IMPORT_ZOHO,
]; ];
@ -113,6 +123,7 @@ class ImportService
* *
* @param Manager $manager * @param Manager $manager
* @param ClientRepository $clientRepo * @param ClientRepository $clientRepo
* @param CustomerRepository $customerRepo
* @param InvoiceRepository $invoiceRepo * @param InvoiceRepository $invoiceRepo
* @param PaymentRepository $paymentRepo * @param PaymentRepository $paymentRepo
* @param ContactRepository $contactRepo * @param ContactRepository $contactRepo
@ -121,6 +132,7 @@ class ImportService
public function __construct( public function __construct(
Manager $manager, Manager $manager,
ClientRepository $clientRepo, ClientRepository $clientRepo,
CustomerRepository $customerRepo,
InvoiceRepository $invoiceRepo, InvoiceRepository $invoiceRepo,
PaymentRepository $paymentRepo, PaymentRepository $paymentRepo,
ContactRepository $contactRepo, ContactRepository $contactRepo,
@ -134,6 +146,7 @@ class ImportService
$this->fractal->setSerializer(new ArraySerializer()); $this->fractal->setSerializer(new ArraySerializer());
$this->clientRepo = $clientRepo; $this->clientRepo = $clientRepo;
$this->customerRepo = $customerRepo;
$this->invoiceRepo = $invoiceRepo; $this->invoiceRepo = $invoiceRepo;
$this->paymentRepo = $paymentRepo; $this->paymentRepo = $paymentRepo;
$this->contactRepo = $contactRepo; $this->contactRepo = $contactRepo;
@ -428,8 +441,10 @@ class ImportService
$entity = $this->{"{$entityType}Repo"}->save($data); $entity = $this->{"{$entityType}Repo"}->save($data);
// update the entity maps // update the entity maps
$mapFunction = 'add' . ucwords($entity->getEntityType()) . 'ToMaps'; if ($entityType != ENTITY_CUSTOMER) {
$this->$mapFunction($entity); $mapFunction = 'add' . ucwords($entity->getEntityType()) . 'ToMaps';
$this->$mapFunction($entity);
}
// if the invoice is paid we'll also create a payment record // if the invoice is paid we'll also create a payment record
if ($entityType === ENTITY_INVOICE && isset($data['paid']) && $data['paid'] > 0) { if ($entityType === ENTITY_INVOICE && isset($data['paid']) && $data['paid'] > 0) {
@ -836,6 +851,8 @@ class ImportService
$this->maps = [ $this->maps = [
'client' => [], 'client' => [],
'contact' => [],
'customer' => [],
'invoice' => [], 'invoice' => [],
'invoice_client' => [], 'invoice_client' => [],
'product' => [], 'product' => [],
@ -855,6 +872,16 @@ class ImportService
$this->addClientToMaps($client); $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(); $invoices = $this->invoiceRepo->all();
foreach ($invoices as $invoice) { foreach ($invoices as $invoice) {
$this->addInvoiceToMaps($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 * @param Product $product
*/ */

View File

@ -2426,6 +2426,10 @@ $LANG = array(
'include_errors_help' => 'Include :link from storage/logs/laravel-error.log', 'include_errors_help' => 'Include :link from storage/logs/laravel-error.log',
'recent_errors' => 'recent errors', 'recent_errors' => 'recent errors',
'add_item' => 'Add Item', 'add_item' => 'Add Item',
'customer' => 'Customer',
'customers' => 'Customers',
'created_customer' => 'Successfully created customer',
'created_customers' => 'Successfully created :count customers',
); );

View File

@ -34,7 +34,8 @@
<br/> <br/>
@foreach (\App\Services\ImportService::$entityTypes as $entityType) @foreach (\App\Services\ImportService::$entityTypes as $entityType)
{!! Former::file($entityType) {!! Former::file($entityType)
->addGroupClass("import-file {$entityType}-file") !!} ->addGroupClass("import-file {$entityType}-file")
->label(Utils::pluralizeEntityType($entityType)) !!}
@endforeach @endforeach
<div id="jsonIncludes" style="display:none"> <div id="jsonIncludes" style="display:none">