nycorp / finance
to handle wallet
Requires
- ext-bcmath: *
- ext-json: *
Requires (Dev)
- dohone/payment: dev-master
- nycorp/lite-api: ^1.6
- orchestra/testbench: ^9.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-master
- 2.3.0
- 2.2.0
- 2.1.1
- 2.1.0
- 2.0.0
- 1.4.8
- 1.4.7
- 1.4.6
- 1.4.5
- 1.4.4
- 1.4.3
- 1.4.2
- 1.4.1
- 1.4.0
- 1.3.202
- 1.3.201
- 1.3.200
- 1.3.118
- 1.3.117
- 1.3.116
- 1.3.115
- 1.3.114
- 1.3.113
- 1.3.112
- 1.3.111
- 1.3.110
- 1.3.11
- 1.3.10
- 1.3.4
- 1.3.3
- 1.3.2
- 1.3.1
- 1.3.0
- 1.2.954
- 1.2.953
- 1.2.952
- 1.2.951
- 1.2.95
- 1.2.94
- 1.2.93
- 1.2.92
- 1.2.91
- 1.2.9
- 1.2.8
- 1.2.7
- 1.2.6
- 1.2.5
- 1.2.4
- 1.2.3
- 1.2.2
- 1.2.1
- 1.2.0
- 1.0.2
- 1.0.1
- 1.0
This package is auto-updated.
Last update: 2026-11-02 01:19:54 UTC
README
Features
- make deposit
- make withdrawal
- Set account threshold
- Listen to success transaction
- Custom payment provider
Installation (with Composer)
composer require nycorp/finance
nycorp/lite-api is no longer required. When lite-api 1.x is installed, the finance responses (DefResponse) and
exceptions (FinanceException) extend the lite-api ones, so code typed against lite-api keeps working; set
FINANCE_LITE_API_BRIDGE=0 to turn that off.
Configuration
Publish migration file and config
php artisan vendor:publish --provider="NYCorp\Finance\FinanceServiceProvider"
Run migration
php artisan migrate
Get exchange rate API KEY of https://api.exchangeratesapi.io and set in .env
EXCHANGE_RATE_API_KEY=<api key> # optional, defaults to https://api.exchangeratesapi.io/v1/latest EXCHANGE_RATE_API_URL=
Set a dedicated signing key (see Integrity)
FINANCE_SIGNATURE_KEY_ID=k1 FINANCE_SIGNATURE_KEY=base64:... # php -r "echo 'base64:'.base64_encode(random_bytes(32)).PHP_EOL;"
Usage
Add Finance Account Trait to any model
use FinanceAccountTrait;
(Optional) Set Threshold the min balance for an account
return User::first()->setThreshold(100)
For deposit
return User::first()->deposit(DefaultPaymentProvider::getId(), 12, $description)
Get balance
return User::first()->balance
For withdrawal
return User::first()->withdrawal(DefaultPaymentProvider::getId(), 12, $description)
Customize the canWithdraw in the model (Optional)
public function canWithdraw(float $amount, bool $forceBalanceCalculation): bool { //EX : Set to true because the account is debited only when the service is consumed return true; }
Set model currency by adding this method in the model
public function getCurrency() { // Implement your logic to get currency here the default value is set in the finance config file return \NYCorp\Finance\Http\Core\ConfigReader::getDefaultCurrency(); }
Zero amounts are refused, unless explicitly allowed (e.g. to record a plan granted for free)
return User::first()->credit(DefaultPaymentProvider::getId(), 0, 'Pro plan granted', allowZeroAmount: true)
Public, translatable description and search tags (optional on credit, debit, deposit, withdrawal and transfer)
use NYCorp\Finance\Models\TransactionDisplay; $user->credit(DefaultPaymentProvider::getId(), 9.99, 'plan Pro #42 (internal)', display: TransactionDisplay::make('subscription.payment') ->with('plan', 'Pro') // string, int, float, bool, null or backed enum ->withDate('from', $subscription->starts_at), // Y-m-d by default tags: ['plan:pro', 'invoice:FAC-001']); $transaction->display; // TransactionDisplay (key(), params(), param(), translate()) $transaction->describe(); // __('subscription.payment', [...]) from your lang files, else the description $transaction->tag('campaign:CMP-12'); // tags can be added later, even on a finalized transaction FinanceTransaction::tagged('plan:pro', 'invoice:FAC-001')->get(); // all the tags FinanceTransaction::taggedAny('merchant:eneo', 'merchant:camwater')->get(); // any of them
TransactionDisplay is immutable and validated when built (an invalid key or parameter fails in your code, not in
the library). It stores the key and its parameters, never the text, and is part of the v3 signature; tags live in an indexed
table and are not signed (they are for search, not proof).
Check if user can make transaction if his finance account is not disabled
return Company::first()->canMakeTransaction() ? Company::first()->withdrawal(DefaultPaymentProvider::getId(), 12, $description) : 'Your account is disabled';
Check if user can make transaction if his finance account has enough balance base on threshold use true to force balance calculation
return Company::first()->canWithdraw(100,true) ? Company::first()->withdrawal(DefaultPaymentProvider::getId(), 12, $description) : 'Insufficient balance';
To listen to success transaction
php artisan make:listener SuccessFinanceTransactionListener --event=FinanceTransactionSuccessEvent
/** * Handle the event. */ public function handle(FinanceTransactionSuccessEvent $event): void { # In case you handle multiple model match (get_class($event->model)) { Model1::class => $this->handleModel1($event), Model2::class => $this->hanleModel2($event), default => static fn() => Log::warning("FinanceTransactionSuccessEvent Model not handle") }; }
Custom Provider
use NYCorp\Finance\Http\Payment\PaymentProviderGateway; class CustomPaymentProvider extends PaymentProviderGateway { public static function getName(): string { return 'CustomProvider'; } public function deposit(FinanceTransaction $transaction): PaymentProviderGateway { #Your custom logic here //use this url for callback $callbackUrl = self::depositNotificationUrl(); $amountToPay = $transaction->getConvertedAmount(); $response = Http::post('https://api-checkout/v2/payment', $formData); $this->successful = $response->successful(); $this->message = $response->json('description'); $this->response = new FinanceProviderGatewayResponse($transaction, $this->getWallet($transaction)->id, $response->body(), false, $response->json('data.payment_url')); return $this; } public static function getId(): string { return 'MY_PROVIDER_ID'; } public function withdrawal(FinanceTransaction $transaction): PaymentProviderGateway { #Your custom logic here //use this url for callback $callbackUrl = self::withdrawalNotificationUrl(); $response = Http::post('https://api-checkout/v2/payment', $formData); $this->successful = $response->successful(); $this->message = $response->json('description'); $this->response = new FinanceProviderGatewayResponse($transaction, $this->getWallet($transaction)->id, $response->body(), false, $response->json('data.payment_url')); return $this; } public function onDepositSuccess(Request $request): PaymentProviderGateway { # For example here the transaction id is inside the cpm_trans_id in your case it maybe another value if ($this->findTransaction($request, 'cpm_trans_id') === null) { return $this; // answered "not found", nothing changes } if (!hash_equals($this->expectedSignature($request), (string)$request->get('signature'))) { $this->rejectCallback('invalid signature'); // the transaction stays PENDING return $this; } match ($request->get('status')) { 'ACCEPTED' => $this->successful = true, 'REFUSED' => $this->markDefinitiveFailure(), // closes the transaction as FAILED default => null, // still pending: nothing changes }; if ($this->successful()) { $this->setExternalId($request->get('operator_id')); // COMPLETED needs success AND a reference } return $this; } public function onWithdrawalSuccess(Request $request): PaymentProviderGateway { return $this; } }
The notification controller only applies the provider's verdict (resolveState()): a success with an external id
completes the transaction, markDefinitiveFailure() fails it, anything else leaves it PENDING, and a callback rejected,
for an unknown transaction or for another provider's transaction changes nothing. Repeated callbacks are idempotent.
Do not log $request->all(): callbacks carry personal data.
Register provider in config
return [ 'default_payment_provider_id' => 'LOCAL_PROVIDER', 'default_payment_provider_name' => env('APP_NAME')."'s Local Provider", 'default_threshold' => 0, #minimum account balance applied to all model 'default_currency' => 'USD', 'refresh_account_ttl' => 60, #in minute 'payment_providers' => [ \NYCorp\Finance\Http\Payment\DefaultPaymentProvider::class, \NYCorp\Finance\Http\Payment\CustomPaymentProvider::class, ], 'force_balance_check_min_amount' => 5000, 'api_prefix' => env('FINANCE_API_PREFIX', 'api'), # API routes: {api_prefix}/{prefix}/... (api/finance) 'prefix' => env('FINANCE_ROUTE_PREFIX', 'finance'), # web routes: {prefix}/... 'middleware' => ['api'], 'user_email_field' => "email", 'finance_account_id_parameter' => "finance_account_id", ];
The provider callback URLs (/api/finance/deposit/notification/{provider}, ...) are built from the route names, so
they follow api_prefix and prefix: update the URLs registered with your providers when you change them. Either
prefix may be empty. A config published before 2.2 has no api_prefix and keeps api.
Integrity
Every money movement is tamper-evident:
- Signature v3: transactions (start signature + checksum), wallets and ledgers are signed with HMAC-SHA256 and
the key
FINANCE_SIGNATURE_KEY, stored asv3:{keyId}:{hmac}. Without the key, nobody with database access can recompute them. Rows signed by older versions (v1, v2) are still verified. Without a key the library keeps the unkeyed v2 hashes. - Key rotation: give the new key a new
FINANCE_SIGNATURE_KEY_IDand keep the old one inFINANCE_SIGNATURE_PREVIOUS_KEYS=k1:base64:.... - Hash chain (
finance_chain_entries): each finalized transaction is appended to its owner's chain, each entry signing the previous one. A deleted, inserted or modified entry breaks the chain; sealed transactions cannot be deleted. - Audit log (
finance_audit_logs): signed, append-only rows for finalizations, rejected callbacks, balance recalculations and threshold changes, with the actor and the context (route or command). - Finalization (state, checksum, lock, chain entry, audit row, balance increment) is one database transaction; a debit
locks the account and reserves the pending debits of the last
FINANCE_PENDING_RESERVATION_TTLminutes.
Commands:
php artisan finance:verify-integrity # exit code 1 on any error; --fix-balances, --json php artisan finance:seal-history # once after upgrading: seal the transactions finalized before the chain php artisan finance:expire-pending # cancel transactions a provider never answered for FINANCE_PENDING_EXPIRY_MINUTES (7 days); --minutes overrides it
Acknowledged transactions
A legacy row whose signature failure was investigated and found genuine can be listed in the published config
(config/finance.php), as transaction id => reason:
'integrity' => [ 'acknowledged' => [ '340fe1de-e9a8-4218-a572-8e3f946c76cd' => 'Fee signed with 6 decimals before 2.0, column rounded to 5', '4df0cace-0e0a-471b-aa6b-d98ea9094e82' => 'Fee signed with 6 decimals before 2.0, column rounded to 5', ], 'acknowledged_mode' => env('FINANCE_INTEGRITY_ACKNOWLEDGED_MODE', 'rounding'), ],
finance:verify-integrity then reports their start signature or checksum failure as a warning quoting the reason, and
counts them in acknowledged_transactions. Nothing else is skipped: their chain entry, wallet and the owner's balance
are verified as usual, and any other error on the row (locked in the wrong state, not locked) stays an error. A listed
id that no longer exists, or whose signature verifies again, produces a warning so the list can be cleaned up.
Modes:
rounding(default): before 2.0 an amount could be signed with 6 decimals (-0.004572) and then rounded to 5 by theamountcolumn (-0.00457). The entry is accepted only if the signatures verify with the amount ofstart_log.parameters.amount(with the sign of the movement) and that amount rounds to the stored one. Any other alteration, e.g. a rewritten amount, stays an error even when the id is listed.any: every listed id is accepted as is. Use it only for failures with another proven cause.
finance:expire-pending skips acknowledged PENDING rows and leaves them untouched (canceling would checksum a failing
start signature). Any other transaction it cannot cancel is logged and skipped; the command then exits with 1.
Never re-sign such rows: the original signature is the proof that the amount was not changed beyond rounding. Since 2.0 amounts are rounded before being signed, so new rows are not affected.
Upgrading to 2.0: publish and run the four new migrations, set the key, run finance:seal-history, restart the queue
workers, then schedule finance:verify-integrity.
Tests
composer install vendor/bin/phpunit # SQLite in memory FINANCE_TEST_DB=mysql FINANCE_TEST_DB_DATABASE=<throw-away db> FINANCE_TEST_DB_PASSWORD=... vendor/bin/phpunit
Response handle
$response = \Nycorp\LiteApi\Response\DefResponse::parse(User::first()->withdrawal(DefaultPaymentProvider::getId(), 12, $description)); $response->getBody(); // get the body of the response $response->isSuccess(); // get the success state as boolean $response->getMessage(); // get response message