table = $db->prefix . 'us_credits'; } public function insert( Credit $credit ): int { $this->db->insert( $this->table, [ 'student_id' => $credit->studentId, 'payer_id' => $credit->payerOrStudent(), 'amount' => $credit->amount, 'remaining' => $credit->remaining, 'currency' => $credit->currency, 'source_payment_id' => $credit->sourcePaymentId, 'source_lesson_id' => $credit->sourceLessonId, 'reason' => $credit->reason, 'status' => $credit->status, 'created_at' => current_time( 'mysql' ), ], [ '%d', '%d', '%f', '%f', '%s', '%d', '%d', '%s', '%s', '%s' ] ); return $this->db->insert_id; } public function findById( int $id ): ?Credit { $row = $this->db->get_row( $this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id ) ); return $row ? Credit::fromRow( $row ) : null; } /** * Whether a credit has already been issued for a cancelled lesson, so cancelling * (or re-cancelling) the same lesson never grants a second credit. */ public function existsForLesson( int $lessonId ): bool { $found = $this->db->get_var( $this->db->prepare( 'SELECT id FROM %i WHERE source_lesson_id = %d LIMIT 1', $this->table, $lessonId ) ); return null !== $found; } /** * A payer's total unused credit balance (sum of the remaining amounts of every * still-available credit). Keyed on the payer, so a guardian's balance covers * credits earned by any of their children — one family, one balance. */ public function availableBalance( int $payerId ): float { $total = $this->db->get_var( $this->db->prepare( 'SELECT COALESCE( SUM( remaining ), 0 ) FROM %i WHERE payer_id = %d AND status = %s', $this->table, $payerId, Credit::STATUS_AVAILABLE ) ); return round( (float) $total, 2 ); } /** * A payer's still-available credits, oldest first — the FIFO order they are * consumed in. * * @return list */ public function findAvailableByPayer( int $payerId ): array { $rows = $this->db->get_results( $this->db->prepare( 'SELECT * FROM %i WHERE payer_id = %d AND status = %s AND remaining > 0 ORDER BY created_at ASC, id ASC', $this->table, $payerId, Credit::STATUS_AVAILABLE ) ); return array_map( Credit::fromRow( ... ), $rows ?? [] ); } /** * Every credit earned by a student, newest first — the admin history on their * own screen. Unlike the balance this is keyed on the student, so a child's * screen shows the credits their cancellations produced even though the * balance itself sits with their guardian. * * @return list */ public function findByStudent( int $studentId ): array { $rows = $this->db->get_results( $this->db->prepare( 'SELECT * FROM %i WHERE student_id = %d ORDER BY created_at DESC, id DESC', $this->table, $studentId ) ); return array_map( Credit::fromRow( ... ), $rows ?? [] ); } /** * Backfill `payer_id` on credits written before guardian accounts existed, * where the student was always the payer. Run once from the installer so the * payer-keyed balance queries see those rows. */ public function backfillPayerIds(): void { $sql = $this->db->prepare( 'UPDATE %i SET payer_id = student_id WHERE payer_id = 0', $this->table ); if ( null !== $sql ) { $this->db->query( $sql ); } } /** * Draw down a payer's credit balance by $amount, consuming their available * credits oldest first and marking each fully-spent credit `consumed`. Stops once * the amount is exhausted; a balance shorter than $amount simply drains to zero. */ public function consume( int $payerId, float $amount ): void { $remaining = round( $amount, 2 ); if ( $remaining <= 0.0 ) { return; } foreach ( $this->findAvailableByPayer( $payerId ) as $credit ) { if ( $remaining <= 0.0 ) { break; } if ( null === $credit->id ) { continue; } $take = min( $credit->remaining, $remaining ); $newRemaining = round( $credit->remaining - $take, 2 ); $status = $newRemaining <= 0.0 ? Credit::STATUS_CONSUMED : Credit::STATUS_AVAILABLE; $this->db->update( $this->table, [ 'remaining' => $newRemaining, 'status' => $status, 'updated_at' => current_time( 'mysql' ), ], [ 'id' => $credit->id ], [ '%f', '%s', '%s' ], [ '%d' ] ); $remaining = round( $remaining - $take, 2 ); } } }