Add account balances
This commit is contained in:
parent
52310ef889
commit
bec18b925b
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"setup-worktree": [
|
||||
"cp $ROOT_WORKTREE_PATH/.env .env",
|
||||
"composer install",
|
||||
"bun install"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\UpdateCurrentAccountBalanceRequest;
|
||||
use App\Models\Account;
|
||||
use App\Models\AccountBalance;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class AccountBalanceController extends Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
/**
|
||||
* Update or create the current balance for an account.
|
||||
*/
|
||||
public function updateCurrent(UpdateCurrentAccountBalanceRequest $request, Account $account): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $account);
|
||||
|
||||
$today = now()->toDateString();
|
||||
|
||||
$balance = AccountBalance::updateOrCreate(
|
||||
[
|
||||
'account_id' => $account->id,
|
||||
'balance_date' => $today,
|
||||
],
|
||||
[
|
||||
'balance' => $request->validated()['balance'],
|
||||
]
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'data' => $balance,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Sync;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreAccountBalanceRequest;
|
||||
use App\Models\AccountBalance;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AccountBalanceSyncController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$query = AccountBalance::query()
|
||||
->whereHas('account', function ($q) use ($request) {
|
||||
$q->where('user_id', $request->user()->id);
|
||||
});
|
||||
|
||||
if ($request->has('since')) {
|
||||
$query->where('updated_at', '>', $request->input('since'));
|
||||
}
|
||||
|
||||
$balances = $query
|
||||
->orderBy('balance_date', 'desc')
|
||||
->orderBy('updated_at', 'desc')
|
||||
->get();
|
||||
|
||||
return response()->json([
|
||||
'data' => $balances,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreAccountBalanceRequest $request): JsonResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
|
||||
$balance = new AccountBalance([
|
||||
'account_id' => $data['account_id'],
|
||||
'balance_date' => $data['balance_date'],
|
||||
'balance' => $data['balance'],
|
||||
]);
|
||||
|
||||
if (isset($data['id'])) {
|
||||
$balance->id = $data['id'];
|
||||
$balance->exists = false;
|
||||
}
|
||||
|
||||
$balance->save();
|
||||
|
||||
return response()->json([
|
||||
'data' => $balance,
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function update(StoreAccountBalanceRequest $request, AccountBalance $accountBalance): JsonResponse
|
||||
{
|
||||
$accountBalance->account->loadMissing('user');
|
||||
|
||||
if ($accountBalance->account->user_id !== $request->user()->id) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
$data = $request->validated();
|
||||
unset($data['id']);
|
||||
|
||||
$accountBalance->update($data);
|
||||
|
||||
return response()->json([
|
||||
'data' => $accountBalance->fresh(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreAccountBalanceRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'id' => ['sometimes', 'uuid'],
|
||||
'account_id' => ['required', 'exists:accounts,id'],
|
||||
'balance_date' => ['required', 'date'],
|
||||
'balance' => ['required', 'integer'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'account_id.required' => 'The account is required.',
|
||||
'account_id.exists' => 'The selected account does not exist.',
|
||||
'balance_date.required' => 'The balance date is required.',
|
||||
'balance_date.date' => 'The balance date must be a valid date.',
|
||||
'balance.required' => 'The balance is required.',
|
||||
'balance.integer' => 'The balance must be an integer.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateCurrentAccountBalanceRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'balance' => ['required', 'integer'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'balance.required' => 'The balance is required.',
|
||||
'balance.integer' => 'The balance must be an integer.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -44,4 +44,9 @@ class Account extends Model
|
|||
{
|
||||
return $this->hasMany(Transaction::class);
|
||||
}
|
||||
|
||||
public function balances(): HasMany
|
||||
{
|
||||
return $this->hasMany(AccountBalance::class);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AccountBalance extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\AccountBalanceFactory> */
|
||||
use HasFactory, HasUuids;
|
||||
|
||||
protected $fillable = [
|
||||
'account_id',
|
||||
'balance_date',
|
||||
'balance',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'balance_date' => 'date',
|
||||
];
|
||||
}
|
||||
|
||||
public function account(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Account::class);
|
||||
}
|
||||
|
||||
public function newUniqueId(): string
|
||||
{
|
||||
return (string) \Illuminate\Support\Str::uuid7();
|
||||
}
|
||||
}
|
||||
7
bun.lock
7
bun.lock
|
|
@ -15,6 +15,7 @@
|
|||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.5",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
|
|
@ -281,6 +282,8 @@
|
|||
|
||||
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.8", "", { "dependencies": { "@radix-ui/react-context": "1.1.3", "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA=="],
|
||||
|
||||
"@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.3.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ=="],
|
||||
|
||||
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
|
||||
|
|
@ -1127,6 +1130,10 @@
|
|||
|
||||
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-progress/@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="],
|
||||
|
||||
"@radix-ui/react-progress/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
|
||||
|
||||
"@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\AccountBalance>
|
||||
*/
|
||||
class AccountBalanceFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'account_id' => \App\Models\Account::factory(),
|
||||
'balance_date' => fake()->date(),
|
||||
'balance' => fake()->numberBetween(100000, 10000000),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('account_balances', function (Blueprint $table) {
|
||||
$table->char('id', 36)->primary();
|
||||
$table->foreignId('account_id')->constrained()->onDelete('cascade');
|
||||
$table->date('balance_date');
|
||||
$table->bigInteger('balance');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['account_id', 'balance_date']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('account_balances');
|
||||
}
|
||||
};
|
||||
|
|
@ -39,6 +39,7 @@
|
|||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.5",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
|
|
|
|||
|
|
@ -97,9 +97,9 @@ export function ImportStepMapping({
|
|||
<SelectValue placeholder="Select date column" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columnOptions.map((option) => (
|
||||
{columnOptions.map((option, index) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
key={`date-${option.value}-${index}`}
|
||||
value={option.value}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
|
|
@ -125,9 +125,9 @@ export function ImportStepMapping({
|
|||
<SelectValue placeholder="Select description column" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columnOptions.map((option) => (
|
||||
{columnOptions.map((option, index) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
key={`desc-${option.value}-${index}`}
|
||||
value={option.value}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
|
|
@ -153,9 +153,36 @@ export function ImportStepMapping({
|
|||
<SelectValue placeholder="Select amount column" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columnOptions.map((option) => (
|
||||
{columnOptions.map((option, index) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
key={`amount-${option.value}-${index}`}
|
||||
value={option.value}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="balance-column">
|
||||
Balance (Optional)
|
||||
</Label>
|
||||
<Select
|
||||
value={columnMapping.balance || '__none__'}
|
||||
onValueChange={(value) =>
|
||||
onMappingChange('balance', value === '__none__' ? '' : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="balance-column">
|
||||
<SelectValue placeholder="Select balance column (optional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">None</SelectItem>
|
||||
{columnOptions.map((option, index) => (
|
||||
<SelectItem
|
||||
key={`balance-${option.value}-${index}`}
|
||||
value={option.value}
|
||||
>
|
||||
{option.label}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
} from '@/lib/import-config-storage';
|
||||
import { getStoredKey } from '@/lib/key-storage';
|
||||
import { evaluateRules } from '@/lib/rule-engine';
|
||||
import { accountBalanceSyncService } from '@/services/account-balance-sync';
|
||||
import { accountSyncService } from '@/services/account-sync';
|
||||
import { automationRuleSyncService } from '@/services/automation-rule-sync';
|
||||
import { transactionSyncService } from '@/services/transaction-sync';
|
||||
|
|
@ -29,7 +30,8 @@ import {
|
|||
type ColumnMapping,
|
||||
type ImportState,
|
||||
} from '@/types/import';
|
||||
import { Check } from 'lucide-react';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Check, Loader2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { ImportStepAccount } from './import-step-account';
|
||||
|
|
@ -45,6 +47,16 @@ interface ImportTransactionsDrawerProps {
|
|||
banks: import('@/types/account').Bank[];
|
||||
}
|
||||
|
||||
interface ImportError {
|
||||
rowNumber: number;
|
||||
transaction: {
|
||||
date: string;
|
||||
description: string;
|
||||
amount: string;
|
||||
};
|
||||
error: string;
|
||||
}
|
||||
|
||||
export function ImportTransactionsDrawer({
|
||||
open,
|
||||
onOpenChange,
|
||||
|
|
@ -54,6 +66,9 @@ export function ImportTransactionsDrawer({
|
|||
}: ImportTransactionsDrawerProps) {
|
||||
const { isKeySet } = useEncryptionKey();
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [importProgress, setImportProgress] = useState(0);
|
||||
const [importTotal, setImportTotal] = useState(0);
|
||||
const [importErrors, setImportErrors] = useState<ImportError[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedAccount, setSelectedAccount] = useState<Account | null>(
|
||||
null,
|
||||
|
|
@ -287,129 +302,197 @@ export function ImportTransactionsDrawer({
|
|||
|
||||
setIsImporting(true);
|
||||
setError(null);
|
||||
setImportErrors([]);
|
||||
|
||||
const newTransactions = state.transactions.filter(
|
||||
(t) => !t.isDuplicate,
|
||||
);
|
||||
const total = newTransactions.length;
|
||||
setImportTotal(total);
|
||||
setImportProgress(0);
|
||||
|
||||
try {
|
||||
if (!selectedAccount) {
|
||||
throw new Error('Selected account not found');
|
||||
if (!selectedAccount) {
|
||||
setError('Selected account not found');
|
||||
setIsImporting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const createdTransactions: any[] = [];
|
||||
const errors: ImportError[] = [];
|
||||
const keyString = getStoredKey();
|
||||
const key = keyString ? await importKey(keyString) : null;
|
||||
const rules = key ? await automationRuleSyncService.getAll() : [];
|
||||
|
||||
for (let i = 0; i < newTransactions.length; i++) {
|
||||
const transaction = newTransactions[i];
|
||||
const rowNumber = i + 1;
|
||||
|
||||
try {
|
||||
const { encrypted, iv } =
|
||||
await transactionSyncService.encryptDescription(
|
||||
transaction.description,
|
||||
);
|
||||
|
||||
const transactionData = {
|
||||
user_id:
|
||||
(selectedAccount as Account & { user_id?: number })
|
||||
.user_id || 0,
|
||||
account_id: selectedAccount.id,
|
||||
category_id: null,
|
||||
description: encrypted,
|
||||
description_iv: iv,
|
||||
transaction_date: transaction.transaction_date,
|
||||
amount: transaction.amount.toString(),
|
||||
currency_code: selectedAccount.currency_code,
|
||||
notes: null,
|
||||
notes_iv: null,
|
||||
};
|
||||
|
||||
const createdTransaction =
|
||||
await transactionSyncService.create(transactionData);
|
||||
|
||||
createdTransactions.push(createdTransaction);
|
||||
} catch (err) {
|
||||
errors.push({
|
||||
rowNumber,
|
||||
transaction: {
|
||||
date: transaction.transaction_date,
|
||||
description: transaction.description,
|
||||
amount: transaction.amount.toString(),
|
||||
},
|
||||
error:
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Unknown error',
|
||||
});
|
||||
}
|
||||
|
||||
const transactionsToImport = await Promise.all(
|
||||
newTransactions.map(async (transaction) => {
|
||||
const { encrypted, iv } =
|
||||
await transactionSyncService.encryptDescription(
|
||||
transaction.description,
|
||||
);
|
||||
setImportProgress(i + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
user_id:
|
||||
(selectedAccount as Account & { user_id?: number })
|
||||
.user_id || 0,
|
||||
account_id: selectedAccount.id,
|
||||
category_id: null,
|
||||
description: encrypted,
|
||||
description_iv: iv,
|
||||
transaction_date: transaction.transaction_date,
|
||||
amount: transaction.amount.toString(),
|
||||
currency_code: selectedAccount.currency_code,
|
||||
notes: null,
|
||||
notes_iv: null,
|
||||
if (key && rules.length > 0 && createdTransactions.length > 0) {
|
||||
for (const createdTransaction of createdTransactions) {
|
||||
try {
|
||||
const decryptedDescription = await decrypt(
|
||||
createdTransaction.description,
|
||||
key,
|
||||
createdTransaction.description_iv,
|
||||
);
|
||||
|
||||
const account = accounts.find(
|
||||
(a) => a.id === createdTransaction.account_id,
|
||||
);
|
||||
const category = createdTransaction.category_id
|
||||
? categories.find(
|
||||
(c) => c.id === createdTransaction.category_id,
|
||||
)
|
||||
: null;
|
||||
|
||||
const decryptedTransaction = {
|
||||
...createdTransaction,
|
||||
decryptedDescription,
|
||||
decryptedNotes: null,
|
||||
account,
|
||||
category: category || null,
|
||||
bank: account?.bank?.id
|
||||
? banks.find((b) => b.id === account.bank.id)
|
||||
: undefined,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const createdTransactions =
|
||||
await transactionSyncService.createMany(transactionsToImport);
|
||||
const result = evaluateRules(
|
||||
decryptedTransaction,
|
||||
rules,
|
||||
categories,
|
||||
accounts,
|
||||
banks,
|
||||
);
|
||||
|
||||
const keyString = getStoredKey();
|
||||
if (keyString) {
|
||||
const key = await importKey(keyString);
|
||||
const rules = await automationRuleSyncService.getAll();
|
||||
if (result) {
|
||||
let finalNotes = createdTransaction.notes;
|
||||
let finalNotesIv = createdTransaction.notes_iv;
|
||||
|
||||
if (rules.length > 0) {
|
||||
for (const transaction of createdTransactions) {
|
||||
const decryptedDescription = await decrypt(
|
||||
transaction.description,
|
||||
key,
|
||||
transaction.description_iv,
|
||||
);
|
||||
|
||||
const account = accounts.find(
|
||||
(a) => a.id === transaction.account_id,
|
||||
);
|
||||
const category = transaction.category_id
|
||||
? categories.find(
|
||||
(c) => c.id === transaction.category_id,
|
||||
)
|
||||
: null;
|
||||
|
||||
const decryptedTransaction = {
|
||||
...transaction,
|
||||
decryptedDescription,
|
||||
decryptedNotes: null,
|
||||
account,
|
||||
category: category || null,
|
||||
bank: account?.bank?.id
|
||||
? banks.find((b) => b.id === account.bank.id)
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const result = evaluateRules(
|
||||
decryptedTransaction,
|
||||
rules,
|
||||
categories,
|
||||
accounts,
|
||||
banks,
|
||||
);
|
||||
|
||||
if (result) {
|
||||
let finalNotes = transaction.notes;
|
||||
let finalNotesIv = transaction.notes_iv;
|
||||
|
||||
if (result.note && result.noteIv) {
|
||||
finalNotes = result.note;
|
||||
finalNotesIv = result.noteIv;
|
||||
}
|
||||
|
||||
await transactionSyncService.update(
|
||||
transaction.id,
|
||||
{
|
||||
category_id: result.categoryId,
|
||||
notes: finalNotes,
|
||||
notes_iv: finalNotesIv,
|
||||
},
|
||||
);
|
||||
if (result.note && result.noteIv) {
|
||||
finalNotes = result.note;
|
||||
finalNotesIv = result.noteIv;
|
||||
}
|
||||
|
||||
await transactionSyncService.update(
|
||||
createdTransaction.id,
|
||||
{
|
||||
category_id: result.categoryId,
|
||||
notes: finalNotes,
|
||||
notes_iv: finalNotesIv,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (ruleError) {
|
||||
console.warn('Failed to apply automation rules to transaction:', ruleError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const balancesToImport = new Map<string, number>();
|
||||
for (const transaction of newTransactions) {
|
||||
if (transaction.balance !== null && transaction.balance !== undefined) {
|
||||
balancesToImport.set(transaction.transaction_date, transaction.balance);
|
||||
}
|
||||
}
|
||||
|
||||
if (balancesToImport.size > 0) {
|
||||
try {
|
||||
const balanceRecords = Array.from(balancesToImport.entries()).map(
|
||||
([date, balance]) => ({
|
||||
account_id: selectedAccount.id,
|
||||
balance_date: date,
|
||||
balance,
|
||||
}),
|
||||
);
|
||||
|
||||
await accountBalanceSyncService.createMany(balanceRecords);
|
||||
} catch (err) {
|
||||
console.error('Failed to import balances:', err);
|
||||
}
|
||||
}
|
||||
|
||||
setImportErrors(errors);
|
||||
setIsImporting(false);
|
||||
|
||||
const successCount = createdTransactions.length;
|
||||
const errorCount = errors.length;
|
||||
|
||||
console.log('Import complete:', { successCount, errorCount, total });
|
||||
|
||||
if (errorCount === 0 && successCount > 0) {
|
||||
toast.success(
|
||||
`${total} transaction${total !== 1 ? 's' : ''} imported`,
|
||||
`${successCount} transaction${successCount !== 1 ? 's' : ''} imported successfully`,
|
||||
{
|
||||
icon: <Check className="h-4 w-4" />,
|
||||
},
|
||||
);
|
||||
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Failed to import transactions',
|
||||
} else if (successCount > 0 && errorCount > 0) {
|
||||
toast.warning(
|
||||
`${successCount} transaction${successCount !== 1 ? 's' : ''} imported, ${errorCount} failed`,
|
||||
);
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Failed to import transactions',
|
||||
} else if (successCount > 0) {
|
||||
toast.success(
|
||||
`${successCount} transaction${successCount !== 1 ? 's' : ''} imported successfully`,
|
||||
{
|
||||
icon: <Check className="h-4 w-4" />,
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
toast.error('All transactions failed to import');
|
||||
}
|
||||
|
||||
transactionSyncService.sync().catch((syncError) => {
|
||||
console.error('Failed to sync transactions with backend:', syncError);
|
||||
});
|
||||
|
||||
accountBalanceSyncService.sync().catch((syncError) => {
|
||||
console.error('Failed to sync balances with backend:', syncError);
|
||||
});
|
||||
};
|
||||
|
||||
const moveToStep = (step: ImportStep) => {
|
||||
|
|
@ -442,6 +525,13 @@ export function ImportTransactionsDrawer({
|
|||
description: 'Review transactions before importing',
|
||||
};
|
||||
default:
|
||||
if (isImporting) {
|
||||
return {
|
||||
title: 'Importing Transactions',
|
||||
description: 'Please wait while we import your transactions',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Import Transactions',
|
||||
description: 'Import transactions from CSV or Excel files',
|
||||
|
|
@ -498,6 +588,78 @@ export function ImportTransactionsDrawer({
|
|||
}
|
||||
};
|
||||
|
||||
const renderImportProgress = () => {
|
||||
const percentage = importTotal > 0 ? (importProgress / importTotal) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>
|
||||
{importProgress} of {importTotal} transactions imported
|
||||
</span>
|
||||
<span>{Math.round(percentage)}%</span>
|
||||
</div>
|
||||
<Progress value={percentage} className="h-4" />
|
||||
</div>
|
||||
|
||||
{importErrors.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-destructive">
|
||||
Errors ({importErrors.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-y-auto rounded-lg border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-muted">
|
||||
<tr className="border-b">
|
||||
<th className="px-4 py-2 text-left font-medium">
|
||||
Row
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left font-medium">
|
||||
Date
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left font-medium">
|
||||
Description
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left font-medium">
|
||||
Amount
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left font-medium">
|
||||
Error
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{importErrors.map((error, index) => (
|
||||
<tr key={index} className="border-b">
|
||||
<td className="px-4 py-2 font-mono text-xs">
|
||||
{error.rowNumber}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{error.transaction.date}
|
||||
</td>
|
||||
<td className="px-4 py-2 max-w-[200px] truncate">
|
||||
{error.transaction.description}
|
||||
</td>
|
||||
<td className="px-4 py-2 font-mono">
|
||||
{error.transaction.amount}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-destructive max-w-[200px] truncate">
|
||||
{error.error}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const stepInfo = getStepInfo();
|
||||
|
||||
return (
|
||||
|
|
@ -515,7 +677,9 @@ export function ImportTransactionsDrawer({
|
|||
<AlertError errors={[error]} />
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4">{renderStep()}</div>
|
||||
<div className="mt-4">
|
||||
{isImporting ? renderImportProgress() : renderStep()}
|
||||
</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
import * as React from 'react';
|
||||
import * as ProgressPrimitive from '@radix-ui/react-progress';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative h-4 w-full overflow-hidden rounded-full bg-secondary',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
));
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName;
|
||||
|
||||
export { Progress };
|
||||
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import type { Transaction } from '@/services/transaction-sync';
|
||||
import type { Account, Bank } from '@/types/account';
|
||||
import type { Account, AccountBalance, Bank } from '@/types/account';
|
||||
import type { AutomationRule } from '@/types/automation-rule';
|
||||
import type { Category } from '@/types/category';
|
||||
import Dexie, { type EntityTable } from 'dexie';
|
||||
|
|
@ -23,16 +23,18 @@ const db = new Dexie('whisper_money') as Dexie & {
|
|||
categories: EntityTable<Category, 'id'>;
|
||||
banks: EntityTable<Bank, 'id'>;
|
||||
automation_rules: EntityTable<AutomationRule, 'id'>;
|
||||
account_balances: EntityTable<AccountBalance, 'id'>;
|
||||
sync_metadata: EntityTable<SyncMetadata, 'key'>;
|
||||
pending_changes: EntityTable<PendingChange, 'id'>;
|
||||
};
|
||||
|
||||
db.version(3).stores({
|
||||
db.version(4).stores({
|
||||
transactions: 'id, user_id, account_id, updated_at',
|
||||
accounts: 'id, user_id, bank_id, updated_at',
|
||||
categories: 'id, user_id, updated_at',
|
||||
banks: 'id, user_id, updated_at',
|
||||
automation_rules: 'id, user_id, priority, updated_at',
|
||||
account_balances: 'id, account_id, balance_date, updated_at',
|
||||
sync_metadata: 'key',
|
||||
pending_changes: '++id, store, timestamp',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -230,6 +230,7 @@ export function autoDetectColumns(headers: string[]): ColumnMapping {
|
|||
transaction_date: null,
|
||||
description: null,
|
||||
amount: null,
|
||||
balance: null,
|
||||
};
|
||||
|
||||
if (!headers || headers.length === 0) {
|
||||
|
|
@ -273,6 +274,14 @@ export function autoDetectColumns(headers: string[]): ColumnMapping {
|
|||
'quantity',
|
||||
'cantidad',
|
||||
];
|
||||
const balancePatterns = [
|
||||
'balance',
|
||||
'saldo',
|
||||
'current balance',
|
||||
'available balance',
|
||||
'saldo actual',
|
||||
'saldo disponible',
|
||||
];
|
||||
|
||||
for (let i = 0; i < lowerHeaders.length; i++) {
|
||||
const header = lowerHeaders[i];
|
||||
|
|
@ -299,6 +308,10 @@ export function autoDetectColumns(headers: string[]): ColumnMapping {
|
|||
if (!mapping.amount && amountPatterns.some((p) => header.includes(p))) {
|
||||
mapping.amount = originalHeader;
|
||||
}
|
||||
|
||||
if (!mapping.balance && balancePatterns.some((p) => header.includes(p))) {
|
||||
mapping.balance = originalHeader;
|
||||
}
|
||||
}
|
||||
|
||||
return mapping;
|
||||
|
|
@ -492,10 +505,19 @@ export function convertRowsToTransactions(
|
|||
|
||||
const formattedDate = date.toISOString().split('T')[0];
|
||||
|
||||
let balance: number | null = null;
|
||||
if (mapping.balance && row[mapping.balance]) {
|
||||
const parsedBalance = parseAmount(row[mapping.balance] as string | number);
|
||||
if (parsedBalance !== null) {
|
||||
balance = Math.round(parsedBalance * 100);
|
||||
}
|
||||
}
|
||||
|
||||
results.push({
|
||||
transaction_date: formattedDate,
|
||||
description,
|
||||
amount,
|
||||
balance,
|
||||
validationErrors: [],
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,127 @@
|
|||
import { db } from '@/lib/dexie-db';
|
||||
import { SyncManager } from '@/lib/sync-manager';
|
||||
import type { AccountBalance } from '@/types/account';
|
||||
import { uuidv7 } from 'uuidv7';
|
||||
|
||||
class AccountBalanceSyncService {
|
||||
private syncManager: SyncManager;
|
||||
|
||||
constructor() {
|
||||
this.syncManager = new SyncManager({
|
||||
storeName: 'account_balances',
|
||||
endpoint: '/api/sync/account-balances',
|
||||
});
|
||||
}
|
||||
|
||||
async sync(): Promise<void> {
|
||||
return await this.syncManager.sync();
|
||||
}
|
||||
|
||||
async getAll(): Promise<AccountBalance[]> {
|
||||
return await this.syncManager.getAll<AccountBalance>();
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<AccountBalance | null> {
|
||||
return (await db.account_balances.get(id)) || null;
|
||||
}
|
||||
|
||||
async getByAccountId(accountId: number): Promise<AccountBalance[]> {
|
||||
try {
|
||||
const allBalances = await this.getAll();
|
||||
return allBalances.filter((b) => b.account_id === accountId);
|
||||
} catch (error) {
|
||||
console.warn('Failed to get balances from IndexedDB:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async create(
|
||||
data: Omit<AccountBalance, 'id' | 'created_at' | 'updated_at'>,
|
||||
): Promise<AccountBalance> {
|
||||
return await this.syncManager.create<
|
||||
AccountBalance,
|
||||
Omit<AccountBalance, 'id' | 'created_at' | 'updated_at'> & {
|
||||
id?: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
>(data);
|
||||
}
|
||||
|
||||
async createMany(
|
||||
balances: Omit<AccountBalance, 'id' | 'created_at' | 'updated_at'>[],
|
||||
): Promise<AccountBalance[]> {
|
||||
try {
|
||||
const timestamp = new Date().toISOString();
|
||||
const created: AccountBalance[] = [];
|
||||
|
||||
for (const data of balances) {
|
||||
const record = {
|
||||
...data,
|
||||
id: uuidv7(),
|
||||
created_at: timestamp,
|
||||
updated_at: timestamp,
|
||||
} as AccountBalance;
|
||||
|
||||
await db.account_balances.put(record);
|
||||
await db.pending_changes.add({
|
||||
store: 'account_balances',
|
||||
operation: 'create',
|
||||
data: record,
|
||||
timestamp,
|
||||
});
|
||||
|
||||
created.push(record);
|
||||
}
|
||||
|
||||
return created;
|
||||
} catch (error) {
|
||||
console.error('Failed to create balances in IndexedDB:', error);
|
||||
throw new Error(
|
||||
'Failed to save balances locally. Please refresh the page and try again.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<AccountBalance>): Promise<void> {
|
||||
const existing = await this.getById(id);
|
||||
if (!existing) {
|
||||
throw new Error('Balance not found');
|
||||
}
|
||||
|
||||
return await this.syncManager.update<AccountBalance>(id.toString(), data);
|
||||
}
|
||||
|
||||
async updateOrCreate(
|
||||
accountId: number,
|
||||
balanceDate: string,
|
||||
balance: number,
|
||||
): Promise<AccountBalance> {
|
||||
try {
|
||||
const existing = await db.account_balances
|
||||
.where({ account_id: accountId, balance_date: balanceDate })
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
await this.update(existing.id, { balance });
|
||||
return (await this.getById(existing.id))!;
|
||||
} else {
|
||||
return await this.create({
|
||||
account_id: accountId,
|
||||
balance_date: balanceDate,
|
||||
balance,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update or create balance:', error);
|
||||
throw new Error('Failed to save balance. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
return await this.syncManager.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
export const accountBalanceSyncService = new AccountBalanceSyncService();
|
||||
|
||||
|
|
@ -38,6 +38,15 @@ export interface Account {
|
|||
currency_code: CurrencyCode;
|
||||
}
|
||||
|
||||
export interface AccountBalance {
|
||||
id: string;
|
||||
account_id: number;
|
||||
balance_date: string;
|
||||
balance: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export function formatAccountType(type: AccountType): string {
|
||||
const typeMap: Record<AccountType, string> = {
|
||||
checking: 'Checking',
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export interface ColumnMapping {
|
|||
transaction_date: string | null;
|
||||
description: string | null;
|
||||
amount: string | null;
|
||||
balance: string | null;
|
||||
}
|
||||
|
||||
export interface ParsedRow {
|
||||
|
|
@ -25,6 +26,7 @@ export interface ParsedTransaction {
|
|||
transaction_date: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
balance?: number | null;
|
||||
isDuplicate?: boolean;
|
||||
validationErrors?: string[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\AccountBalanceController;
|
||||
use App\Http\Controllers\EncryptionController;
|
||||
use App\Http\Controllers\Sync\AccountBalanceSyncController;
|
||||
use App\Http\Controllers\Sync\AccountSyncController;
|
||||
use App\Http\Controllers\Sync\BankSyncController;
|
||||
use App\Http\Controllers\Sync\CategorySyncController;
|
||||
|
|
@ -32,6 +34,10 @@ Route::middleware(['auth'])->group(function () {
|
|||
Route::post('api/sync/transactions', [\App\Http\Controllers\Sync\TransactionSyncController::class, 'store']);
|
||||
Route::patch('api/sync/transactions/{transaction}', [\App\Http\Controllers\Sync\TransactionSyncController::class, 'update']);
|
||||
Route::delete('api/sync/transactions/{transaction}', [\App\Http\Controllers\Sync\TransactionSyncController::class, 'destroy']);
|
||||
Route::get('api/sync/account-balances', [AccountBalanceSyncController::class, 'index']);
|
||||
Route::post('api/sync/account-balances', [AccountBalanceSyncController::class, 'store']);
|
||||
Route::patch('api/sync/account-balances/{accountBalance}', [AccountBalanceSyncController::class, 'update']);
|
||||
Route::put('api/accounts/{account}/balance/current', [AccountBalanceController::class, 'updateCurrent'])->name('accounts.balance.update-current');
|
||||
});
|
||||
|
||||
Route::middleware(['auth', 'verified', 'redirect.encryption'])->group(function () {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\AccountBalance;
|
||||
use App\Models\User;
|
||||
|
||||
it('can update current balance for an account', function () {
|
||||
$user = User::factory()->create();
|
||||
$account = Account::factory()->for($user)->create();
|
||||
|
||||
$balanceData = [
|
||||
'balance' => 134566,
|
||||
];
|
||||
|
||||
$response = $this->actingAs($user)->putJson("/api/accounts/{$account->id}/balance/current", $balanceData);
|
||||
|
||||
$response->assertSuccessful()
|
||||
->assertJsonStructure([
|
||||
'data' => [
|
||||
'id',
|
||||
'account_id',
|
||||
'balance_date',
|
||||
'balance',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
],
|
||||
])
|
||||
->assertJsonPath('data.account_id', $account->id)
|
||||
->assertJsonPath('data.balance', 134566);
|
||||
|
||||
expect($response->json('data.balance_date'))->toContain(now()->toDateString());
|
||||
|
||||
$this->assertDatabaseHas('account_balances', [
|
||||
'account_id' => $account->id,
|
||||
'balance_date' => now()->toDateString(),
|
||||
'balance' => 134566,
|
||||
]);
|
||||
});
|
||||
|
||||
it('updates existing balance for current date', function () {
|
||||
$user = User::factory()->create();
|
||||
$account = Account::factory()->for($user)->create();
|
||||
|
||||
AccountBalance::factory()->for($account)->create([
|
||||
'balance_date' => now()->toDateString(),
|
||||
'balance' => 100000,
|
||||
]);
|
||||
|
||||
$balanceData = [
|
||||
'balance' => 200000,
|
||||
];
|
||||
|
||||
$response = $this->actingAs($user)->putJson("/api/accounts/{$account->id}/balance/current", $balanceData);
|
||||
|
||||
$response->assertSuccessful()
|
||||
->assertJsonPath('data.balance', 200000);
|
||||
|
||||
$this->assertDatabaseCount('account_balances', 1);
|
||||
$this->assertDatabaseHas('account_balances', [
|
||||
'account_id' => $account->id,
|
||||
'balance_date' => now()->toDateString(),
|
||||
'balance' => 200000,
|
||||
]);
|
||||
});
|
||||
|
||||
it('cannot update balance for another user account', function () {
|
||||
$user = User::factory()->create();
|
||||
$otherUser = User::factory()->create();
|
||||
$account = Account::factory()->for($otherUser)->create();
|
||||
|
||||
$balanceData = [
|
||||
'balance' => 999999,
|
||||
];
|
||||
|
||||
$response = $this->actingAs($user)->putJson("/api/accounts/{$account->id}/balance/current", $balanceData);
|
||||
|
||||
$response->assertForbidden();
|
||||
|
||||
$this->assertDatabaseMissing('account_balances', [
|
||||
'account_id' => $account->id,
|
||||
'balance' => 999999,
|
||||
]);
|
||||
});
|
||||
|
||||
it('validates required balance field', function () {
|
||||
$user = User::factory()->create();
|
||||
$account = Account::factory()->for($user)->create();
|
||||
|
||||
$response = $this->actingAs($user)->putJson("/api/accounts/{$account->id}/balance/current", []);
|
||||
|
||||
$response->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['balance']);
|
||||
});
|
||||
|
||||
it('validates balance is an integer', function () {
|
||||
$user = User::factory()->create();
|
||||
$account = Account::factory()->for($user)->create();
|
||||
|
||||
$response = $this->actingAs($user)->putJson("/api/accounts/{$account->id}/balance/current", [
|
||||
'balance' => 'not-a-number',
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['balance']);
|
||||
});
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\AccountBalance;
|
||||
use App\Models\User;
|
||||
|
||||
it('can fetch user account balances', function () {
|
||||
$user = User::factory()->create();
|
||||
$account = Account::factory()->for($user)->create();
|
||||
$balances = AccountBalance::factory()->count(3)->for($account)->create();
|
||||
|
||||
$response = $this->actingAs($user)->getJson('/api/sync/account-balances');
|
||||
|
||||
$response->assertSuccessful()
|
||||
->assertJsonCount(3, 'data')
|
||||
->assertJsonStructure([
|
||||
'data' => [
|
||||
'*' => [
|
||||
'id',
|
||||
'account_id',
|
||||
'balance_date',
|
||||
'balance',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
],
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('only returns user own account balances', function () {
|
||||
$user = User::factory()->create();
|
||||
$otherUser = User::factory()->create();
|
||||
$account = Account::factory()->for($user)->create();
|
||||
$otherAccount = Account::factory()->for($otherUser)->create();
|
||||
|
||||
AccountBalance::factory()->for($account)->create();
|
||||
AccountBalance::factory()->for($otherAccount)->create();
|
||||
|
||||
$response = $this->actingAs($user)->getJson('/api/sync/account-balances');
|
||||
|
||||
$response->assertSuccessful()
|
||||
->assertJsonCount(1, 'data');
|
||||
});
|
||||
|
||||
it('can filter balances by updated_at', function () {
|
||||
$user = User::factory()->create();
|
||||
$account = Account::factory()->for($user)->create();
|
||||
|
||||
$oldBalance = AccountBalance::factory()->for($account)->create([
|
||||
'updated_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$newBalance = AccountBalance::factory()->for($account)->create([
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->getJson('/api/sync/account-balances?since='.now()->subDay()->toISOString());
|
||||
|
||||
$response->assertSuccessful()
|
||||
->assertJsonCount(1, 'data')
|
||||
->assertJsonPath('data.0.id', $newBalance->id);
|
||||
});
|
||||
|
||||
it('can create an account balance', function () {
|
||||
$user = User::factory()->create();
|
||||
$account = Account::factory()->for($user)->create();
|
||||
|
||||
$balanceData = [
|
||||
'account_id' => $account->id,
|
||||
'balance_date' => now()->toDateString(),
|
||||
'balance' => 134566,
|
||||
];
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/api/sync/account-balances', $balanceData);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJsonStructure([
|
||||
'data' => [
|
||||
'id',
|
||||
'account_id',
|
||||
'balance_date',
|
||||
'balance',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('account_balances', [
|
||||
'account_id' => $account->id,
|
||||
'balance_date' => now()->toDateString(),
|
||||
'balance' => 134566,
|
||||
]);
|
||||
});
|
||||
|
||||
it('can create an account balance with an ID', function () {
|
||||
$user = User::factory()->create();
|
||||
$account = Account::factory()->for($user)->create();
|
||||
$id = (string) \Illuminate\Support\Str::uuid7();
|
||||
|
||||
$balanceData = [
|
||||
'id' => $id,
|
||||
'account_id' => $account->id,
|
||||
'balance_date' => now()->toDateString(),
|
||||
'balance' => 250000,
|
||||
];
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/api/sync/account-balances', $balanceData);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJsonPath('data.id', $id);
|
||||
|
||||
$this->assertDatabaseHas('account_balances', [
|
||||
'id' => $id,
|
||||
'account_id' => $account->id,
|
||||
]);
|
||||
});
|
||||
|
||||
it('validates required fields when creating a balance', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/api/sync/account-balances', []);
|
||||
|
||||
$response->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['account_id', 'balance_date', 'balance']);
|
||||
});
|
||||
|
||||
it('can update an account balance', function () {
|
||||
$user = User::factory()->create();
|
||||
$account = Account::factory()->for($user)->create();
|
||||
$balance = AccountBalance::factory()->for($account)->create([
|
||||
'balance' => 100000,
|
||||
]);
|
||||
|
||||
$updateData = [
|
||||
'account_id' => $account->id,
|
||||
'balance_date' => $balance->balance_date->toDateString(),
|
||||
'balance' => 200000,
|
||||
];
|
||||
|
||||
$response = $this->actingAs($user)->patchJson("/api/sync/account-balances/{$balance->id}", $updateData);
|
||||
|
||||
$response->assertSuccessful()
|
||||
->assertJsonPath('data.balance', 200000);
|
||||
|
||||
$this->assertDatabaseHas('account_balances', [
|
||||
'id' => $balance->id,
|
||||
'balance' => 200000,
|
||||
]);
|
||||
});
|
||||
|
||||
it('cannot update another user account balance', function () {
|
||||
$user = User::factory()->create();
|
||||
$otherUser = User::factory()->create();
|
||||
$account = Account::factory()->for($otherUser)->create();
|
||||
$balance = AccountBalance::factory()->for($account)->create();
|
||||
|
||||
$updateData = [
|
||||
'account_id' => $account->id,
|
||||
'balance_date' => $balance->balance_date->toDateString(),
|
||||
'balance' => 999999,
|
||||
];
|
||||
|
||||
$response = $this->actingAs($user)->patchJson("/api/sync/account-balances/{$balance->id}", $updateData);
|
||||
|
||||
$response->assertForbidden();
|
||||
});
|
||||
|
||||
it('enforces unique constraint on account_id and balance_date', function () {
|
||||
$user = User::factory()->create();
|
||||
$account = Account::factory()->for($user)->create();
|
||||
$date = now()->toDateString();
|
||||
|
||||
AccountBalance::factory()->for($account)->create([
|
||||
'balance_date' => $date,
|
||||
'balance' => 100000,
|
||||
]);
|
||||
|
||||
$balanceData = [
|
||||
'account_id' => $account->id,
|
||||
'balance_date' => $date,
|
||||
'balance' => 200000,
|
||||
];
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/api/sync/account-balances', $balanceData);
|
||||
|
||||
$response->assertStatus(500);
|
||||
});
|
||||
Loading…
Reference in New Issue