uploads[] = [ 'application_id' => $application_id, 'invoice_id' => $invoiceData['invoice']['invoice_id'], 'type' => $invoiceData['type'], ]; } } /** * Covers creationBouncePenal() after it was rewritten on top of getBouncePenalPaidByDate(), * and the relaxed getInvoice() dedupe that goes with the new per-date invoice grain. * * Two things make these tests touchy, both handled below: * * 1. ChargedInvoices lives on the mysql_primary connection, so that connection has to be * transacted explicitly or invoices survive the rollback. * 2. creationBouncePenal() derives its application set from the date range alone, so it will * happily invoice whatever real data sits in the window. The tests use a window with no * transactions in it and assert that baseline in setUp. */ class BounceInvoiceCreationTest extends TestCase { use DatabaseTransactions; /** * ChargedInvoices writes go to mysql_primary. Without this the rollback misses them. */ protected $connectionsToTransact = ['mysql', 'mysql_primary']; /** A window with no payment transactions in it, so only seeded data is in play. */ const RANGE_FROM = '2027-04-01'; const RANGE_TO = '2027-04-30'; /** @var RecordingBouncePenalInvoiceController */ protected $controller; protected function setUp() { parent::setUp(); $this->controller = new RecordingBouncePenalInvoiceController(); $ambient = DB::selectOne( 'SELECT COUNT(*) c FROM payment_transactions WHERE payment_date >= ? AND payment_date < ?', [self::RANGE_FROM . ' 00:00:00', '2027-05-01 00:00:00'] ); $this->assertEquals(0, $ambient->c, 'test window must be free of pre-existing transactions'); } private function seedApplication($lenderId = 3) { return DB::table('consumer_application')->insertGetId([ 'name' => 'Bounce Creation Test', 'state' => 'disbursed', 'is_deleted' => null, 'lender_id' => $lenderId, 'loan_amount' => 100000, 'approved_amount' => 100000, 'permanent_address_state' => 'Delhi', 'created_at' => '2025-01-01 00:00:00', 'updated_at' => '2025-01-01 00:00:00', ]); } private function seedBounce($applicationId, $emiAmount, $bounceCharges, $delayPenalty, $paid, $paymentDate) { $emiId = DB::table('emi_payments')->insertGetId([ 'application_id' => $applicationId, 'emi_date' => date('Y-m-d', strtotime($paymentDate)), 'emi_amount' => $emiAmount, 'bounce_charges' => $bounceCharges, 'delay_penalty' => $delayPenalty, 'charges_waived' => 0, 'payment_status' => 'pending', 'created_at' => '2027-03-01 00:00:00', 'updated_at' => '2027-03-01 00:00:00', ]); DB::table('payment_transactions')->insert([ 'application_id' => $applicationId, 'emi_payment_id' => $emiId, 'amount' => $paid, 'payment_date' => $paymentDate, 'payment_for' => 'EMI', 'mode' => 'neft', 'status' => 'success', 'cf_transaction_id' => 'TESTTXN' . uniqid(), 'created_at' => $paymentDate, 'updated_at' => $paymentDate, ]); return $emiId; } private function seedExistingInvoice($applicationId, $invoiceId, $invoiceDate, $type = 'bounce', $version = '13677NE1761730908') { $invoice = new ChargedInvoices(); $invoice->fill([ 'application_id' => $applicationId, 'lender_id' => 3, 'invoice_id' => $invoiceId, 'amount' => 254.24, 'igst' => 0, 'cgst' => 22.88, 'sgst' => 22.88, 'invoice_date' => $invoiceDate, 'type' => $type, 'version' => $version, ])->save(); return $invoice; } private function payload($from = self::RANGE_FROM, $to = self::RANGE_TO) { $payload = new stdClass(); $payload->from_payment_date = $from; $payload->to_payment_date = $to; // Deliberately bogus: the creation path must no longer read this. $payload->application_id = '999999999'; return $payload; } private function bounceInvoicesFor($applicationId) { return ChargedInvoices::where('application_id', $applicationId) ->where('type', 'bounce') ->orderBy('invoice_date') ->get(); } // ---------------------------------------------------------------- mapping public function testGroupsRowsByApplicationAndNormalisesFields() { $rows = []; foreach ([[11, '2027-04-10', 300, 150], [11, '2027-04-20', 500, 0], [22, '2027-04-15', 700, 25]] as $r) { $row = new stdClass(); $row->application_id = $r[0]; $row->payment_date = $r[1]; $row->bounce_paid = $r[2]; $row->penal_paid = $r[3]; $rows[] = $row; } $grouped = $this->controller->groupPaidRowsByApplication($rows); $this->assertSame([11, 22], array_keys($grouped)); $this->assertCount(2, $grouped[11]); $this->assertCount(1, $grouped[22]); $this->assertSame([ 'bounce_charges' => 300, 'penal_interest' => 150, // charged_invoices.invoice_date is a varchar and every existing row uses this format. 'payment_date' => '2027-04-10 00:00:00', // No longer per transaction, so there is no cf_transaction_id to carry. 'timestamp' => '', ], $grouped[11][0]); } public function testGroupingEmptyResultIsEmpty() { $this->assertSame([], $this->controller->groupPaidRowsByApplication([])); } // ---------------------------------------------------------------- dedupe public function testCreatesInvoiceWhenNoneExistsForThatDate() { $appId = $this->seedApplication(); $application = ConsumerApplication::find($appId); $item = ['payment_date' => '2027-04-10 00:00:00', 'timestamp' => '']; $invoice = $this->controller->getInvoice(300, $application, $item, 'bounce', false, '', ''); $this->assertNotFalse($invoice); $this->assertSame('2027-04-10 00:00:00', $invoice['invoice_date']); $this->assertSame('', $invoice['version']); } /** * The point of dropping version from the lookup. Historical invoices carry a * cf_transaction_id in version; the aggregated path supplies an empty one, so keying on * version would miss the existing row and raise a second invoice for the same day. */ public function testSkipsWhenAnInvoiceWithALegacyVersionExistsForThatDate() { $appId = $this->seedApplication(); $this->seedExistingInvoice($appId, 'AV-B0009999-2526', '2027-04-10 00:00:00'); $application = ConsumerApplication::find($appId); $item = ['payment_date' => '2027-04-10 00:00:00', 'timestamp' => '']; $this->assertFalse($this->controller->getInvoice(300, $application, $item, 'bounce', false, '', '')); } /** * Existing rows store 'Y-m-d H:i:s'. A few historical transactions carried a real time of * day, so the lookup matches on the date prefix rather than the whole string. */ public function testSkipsWhenExistingInvoiceDateCarriesATimeComponent() { $appId = $this->seedApplication(); $this->seedExistingInvoice($appId, 'AV-B0009998-2526', '2027-04-10 09:29:16'); $application = ConsumerApplication::find($appId); $item = ['payment_date' => '2027-04-10 00:00:00', 'timestamp' => '']; $this->assertFalse($this->controller->getInvoice(300, $application, $item, 'bounce', false, '', '')); } public function testSoftDeletedInvoiceDoesNotBlockRegeneration() { $appId = $this->seedApplication(); $this->seedExistingInvoice($appId, 'AV-B0009997-2526', '2027-04-10 00:00:00')->delete(); $application = ConsumerApplication::find($appId); $item = ['payment_date' => '2027-04-10 00:00:00', 'timestamp' => '']; $this->assertNotFalse($this->controller->getInvoice(300, $application, $item, 'bounce', false, '', '')); } public function testInvoiceOfAnotherTypeOnTheSameDateDoesNotBlockBounce() { $appId = $this->seedApplication(); $this->seedExistingInvoice($appId, 'AV-I0009999-2526', '2027-04-10 00:00:00', 'penal'); $application = ConsumerApplication::find($appId); $item = ['payment_date' => '2027-04-10 00:00:00', 'timestamp' => '']; $this->assertNotFalse($this->controller->getInvoice(300, $application, $item, 'bounce', false, '', '')); } // ---------------------------------------------------------------- creation public function testCreatesOneInvoicePerApplicationPerPaymentDate() { $appId = $this->seedApplication(); $this->seedBounce($appId, 10000, 300, 150, 10450, '2027-04-10 00:00:00'); $this->seedBounce($appId, 10000, 500, 0, 10500, '2027-04-20 00:00:00'); $this->controller->creationBouncePenal($this->payload()); $invoices = $this->bounceInvoicesFor($appId); $this->assertCount(2, $invoices); $this->assertSame('2027-04-10 00:00:00', $invoices[0]->invoice_date); $this->assertSame('2027-04-20 00:00:00', $invoices[1]->invoice_date); $this->assertSame('', $invoices[0]->version); $this->assertCount(2, $this->controller->uploads); } public function testSameDayPaymentsBecomeASingleInvoice() { $appId = $this->seedApplication(); // Two separate EMIs both partly recovered on the same day. The old per-transaction // grain would have raised two invoices here. $this->seedBounce($appId, 10000, 300, 0, 10300, '2027-04-10 09:00:00'); $this->seedBounce($appId, 10000, 500, 0, 10500, '2027-04-10 18:00:00'); $this->controller->creationBouncePenal($this->payload()); $invoices = $this->bounceInvoicesFor($appId); $this->assertCount(1, $invoices); $this->assertSame('2027-04-10 00:00:00', $invoices[0]->invoice_date); // Both EMIs' bounce recovery (300 + 500) lands on a single invoice. getGSTDetails // treats the figure as GST-inclusive, so amount is the gross collected. $this->assertEquals(800, $invoices[0]->amount); } public function testRerunningTheSameRangeCreatesNothingNew() { $appId = $this->seedApplication(); $this->seedBounce($appId, 10000, 300, 150, 10450, '2027-04-10 00:00:00'); $this->controller->creationBouncePenal($this->payload()); $afterFirst = $this->bounceInvoicesFor($appId); $this->assertCount(1, $afterFirst); $rerun = new RecordingBouncePenalInvoiceController(); $rerun->creationBouncePenal($this->payload()); $afterSecond = $this->bounceInvoicesFor($appId); $this->assertCount(1, $afterSecond); $this->assertSame($afterFirst[0]->invoice_id, $afterSecond[0]->invoice_id); $this->assertSame([], $rerun->uploads); } public function testMiddleMonthsOfAMultiMonthRangeAreInvoiced() { $appId = $this->seedApplication(); $this->seedBounce($appId, 10000, 300, 0, 10300, '2027-04-10 00:00:00'); $this->seedBounce($appId, 10000, 400, 0, 10400, '2027-05-10 00:00:00'); $this->seedBounce($appId, 10000, 500, 0, 10500, '2027-06-10 00:00:00'); $this->controller->creationBouncePenal($this->payload('2027-04-01', '2027-06-30')); $invoices = $this->bounceInvoicesFor($appId); // May is the regression the old from_month/to_month comparison dropped. $this->assertCount(3, $invoices); $this->assertSame( ['2027-04-10 00:00:00', '2027-05-10 00:00:00', '2027-06-10 00:00:00'], $invoices->pluck('invoice_date')->all() ); } public function testToDateIsInclusiveForItsWholeDay() { $appId = $this->seedApplication(); $this->seedBounce($appId, 10000, 300, 0, 10300, '2027-04-30 23:45:00'); $this->controller->creationBouncePenal($this->payload()); $this->assertCount(1, $this->bounceInvoicesFor($appId)); } public function testPaymentsOutsideTheRangeAreNotInvoiced() { $appId = $this->seedApplication(); $this->seedBounce($appId, 10000, 300, 0, 10300, '2027-05-05 00:00:00'); $this->controller->creationBouncePenal($this->payload()); $this->assertCount(0, $this->bounceInvoicesFor($appId)); } public function testPayloadApplicationListIsIgnored() { $appId = $this->seedApplication(); $this->seedBounce($appId, 10000, 300, 150, 10450, '2027-04-10 00:00:00'); // payload()'s application_id is 999999999, which is not this application. $this->controller->creationBouncePenal($this->payload()); $this->assertCount(1, $this->bounceInvoicesFor($appId)); } public function testNoPenalInvoiceIsRaised() { $appId = $this->seedApplication(); $this->seedBounce($appId, 10000, 300, 150, 10450, '2027-04-10 00:00:00'); $this->controller->creationBouncePenal($this->payload()); $this->assertCount(0, ChargedInvoices::where('application_id', $appId)->where('type', 'penal')->get()); } public function testEveryApplicationInTheWindowIsInvoicedNotJustOne() { $first = $this->seedApplication(); $second = $this->seedApplication(); $this->seedBounce($first, 10000, 300, 0, 10300, '2027-04-10 00:00:00'); $this->seedBounce($second, 10000, 700, 0, 10700, '2027-04-12 00:00:00'); $this->controller->creationBouncePenal($this->payload()); $this->assertCount(1, $this->bounceInvoicesFor($first)); $this->assertCount(1, $this->bounceInvoicesFor($second)); } }