Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Basic budget management #75

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/Actions/QuickBooks/GenerateVendingNetJournalEntry.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ public function execute(Carbon $date): void
// something.
}

$begin = Carbon::create(year: $date->year, month: $date->month, day: $date->day, tz: $date->timezone);
$end = Carbon::make($begin)->addDay()->subSecond();
$begin = $date->startOfDay();
$end = $date->endOfDay();

$wooCommerceOrders = $this->wooCommerceApi->orders->list([
'after' => $begin->toIso8601String(),
Expand Down
57 changes: 57 additions & 0 deletions app/Actions/QuickBooks/GetAmountSpentByClass.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

namespace App\Actions\QuickBooks;

use Carbon\Carbon;
use QuickBooksOnline\API\DataService\DataService;
use QuickBooksOnline\API\ReportService\ReportName;
use QuickBooksOnline\API\ReportService\ReportService;

/**
* Runs a report through QuickBooks to determine spend per QuickBooks class.
*/
class GetAmountSpentByClass
{
public function execute($classId, Carbon $startDate, Carbon $endDate): float
{
/** @var DataService $dataService */
$dataService = app(DataService::class);

$serviceContext = $dataService->getServiceContext();

$reportService = new ReportService($serviceContext);
$reportService->setStartDate($startDate->format("Y-m-d"));
$reportService->setEndDate($endDate->format("Y-m-d"));
$reportService->setClassid($classId);
$reportService->setAccountingMethod("Accrual");

/** @var \stdClass $profitAndLossReport */
$profitAndLossReport = $reportService->executeReport(ReportName::PROFITANDLOSS);

// QuickBooks reports can be very nested which makes the format a little hard to parse. One consistency is the
// columns are all the same for every level of the nesting so we start by grabbing the column index for the
// "total"
$columns = collect($profitAndLossReport->Columns->Column)->map(function ($column) {
return $column->MetaData[0]->Value;
})->values();
$totalColumn = $columns->search("total");

// We then look for the "NetIncome" group (also called "Net Revenue in the row itself") and extract the net
// revenue value.
$netRevenueSection = collect($profitAndLossReport->Rows->Row)->first(function ($row) {
return $row->group == "NetIncome";
});
$netRevenueData = collect($netRevenueSection->Summary->ColData)->map(function ($colData) {
return $colData->value;
});
$netRevenue = floatval($netRevenueData[$totalColumn]);

if(abs($netRevenue) < 0.01) { // Handles 0 and anything less than a penny though I didn't see that in practice
return 0;
}

// Net income is usually negative for budgets since it's all outgoing and no incoming. We want to return spend
// so we negate that value.
return -$netRevenue;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

namespace App\Actions\QuickBooks;

use App\Actions\StaticAction;
use App\Models\Budget;
use Carbon\Carbon;

/**
* Runs a QuickBook report for a specific budget to determine if the internal model needs to be updated. This can be on
* notification that something has changed (via webhook) or just run at a set time every day against all budgets. If
* the currently used amount doesn't need to be updated, nothing further will be done. If it does need to be updated,
* we queue a job to update the spendable amounts for the affected cards.
*/
class PullCurrentlyUsedAmountForBudgetFromQuickBooks
{
use StaticAction;

public function execute(Budget $budget): void
{
$currentlyUsed = $budget->currently_used;

/** @var GetAmountSpentByClass $getAmountSpentByClass */
$getAmountSpentByClass = app(GetAmountSpentByClass::class);

// The QuickBooks API, when it does not use a date time offset, always uses PST as its time zone.
$today = Carbon::today('PST');

switch ($budget->type) {
case Budget::TYPE_ONE_TIME:
case Budget::TYPE_POOL:
// Date from before we were using quickbooks to catch everything until now
$startDate = Carbon::create(2019, tz: 'PST');
$endDate = $today->copy()->endOfDay();
break;
case Budget::TYPE_RECURRING_MONTHLY:
$startDate = $today->copy()->startOfMonth();
$endDate = $today->copy()->endOfMonth();
break;
case Budget::TYPE_RECURRING_YEARLY:
$startDate = $today->copy()->startOfYear();
$endDate = $today->copy()->endOfYear();
break;
default:
throw new \Exception("Unknown budget type $budget->type");
}

$quickBooksCurrentlyUsed = $getAmountSpentByClass->execute($budget->quickbooks_class_id, $startDate, $endDate);

if($budget->type == Budget::TYPE_POOL) {
// For a pool, the "spend" we just fetched is the negative of the amount we have available to use. i.e if
// the "amount spent" retrieved above is -700.00 then that means our pool has $700.00 it can use. If our
// allocated amount is $1,000.00 we can consider that $300.00 used. To make the math easier almost
// everywhere else, we calculate how much we've "used" based on how much is allocated. The only other place
// we have to care about this is when updating the allocated_amount field.
$quickBooksCurrentlyUsed = $budget->allocated_amount + $quickBooksCurrentlyUsed;

if($quickBooksCurrentlyUsed < 0) {
// This can happen if we end up collecting over our pool, but we haven't shifted the excess out of this
// budget class yet.
$quickBooksCurrentlyUsed = 0;
}
}

if (abs($quickBooksCurrentlyUsed - $currentlyUsed) > 0.01) {
$budget->currently_used = $quickBooksCurrentlyUsed;
$budget->save();
}
}
}
121 changes: 121 additions & 0 deletions app/Actions/Stripe/UpdateAllStripeCardsFromBudgets.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<?php

namespace App\Actions\Stripe;

use App\External\Stripe\SpendingControls;
use App\External\Stripe\SpendingLimits;
use App\Models\Budget;
use App\Models\Customer;
use App\Models\StripeCard;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Spatie\QueueableAction\QueueableAction;

class UpdateAllStripeCardsFromBudgets
{
use QueueableAction;

public function __construct()
{

}

/**
* @throws \Exception
*/
public function execute()
{
/** @var UpdateSpendingLimitsOnCard $updateSpendingLimitsOnCard */
$updateSpendingLimitsOnCard = app(UpdateSpendingLimitsOnCard::class);

$cardsToSpendingLimit = collect();
$issuingBalance = 0;

foreach (Budget::all() as $budget) {
/** @var Budget $budget */
if(! $budget->active) {
// This budget isn't active, so we ignore it for all top-up and spending limit purposes. Any cards that
// are only attached to this budget will have their spending limit set to a penny.
continue;
}

$cards = $this->getCardsThatCanSpend($budget);

$budgetAvailable = $budget->available_to_spend;

foreach($cards as $stripeCard) {
$currentAmount = $cardsToSpendingLimit->get($stripeCard->id, 0);
$currentAmount += $budgetAvailable;
$cardsToSpendingLimit->put($stripeCard->id, $currentAmount);
}

if($cards->isNotEmpty()) {
$issuingBalance += $budgetAvailable;
}
}

foreach(StripeCard::all() as $stripeCard) {
if($stripeCard->status == StripeCard::STATUS_CANCELED) {
// Canceled is permanent, spending limits don't matter at all.
continue;
}

// $0.01 since we can't set it to 0. Effectively disables the card if no spend is available.
$spendingLimit = $cardsToSpendingLimit->get($stripeCard->id, 0.01);
$spendingLimitInPennies = round($spendingLimit * 100);

$limits = (new SpendingLimits($spendingLimitInPennies))->per_authorization();
$controls = (new SpendingControls())->spending_limits($limits);
$updateSpendingLimitsOnCard->onQueue()->execute($stripeCard, $controls);
}

$issuingBalanceInPennies = round($issuingBalance * 100);

if($issuingBalanceInPennies > 0) {
/** @var SetIssuingBalanceToValue $setIssuingBalanceToValue */
$setIssuingBalanceToValue = app(SetIssuingBalanceToValue::class);
$today = Carbon::today('America/Denver');
$message = "Top-Up to match current outstanding budgets {$today->toFormattedDayDateString()}";
$setIssuingBalanceToValue->onQueue()->execute($issuingBalanceInPennies, $message);
}
}

/**
* @param Budget $budget
* @return Collection<StripeCard>
* @throws \Exception
*/
private function getCardsThatCanSpend(Budget $budget): Collection
{
$owner = $budget->owner;

if(is_a($owner, Customer::class)) {
if(! $owner->member) {
// Doesn't matter if they have a physical card or not, we'll pretend they don't so any physical cards
// they do have get set to a spending limit of a penny.
return collect();
}

if(is_null($owner->stripe_card_holder_id)) {
// This person might have a card, but the card holder in stripe hasn't been associated to their account
// just yet. We limit their spending to be safe.
return collect();
}

$stripeCard = StripeCard::where('cardholder_id', $owner->stripe_card_holder_id)
->where('status', StripeCard::STATUS_ACTIVE)
->where('type', StripeCard::TYPE_PHYSICAL)
->first();

if(is_null($stripeCard)) {
return collect();
}

return collect([$stripeCard]);

} else {
$owner_type = get_class($owner);
throw new \Exception("Unknown owner type: {$owner_type}");
}
}
}
30 changes: 30 additions & 0 deletions app/Actions/Stripe/UpdateSpendingLimitsOnCard.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

namespace App\Actions\Stripe;

use App\External\Stripe\SpendingControls;
use App\Models\StripeCard;
use Spatie\QueueableAction\QueueableAction;
use Stripe\StripeClient;

class UpdateSpendingLimitsOnCard
{
use QueueableAction;

private StripeClient $client;

public function __construct(StripeClient $client)
{
$this->client = $client;
}

public function execute(StripeCard $stripeCard, SpendingControls $controls)
{
return $this->client->issuing->cards
->update($stripeCard->id,
[
'spending_controls' => $controls->stripeObject()->toArray(),
]
);
}
}
44 changes: 44 additions & 0 deletions app/Actions/Stripe/UpdateStripeCardsFromSource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

namespace App\Actions\Stripe;

use App\Models\StripeCard;
use Spatie\QueueableAction\QueueableAction;
use Stripe\Issuing\Card;
use Stripe\StripeClient;

class UpdateStripeCardsFromSource
{
use QueueableAction;

private StripeClient $stripeClient;

public function __construct(StripeClient $stripeClient)
{
$this->stripeClient = $stripeClient;
}

public function execute()
{
$cards = $this->stripeClient->issuing->cards->all()->autoPagingIterator();

$stripeModels = StripeCard::all();

foreach ($cards as $card) {
/** @var Card $card */
$cardModel = $stripeModels->firstWhere('id', $card->id);

if (is_null($cardModel)) {
$cardModel = StripeCard::make([
'id' => $card->id,
'cardholder_id' => $card->cardholder->id,
]);
}

$cardModel->type = $card->type;
$cardModel->status = $card->status;

$cardModel->save();
}
}
}
Loading