table = $db->prefix . 'us_payments'; } public function insert( Payment $payment ): int { $this->db->insert( $this->table, [ 'student_id' => $payment->studentId, 'instructor_id' => $payment->instructorId, 'registration_type' => $payment->registrationType, 'registration_id' => $payment->registrationId, 'amount' => $payment->amount, 'currency' => $payment->currency, 'method' => $payment->method, 'status' => $payment->status, 'tax_rate' => $payment->taxRate, 'tax_amount' => $payment->taxAmount, 'credit_applied' => $payment->creditApplied, 'due_date' => $payment->dueDate, 'period_key' => $payment->periodKey, 'notice_batch' => $payment->noticeBatch, 'etransfer_email' => $payment->etransferEmail, 'stripe_payment_intent_id' => $payment->stripePaymentIntentId, 'receipt_number' => $payment->receiptNumber, 'receipt_sent_at' => $payment->receiptSentAt, 'paid_at' => $payment->paidAt, 'created_at' => current_time( 'mysql' ), ], [ '%d', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%f', '%f', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ] ); return $this->db->insert_id; } /** * Attach the Stripe PaymentIntent id created for a card payment so the webhook * can later reconcile the charge back to this row. */ public function setStripeIntentId( int $id, string $intentId ): bool { return false !== $this->db->update( $this->table, [ 'stripe_payment_intent_id' => $intentId ], [ 'id' => $id ], [ '%s' ], [ '%d' ] ); } public function findByStripeIntentId( string $intentId ): ?Payment { $row = $this->db->get_row( $this->db->prepare( 'SELECT * FROM %i WHERE stripe_payment_intent_id = %s ORDER BY id DESC LIMIT 1', $this->table, $intentId ) ); return $row ? Payment::fromRow( $row ) : null; } public function updateEtransferEmail( int $id, ?string $email ): bool { return false !== $this->db->update( $this->table, [ 'etransfer_email' => $email ], [ 'id' => $id ], [ '%s' ], [ '%d' ] ); } /** * Add to the account credit applied against a payment, reducing what the student * still owes on it (`Payment::netDue()`). Accumulates, so a second application * adds to the first. */ public function addCreditApplied( int $id, float $amount ): bool { $sql = $this->db->prepare( 'UPDATE %i SET credit_applied = credit_applied + %f WHERE id = %d', $this->table, $amount, $id ); return null !== $sql && false !== $this->db->query( $sql ); } /** * Set a payment's tax rate and recompute the tax amount from its subtotal. */ public function updateTax( int $id, float $rate ): bool { $sql = $this->db->prepare( 'UPDATE %i SET tax_rate = %f, tax_amount = ROUND( amount * %f / 100, 2 ) WHERE id = %d', $this->table, $rate, $rate, $id ); return null !== $sql && false !== $this->db->query( $sql ); } /** * Paid payments in a month (`Y-m` bounds), optionally for one instructor — * the reporting source. * * @return list */ public function findPaidBetween( string $from, string $to, int $instructorId = 0 ): array { $sql = 'SELECT * FROM %i WHERE status = %s AND paid_at >= %s AND paid_at < %s'; $params = [ $this->table, Payment::STATUS_PAID, $from, $to ]; if ( $instructorId > 0 ) { $sql .= ' AND instructor_id = %d'; $params[] = $instructorId; } $sql .= ' ORDER BY paid_at ASC'; $rows = $this->db->get_results( $this->db->prepare( $sql, $params ) ); return array_map( Payment::fromRow( ... ), $rows ?? [] ); } public function findById( int $id ): ?Payment { $row = $this->db->get_row( $this->db->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table, $id ) ); return $row ? Payment::fromRow( $row ) : null; } /** * Tag a set of payments with a shared notice-batch reference — the payments the * daily scan emailed a student together, so the admin can see which pending * payments a single lump-sum e-transfer covers. No-op for an empty id list. * * @param list $ids */ public function assignNoticeBatch( array $ids, string $batch ): void { if ( [] === $ids ) { return; } $placeholders = implode( ', ', array_fill( 0, count( $ids ), '%d' ) ); $sql = $this->db->prepare( "UPDATE %i SET notice_batch = %s WHERE id IN ( {$placeholders} )", $this->table, $batch, ...$ids ); if ( null !== $sql ) { $this->db->query( $sql ); } } /** * Whether a scheduled payment already exists for a registration and billing * period. The daily billing scan uses this to avoid double-billing an * enrolment for the same session (weekly) or month (monthly). A voided * (`failed`) row still counts so a cancelled charge is not silently re-created. */ public function existsForPeriod( string $registrationType, int $registrationId, string $periodKey ): bool { $found = $this->db->get_var( $this->db->prepare( 'SELECT id FROM %i WHERE registration_type = %s AND registration_id = %d AND period_key = %s LIMIT 1', $this->table, $registrationType, $registrationId, $periodKey ) ); return null !== $found; } public function findByRegistration( string $registrationType, int $registrationId ): ?Payment { $row = $this->db->get_row( $this->db->prepare( 'SELECT * FROM %i WHERE registration_type = %s AND registration_id = %d ORDER BY id DESC LIMIT 1', $this->table, $registrationType, $registrationId ) ); return $row ? Payment::fromRow( $row ) : null; } /** * Every payment for a student, newest first (admin payment history). * * @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( Payment::fromRow( ... ), $rows ?? [] ); } /** * Pending payments, newest first (studio-admin confirmation queue). * * @return list */ public function findPending(): array { $rows = $this->db->get_results( $this->db->prepare( 'SELECT * FROM %i WHERE status = %s ORDER BY created_at DESC', $this->table, Payment::STATUS_PENDING ) ); return array_map( Payment::fromRow( ... ), $rows ?? [] ); } /** * Mark a payment paid, stamping the paid time and receipt number. */ public function markPaid( int $id, string $receiptNumber ): bool { return false !== $this->db->update( $this->table, [ 'status' => Payment::STATUS_PAID, 'paid_at' => current_time( 'mysql' ), 'receipt_number' => $receiptNumber, ], [ 'id' => $id ], [ '%s', '%s', '%s' ], [ '%d' ] ); } public function markReceiptSent( int $id ): bool { return false !== $this->db->update( $this->table, [ 'receipt_sent_at' => current_time( 'mysql' ) ], [ 'id' => $id ], [ '%s' ], [ '%d' ] ); } public function updateStatus( int $id, string $status ): bool { if ( ! in_array( $status, Payment::VALID_STATUSES, true ) ) { return false; } return (bool) $this->db->update( $this->table, [ 'status' => $status ], [ 'id' => $id ], [ '%s' ], [ '%d' ] ); } }