diff --git a/migrations/2024_01_01_000001_improve_transactions_table.php b/migrations/2024_01_01_000001_improve_transactions_table.php new file mode 100644 index 0000000..1570c3e --- /dev/null +++ b/migrations/2024_01_01_000001_improve_transactions_table.php @@ -0,0 +1,205 @@ +char('subject_uuid', 36)->nullable()->after('owner_type'); + $table->string('subject_type')->nullable()->after('subject_uuid'); + + // ---------------------------------------------------------------- + // New polymorphic: payer (funds flow from) + // ---------------------------------------------------------------- + $table->char('payer_uuid', 36)->nullable()->after('subject_type'); + $table->string('payer_type')->nullable()->after('payer_uuid'); + + // ---------------------------------------------------------------- + // New polymorphic: payee (funds flow to) + // ---------------------------------------------------------------- + $table->char('payee_uuid', 36)->nullable()->after('payer_type'); + $table->string('payee_type')->nullable()->after('payee_uuid'); + + // ---------------------------------------------------------------- + // New polymorphic: initiator (what triggered the transaction) + // ---------------------------------------------------------------- + $table->char('initiator_uuid', 36)->nullable()->after('payee_type'); + $table->string('initiator_type')->nullable()->after('initiator_uuid'); + + // ---------------------------------------------------------------- + // New polymorphic: context (related business object) + // ---------------------------------------------------------------- + $table->char('context_uuid', 36)->nullable()->after('initiator_type'); + $table->string('context_type')->nullable()->after('context_uuid'); + + // ---------------------------------------------------------------- + // Direction and balance + // ---------------------------------------------------------------- + $table->string('direction')->nullable()->after('status'); // credit | debit + $table->integer('balance_after')->nullable()->after('direction'); + + // ---------------------------------------------------------------- + // Monetary breakdown (all in smallest currency unit / cents) + // ---------------------------------------------------------------- + $table->integer('fee_amount')->default(0)->after('amount'); + $table->integer('tax_amount')->default(0)->after('fee_amount'); + $table->integer('net_amount')->default(0)->after('tax_amount'); + + // ---------------------------------------------------------------- + // Multi-currency settlement + // ---------------------------------------------------------------- + $table->decimal('exchange_rate', 18, 8)->default(1)->after('currency'); + $table->string('settled_currency', 3)->nullable()->after('exchange_rate'); + $table->integer('settled_amount')->nullable()->after('settled_currency'); + + // ---------------------------------------------------------------- + // Idempotency and linkage + // ---------------------------------------------------------------- + $table->string('reference', 191)->nullable()->unique()->after('description'); + $table->char('parent_transaction_uuid', 36)->nullable()->after('reference'); + + // ---------------------------------------------------------------- + // Gateway enrichment + // ---------------------------------------------------------------- + $table->json('gateway_response')->nullable()->after('gateway_transaction_id'); + $table->string('payment_method', 50)->nullable()->after('gateway_response'); + $table->string('payment_method_last4', 4)->nullable()->after('payment_method'); + $table->string('payment_method_brand', 50)->nullable()->after('payment_method_last4'); + + // ---------------------------------------------------------------- + // Traceability and compliance + // ---------------------------------------------------------------- + $table->string('ip_address', 45)->nullable()->after('meta'); + $table->text('notes')->nullable()->after('ip_address'); + $table->string('failure_reason', 191)->nullable()->after('notes'); + $table->string('failure_code', 50)->nullable()->after('failure_reason'); + + // ---------------------------------------------------------------- + // Reporting + // ---------------------------------------------------------------- + $table->string('period', 7)->nullable()->after('failure_code'); // YYYY-MM + $table->json('tags')->nullable()->after('period'); + + // ---------------------------------------------------------------- + // Lifecycle timestamps + // ---------------------------------------------------------------- + $table->timestamp('settled_at')->nullable()->after('updated_at'); + $table->timestamp('voided_at')->nullable()->after('settled_at'); + $table->timestamp('reversed_at')->nullable()->after('voided_at'); + $table->timestamp('expires_at')->nullable()->after('reversed_at'); + }); + + // -------------------------------------------------------------------- + // Backfill subject_* from owner_* (preserve existing data) + // -------------------------------------------------------------------- + DB::statement('UPDATE transactions SET subject_uuid = owner_uuid, subject_type = owner_type WHERE owner_uuid IS NOT NULL'); + + // -------------------------------------------------------------------- + // Backfill payer_* from customer_* (customer was semantically the payer) + // -------------------------------------------------------------------- + DB::statement('UPDATE transactions SET payer_uuid = customer_uuid, payer_type = customer_type WHERE customer_uuid IS NOT NULL'); + + // -------------------------------------------------------------------- + // Backfill period from created_at + // -------------------------------------------------------------------- + DB::statement("UPDATE transactions SET period = DATE_FORMAT(created_at, '%Y-%m') WHERE created_at IS NOT NULL"); + + // -------------------------------------------------------------------- + // Backfill net_amount = amount (no fees/tax in legacy records) + // -------------------------------------------------------------------- + DB::statement('UPDATE transactions SET net_amount = COALESCE(amount, 0) WHERE net_amount = 0'); + + // -------------------------------------------------------------------- + // Add indexes on new columns + // -------------------------------------------------------------------- + Schema::table('transactions', function (Blueprint $table) { + $table->index(['subject_uuid', 'subject_type'], 'transactions_subject_index'); + $table->index(['payer_uuid', 'payer_type'], 'transactions_payer_index'); + $table->index(['payee_uuid', 'payee_type'], 'transactions_payee_index'); + $table->index(['initiator_uuid', 'initiator_type'], 'transactions_initiator_index'); + $table->index(['context_uuid', 'context_type'], 'transactions_context_index'); + $table->index('direction', 'transactions_direction_index'); + $table->index('period', 'transactions_period_index'); + $table->index('parent_transaction_uuid', 'transactions_parent_index'); + $table->index('payment_method', 'transactions_payment_method_index'); + $table->index('settled_at', 'transactions_settled_at_index'); + $table->index(['company_uuid', 'type'], 'transactions_company_type_index'); + $table->index(['company_uuid', 'status'], 'transactions_company_status_index'); + $table->index(['company_uuid', 'period'], 'transactions_company_period_index'); + }); + } + + public function down(): void + { + Schema::table('transactions', function (Blueprint $table) { + // Drop indexes + $table->dropIndex('transactions_subject_index'); + $table->dropIndex('transactions_payer_index'); + $table->dropIndex('transactions_payee_index'); + $table->dropIndex('transactions_initiator_index'); + $table->dropIndex('transactions_context_index'); + $table->dropIndex('transactions_direction_index'); + $table->dropIndex('transactions_period_index'); + $table->dropIndex('transactions_parent_index'); + $table->dropIndex('transactions_payment_method_index'); + $table->dropIndex('transactions_settled_at_index'); + $table->dropIndex('transactions_company_type_index'); + $table->dropIndex('transactions_company_status_index'); + $table->dropIndex('transactions_company_period_index'); + $table->dropUnique(['reference']); + + // Drop new columns + $table->dropColumn([ + 'subject_uuid', 'subject_type', + 'payer_uuid', 'payer_type', + 'payee_uuid', 'payee_type', + 'initiator_uuid', 'initiator_type', + 'context_uuid', 'context_type', + 'direction', 'balance_after', + 'fee_amount', 'tax_amount', 'net_amount', + 'exchange_rate', 'settled_currency', 'settled_amount', + 'reference', 'parent_transaction_uuid', + 'gateway_response', + 'payment_method', 'payment_method_last4', 'payment_method_brand', + 'ip_address', 'notes', 'failure_reason', 'failure_code', + 'period', 'tags', + 'settled_at', 'voided_at', 'reversed_at', 'expires_at', + ]); + }); + } +}; diff --git a/migrations/2024_01_01_000002_improve_transaction_items_table.php b/migrations/2024_01_01_000002_improve_transaction_items_table.php new file mode 100644 index 0000000..8066f19 --- /dev/null +++ b/migrations/2024_01_01_000002_improve_transaction_items_table.php @@ -0,0 +1,70 @@ +string('public_id', 191)->nullable()->unique()->after('uuid'); + + // Add quantity and unit price for proper line-item accounting + $table->integer('quantity')->default(1)->after('transaction_uuid'); + $table->integer('unit_price')->default(0)->after('quantity'); + + // Add tax columns + $table->decimal('tax_rate', 5, 2)->default(0.00)->after('currency'); + $table->integer('tax_amount')->default(0)->after('tax_rate'); + + // Add description as a longer alternative to details (TEXT vs VARCHAR) + $table->text('description')->nullable()->after('details'); + + // Add sort order for ordered display of line items + $table->unsignedSmallInteger('sort_order')->default(0)->after('description'); + }); + + // Fix amount column: string → integer + // First copy to a temp column, then drop and re-add as integer + DB::statement('ALTER TABLE transaction_items MODIFY COLUMN amount BIGINT NOT NULL DEFAULT 0'); + + // Backfill unit_price = amount for existing records (single-unit assumption) + DB::statement('UPDATE transaction_items SET unit_price = amount WHERE unit_price = 0 AND amount > 0'); + } + + public function down(): void + { + // Revert amount back to string (original type) + DB::statement('ALTER TABLE transaction_items MODIFY COLUMN amount VARCHAR(191) NULL'); + + Schema::table('transaction_items', function (Blueprint $table) { + $table->dropUnique(['public_id']); + $table->dropColumn([ + 'public_id', + 'quantity', + 'unit_price', + 'tax_rate', + 'tax_amount', + 'description', + 'sort_order', + ]); + }); + } +}; diff --git a/src/Models/Transaction.php b/src/Models/Transaction.php index b10e169..2a5a1aa 100644 --- a/src/Models/Transaction.php +++ b/src/Models/Transaction.php @@ -3,13 +3,38 @@ namespace Fleetbase\Models; use Fleetbase\Casts\Json; +use Fleetbase\Casts\Money; use Fleetbase\Casts\PolymorphicType; use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasApiModelCache; use Fleetbase\Traits\HasMetaAttributes; use Fleetbase\Traits\HasPublicId; use Fleetbase\Traits\HasUuid; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\MorphTo; +use Illuminate\Database\Eloquent\SoftDeletes; +/** + * Transaction + * + * The platform-wide financial transaction primitive. Every monetary movement + * on the Fleetbase platform — dispatch charges, wallet operations, gateway + * payments, refunds, earnings, adjustments — is recorded as a Transaction. + * + * Extensions (e.g. Ledger) extend this model to add domain-specific + * relationships (journal entries, invoices) without altering this schema. + * + * Monetary values are always stored as integers in the smallest currency unit + * (cents). For example, USD 10.50 is stored as 1050. + * + * Five polymorphic roles capture the full context of any transaction: + * - subject: the primary owner of the transaction record + * - payer: the entity funds flow FROM + * - payee: the entity funds flow TO + * - initiator: what triggered or authorised the transaction + * - context: the related business object (Order, Invoice, etc.) + */ class Transaction extends Model { use HasUuid; @@ -17,85 +42,470 @@ class Transaction extends Model use HasApiModelBehavior; use HasApiModelCache; use HasMetaAttributes; + use SoftDeletes; /** * The database table used by the model. - * - * @var string */ protected $table = 'transactions'; /** - * The type of public Id to generate. - * - * @var string + * The type of public ID to generate. */ protected $publicIdType = 'transaction'; /** - * The attributes that can be queried. - * - * @var array + * The attributes that can be queried via search. */ - protected $searchableColumns = []; + protected $searchableColumns = ['description', 'reference', 'gateway_transaction_id', 'type', 'status']; /** * The attributes that are mass assignable. - * - * @var array */ - protected $fillable = ['public_id', 'owner_uuid', 'owner_type', 'customer_uuid', 'customer_type', 'company_uuid', 'gateway_transaction_id', 'gateway', 'gateway_uuid', 'amount', 'currency', 'description', 'meta', 'type', 'status']; + protected $fillable = [ + 'public_id', + 'company_uuid', + + // Five polymorphic roles + 'subject_uuid', + 'subject_type', + 'payer_uuid', + 'payer_type', + 'payee_uuid', + 'payee_type', + 'initiator_uuid', + 'initiator_type', + 'context_uuid', + 'context_type', + + // Classification + 'type', + 'direction', + 'status', + + // Monetary (all in smallest currency unit / cents) + 'amount', + 'fee_amount', + 'tax_amount', + 'net_amount', + 'currency', + 'exchange_rate', + 'settled_currency', + 'settled_amount', + 'balance_after', + + // Gateway + 'gateway', + 'gateway_uuid', + 'gateway_transaction_id', + 'gateway_response', + 'payment_method', + 'payment_method_last4', + 'payment_method_brand', + + // Idempotency and linkage + 'reference', + 'parent_transaction_uuid', + + // Descriptive + 'description', + 'notes', + + // Failure info + 'failure_reason', + 'failure_code', + + // Reporting + 'period', + 'tags', + + // Traceability + 'ip_address', + + // Misc + 'meta', + + // Deprecated aliases (kept for backward compatibility) + 'owner_uuid', + 'owner_type', + 'customer_uuid', + 'customer_type', + ]; /** - * Dynamic attributes that are appended to object. - * - * @var array + * The attributes that should be cast to native types. + */ + protected $casts = [ + 'amount' => Money::class, + 'fee_amount' => Money::class, + 'tax_amount' => Money::class, + 'net_amount' => Money::class, + 'balance_after' => Money::class, + 'settled_amount' => Money::class, + 'exchange_rate' => 'decimal:8', + 'gateway_response' => Json::class, + 'tags' => Json::class, + 'meta' => Json::class, + 'subject_type' => PolymorphicType::class, + 'payer_type' => PolymorphicType::class, + 'payee_type' => PolymorphicType::class, + 'initiator_type' => PolymorphicType::class, + 'context_type' => PolymorphicType::class, + 'customer_type' => PolymorphicType::class, + 'settled_at' => 'datetime', + 'voided_at' => 'datetime', + 'reversed_at' => 'datetime', + 'expires_at' => 'datetime', + ]; + + /** + * Dynamic attributes appended to the model's JSON form. */ protected $appends = []; /** * The attributes excluded from the model's JSON form. - * - * @var array */ - protected $hidden = []; + protected $hidden = ['gateway_response']; + + // ========================================================================= + // Direction Constants + // ========================================================================= + + /** Money flowing into the subject's account. */ + public const DIRECTION_CREDIT = 'credit'; + + /** Money flowing out of the subject's account. */ + public const DIRECTION_DEBIT = 'debit'; + + // ========================================================================= + // Status Constants + // ========================================================================= + + public const STATUS_PENDING = 'pending'; + public const STATUS_SUCCESS = 'success'; + public const STATUS_FAILED = 'failed'; + public const STATUS_REVERSED = 'reversed'; + public const STATUS_CANCELLED = 'cancelled'; + public const STATUS_VOIDED = 'voided'; + public const STATUS_EXPIRED = 'expired'; + + // ========================================================================= + // Type Constants — Platform-wide taxonomy + // ========================================================================= + + // FleetOps + public const TYPE_DISPATCH = 'dispatch'; + public const TYPE_SERVICE_QUOTE = 'service_quote'; + + // Wallet operations + public const TYPE_WALLET_DEPOSIT = 'wallet_deposit'; + public const TYPE_WALLET_WITHDRAWAL = 'wallet_withdrawal'; + public const TYPE_WALLET_TRANSFER_IN = 'wallet_transfer_in'; + public const TYPE_WALLET_TRANSFER_OUT = 'wallet_transfer_out'; + public const TYPE_WALLET_EARNING = 'wallet_earning'; + public const TYPE_WALLET_PAYOUT = 'wallet_payout'; + public const TYPE_WALLET_FEE = 'wallet_fee'; + public const TYPE_WALLET_ADJUSTMENT = 'wallet_adjustment'; + public const TYPE_WALLET_REFUND = 'wallet_refund'; + + // Gateway payments + public const TYPE_GATEWAY_CHARGE = 'gateway_charge'; + public const TYPE_GATEWAY_REFUND = 'gateway_refund'; + public const TYPE_GATEWAY_SETUP_INTENT = 'gateway_setup_intent'; + + // Invoice + public const TYPE_INVOICE_PAYMENT = 'invoice_payment'; + public const TYPE_INVOICE_REFUND = 'invoice_refund'; + + // System + public const TYPE_ADJUSTMENT = 'adjustment'; + public const TYPE_REVERSAL = 'reversal'; + public const TYPE_CORRECTION = 'correction'; + + // ========================================================================= + // Relationships + // ========================================================================= + + /** + * The primary owner of this transaction record. + * Replaces the deprecated owner() relationship. + */ + public function subject(): MorphTo + { + return $this->morphTo(__FUNCTION__, 'subject_type', 'subject_uuid')->withoutGlobalScopes(); + } /** - * The attributes that should be cast to native types. - * - * @var array + * The entity funds flow FROM. */ - protected $casts = [ - 'meta' => Json::class, - 'customer_type' => PolymorphicType::class, - ]; + public function payer(): MorphTo + { + return $this->morphTo(__FUNCTION__, 'payer_type', 'payer_uuid')->withoutGlobalScopes(); + } + + /** + * The entity funds flow TO. + */ + public function payee(): MorphTo + { + return $this->morphTo(__FUNCTION__, 'payee_type', 'payee_uuid')->withoutGlobalScopes(); + } + + /** + * What triggered or authorised this transaction. + */ + public function initiator(): MorphTo + { + return $this->morphTo(__FUNCTION__, 'initiator_type', 'initiator_uuid')->withoutGlobalScopes(); + } + + /** + * The related business object (Order, Invoice, PurchaseRate, etc.). + */ + public function context(): MorphTo + { + return $this->morphTo(__FUNCTION__, 'context_type', 'context_uuid')->withoutGlobalScopes(); + } + + /** + * The parent transaction this record is a refund, reversal, or split of. + */ + public function parentTransaction(): BelongsTo + { + return $this->belongsTo(static::class, 'parent_transaction_uuid', 'uuid'); + } + + /** + * Child transactions (refunds, reversals, splits) of this transaction. + */ + public function childTransactions(): HasMany + { + return $this->hasMany(static::class, 'parent_transaction_uuid', 'uuid'); + } + + /** + * Line items belonging to this transaction. + */ + public function items(): HasMany + { + return $this->hasMany(TransactionItem::class, 'transaction_uuid', 'uuid'); + } /** - * Transaction items. - * - * @var array + * @deprecated Use subject() instead. */ - public function items() + public function owner(): MorphTo { - return $this->hasMany(TransactionItem::class); + return $this->morphTo(__FUNCTION__, 'owner_type', 'owner_uuid')->withoutGlobalScopes(); } /** - * The customer if any for this place. - * - * @var Model + * @deprecated Use payer() instead. The customer was semantically the payer. */ - public function customer() + public function customer(): MorphTo { return $this->morphTo(__FUNCTION__, 'customer_type', 'customer_uuid')->withoutGlobalScopes(); } + // ========================================================================= + // Scopes + // ========================================================================= + + /** + * Scope to credit transactions (money in). + */ + public function scopeCredits($query) + { + return $query->where('direction', self::DIRECTION_CREDIT); + } + + /** + * Scope to debit transactions (money out). + */ + public function scopeDebits($query) + { + return $query->where('direction', self::DIRECTION_DEBIT); + } + + /** + * Scope to successful transactions. + */ + public function scopeSuccessful($query) + { + return $query->where('status', self::STATUS_SUCCESS); + } + + /** + * Scope to pending transactions. + */ + public function scopePending($query) + { + return $query->where('status', self::STATUS_PENDING); + } + + /** + * Scope to failed transactions. + */ + public function scopeFailed($query) + { + return $query->where('status', self::STATUS_FAILED); + } + + /** + * Scope to a specific transaction type. + */ + public function scopeOfType($query, string $type) + { + return $query->where('type', $type); + } + + /** + * Scope to transactions for a specific accounting period (YYYY-MM). + */ + public function scopeForPeriod($query, string $period) + { + return $query->where('period', $period); + } + + /** + * Scope to transactions where the given model is the subject. + */ + public function scopeForSubject($query, \Illuminate\Database\Eloquent\Model $subject) + { + return $query->where('subject_uuid', $subject->uuid) + ->where('subject_type', get_class($subject)); + } + + /** + * Scope to transactions where the given model is the payer. + */ + public function scopeForPayer($query, \Illuminate\Database\Eloquent\Model $payer) + { + return $query->where('payer_uuid', $payer->uuid) + ->where('payer_type', get_class($payer)); + } + + /** + * Scope to transactions where the given model is the payee. + */ + public function scopeForPayee($query, \Illuminate\Database\Eloquent\Model $payee) + { + return $query->where('payee_uuid', $payee->uuid) + ->where('payee_type', get_class($payee)); + } + + /** + * Scope to transactions related to a specific business context object. + */ + public function scopeForContext($query, \Illuminate\Database\Eloquent\Model $context) + { + return $query->where('context_uuid', $context->uuid) + ->where('context_type', get_class($context)); + } + + /** + * Scope to refund/reversal transactions (children of another transaction). + */ + public function scopeRefunds($query) + { + return $query->whereNotNull('parent_transaction_uuid'); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + /** + * Whether this transaction is a credit (money in to subject). + */ + public function isCredit(): bool + { + return $this->direction === self::DIRECTION_CREDIT; + } + + /** + * Whether this transaction is a debit (money out from subject). + */ + public function isDebit(): bool + { + return $this->direction === self::DIRECTION_DEBIT; + } + + /** + * Whether this transaction completed successfully. + */ + public function isSuccessful(): bool + { + return $this->status === self::STATUS_SUCCESS; + } + + /** + * Whether this transaction is still pending. + */ + public function isPending(): bool + { + return $this->status === self::STATUS_PENDING; + } + + /** + * Whether this transaction failed. + */ + public function isFailed(): bool + { + return $this->status === self::STATUS_FAILED; + } + + /** + * Whether this transaction is a refund or reversal of another transaction. + */ + public function isRefund(): bool + { + return $this->parent_transaction_uuid !== null; + } + + /** + * Whether this transaction has been voided. + */ + public function isVoided(): bool + { + return $this->status === self::STATUS_VOIDED || $this->voided_at !== null; + } + /** - * Generates a fleetbase transaction number. - * - * @var array + * Whether this transaction has been reversed. */ - public static function generateInternalNumber($length = 10) + public function isReversed(): bool + { + return $this->status === self::STATUS_REVERSED || $this->reversed_at !== null; + } + + /** + * Whether this transaction has settled. + */ + public function isSettled(): bool + { + return $this->settled_at !== null; + } + + /** + * Whether this transaction has expired (e.g. an uncaptured pre-auth). + */ + public function isExpired(): bool + { + return $this->status === self::STATUS_EXPIRED || + ($this->expires_at !== null && $this->expires_at->isPast()); + } + + // ========================================================================= + // Static Helpers + // ========================================================================= + + /** + * Generate an internal transaction reference number. + * Format: TR + N random digits. + */ + public static function generateInternalNumber(int $length = 10): string { $number = 'TR'; for ($i = 0; $i < $length; $i++) { @@ -106,18 +516,17 @@ public static function generateInternalNumber($length = 10) } /** - * Generates a unique transaction number. - * - * @var array + * Generate a unique transaction reference number. + * Ensures uniqueness against the gateway_transaction_id column. */ - public static function generateNumber($length = 10) + public static function generateNumber(int $length = 10): string { $n = self::generateInternalNumber($length); - $tr = self::where('gateway_transaction_id', $n) - ->withTrashed() - ->first(); - while (is_object($tr) && $n == $tr->gateway_transaction_id) { - $n = self::generateInternalNumber($length); + $tr = self::where('gateway_transaction_id', $n)->withTrashed()->first(); + + while ($tr !== null && $n === $tr->gateway_transaction_id) { + $n = self::generateInternalNumber($length); + $tr = self::where('gateway_transaction_id', $n)->withTrashed()->first(); } return $n; diff --git a/src/Models/TransactionItem.php b/src/Models/TransactionItem.php index 0b8e957..4f6b339 100644 --- a/src/Models/TransactionItem.php +++ b/src/Models/TransactionItem.php @@ -3,48 +3,122 @@ namespace Fleetbase\Models; use Fleetbase\Casts\Json; +use Fleetbase\Casts\Money; +use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasMetaAttributes; +use Fleetbase\Traits\HasPublicId; use Fleetbase\Traits\HasUuid; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\SoftDeletes; +/** + * TransactionItem + * + * A line item belonging to a Transaction. Stores the individual components + * of a transaction's total — e.g. base fare, surcharges, taxes, discounts. + * + * All monetary values (amount, unit_price, tax_amount) are stored as integers + * in the smallest currency unit (cents) and cast via Fleetbase\Casts\Money. + */ class TransactionItem extends Model { use HasUuid; + use HasPublicId; + use HasApiModelBehavior; use HasMetaAttributes; + use SoftDeletes; /** * The database table used by the model. - * - * @var string */ protected $table = 'transaction_items'; + /** + * The type of public ID to generate. + */ + protected $publicIdType = 'transaction_item'; + /** * The attributes that are mass assignable. - * - * @var array */ - protected $fillable = ['transaction_uuid', 'amount', 'currency', 'details', 'code', 'meta']; + protected $fillable = [ + 'public_id', + 'transaction_uuid', + 'quantity', + 'unit_price', + 'amount', + 'currency', + 'tax_rate', + 'tax_amount', + 'details', + 'description', + 'code', + 'sort_order', + 'meta', + ]; /** * The attributes that should be cast to native types. - * - * @var array */ protected $casts = [ - 'meta' => Json::class, + 'amount' => Money::class, + 'unit_price' => Money::class, + 'tax_amount' => Money::class, + 'quantity' => 'integer', + 'tax_rate' => 'decimal:2', + 'sort_order' => 'integer', + 'meta' => Json::class, ]; /** - * Dynamic attributes that are appended to object. - * - * @var array + * Dynamic attributes appended to the model's JSON form. */ protected $appends = []; /** * The attributes excluded from the model's JSON form. - * - * @var array */ protected $hidden = []; + + // ========================================================================= + // Relationships + // ========================================================================= + + /** + * The transaction this item belongs to. + */ + public function transaction(): BelongsTo + { + return $this->belongsTo(Transaction::class, 'transaction_uuid', 'uuid'); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + /** + * Calculate the line total: quantity * unit_price (in cents). + * Returns the stored amount if unit_price is not set. + */ + public function getLineTotal(): int + { + if ($this->unit_price > 0 && $this->quantity > 0) { + return $this->unit_price * $this->quantity; + } + + return $this->amount; + } + + /** + * Calculate the tax amount for this line item based on tax_rate and line total. + * Returns the stored tax_amount if tax_rate is not set. + */ + public function calculateTax(): int + { + if ($this->tax_rate > 0) { + return (int) round($this->getLineTotal() * ($this->tax_rate / 100)); + } + + return $this->tax_amount; + } }