Add bill payment initiation adapter

This commit is contained in:
MOHAN 2026-07-17 23:32:52 +05:30
parent fa7b4b81be
commit 14dec26ccf
5 changed files with 155 additions and 3 deletions

View File

@ -0,0 +1,7 @@
-- Track provider-backed bill payment initiation state.
ALTER TABLE "BillPayment" ADD COLUMN "status" TEXT NOT NULL DEFAULT 'completed';
ALTER TABLE "BillPayment" ADD COLUMN "provider" TEXT;
ALTER TABLE "BillPayment" ADD COLUMN "providerPaymentId" TEXT;
CREATE INDEX "BillPayment_userId_status_idx" ON "BillPayment"("userId", "status");
CREATE INDEX "BillPayment_provider_providerPaymentId_idx" ON "BillPayment"("provider", "providerPaymentId");

View File

@ -166,6 +166,9 @@ model BillPayment {
amount Decimal
paidAt DateTime @default(now())
method String @default("manual")
status String @default("completed")
provider String?
providerPaymentId String?
confirmationNumber String?
notes String?
metadata Json @default("{}")
@ -175,6 +178,8 @@ model BillPayment {
bill Bill @relation(fields: [billId], references: [id], onDelete: Cascade)
@@index([userId, paidAt])
@@index([userId, status])
@@index([provider, providerPaymentId])
@@index([billId])
}

View File

@ -2,7 +2,7 @@ import { Body, Controller, Get, Param, Patch, Post, Query } from "@nestjs/common
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { ok } from "../common/response";
import { BillPayService } from "./bill-pay.service";
import { CreateBillDto, CreateBillPayeeDto, MarkBillPaidDto, UpdateBillDto } from "./dto";
import { CreateBillDto, CreateBillPayeeDto, InitiateBillPaymentDto, MarkBillPaidDto, UpdateBillDto } from "./dto";
@Controller("bill-pay")
export class BillPayController {
@ -42,4 +42,9 @@ export class BillPayController {
async markPaid(@CurrentUser() userId: string, @Param("id") id: string, @Body() body: MarkBillPaidDto) {
return ok(await this.billPayService.markPaid(userId, id, body));
}
@Post("bills/:id/initiate-payment")
async initiatePayment(@CurrentUser() userId: string, @Param("id") id: string, @Body() body: InitiateBillPaymentDto) {
return ok(await this.billPayService.initiatePayment(userId, id, body));
}
}

View File

@ -1,10 +1,12 @@
import { BadRequestException, Injectable } from "@nestjs/common";
import { randomUUID } from "crypto";
import { Prisma } from "@prisma/client";
import { NotificationsService } from "../notifications/notifications.service";
import { PrismaService } from "../prisma/prisma.service";
import { CreateBillDto, CreateBillPayeeDto, MarkBillPaidDto, UpdateBillDto } from "./dto";
import { CreateBillDto, CreateBillPayeeDto, InitiateBillPaymentDto, MarkBillPaidDto, UpdateBillDto } from "./dto";
const ACTIVE_STATUSES = ["pending", "scheduled"];
const SUPPORTED_PAYMENT_PROVIDERS = ["sandbox"];
@Injectable()
export class BillPayService {
@ -126,7 +128,7 @@ export class BillPayService {
const paidAt = dto.paidAt ? this.parseDate(dto.paidAt, "paid date") : new Date();
const amount = new Prisma.Decimal(dto.amount ?? Number(bill.amount));
const payment = await this.prisma.billPayment.create({
const payment = await (this.prisma as any).billPayment.create({
data: {
userId,
billId: bill.id,
@ -168,6 +170,85 @@ export class BillPayService {
return { bill: this.withComputedStatus(updated), payment };
}
async initiatePayment(userId: string, billId: string, dto: InitiateBillPaymentDto) {
const bill = await this.assertBill(userId, billId);
if (!ACTIVE_STATUSES.includes(bill.status)) {
throw new BadRequestException("Only pending or scheduled bills can be initiated.");
}
const provider = (process.env.BILL_PAY_PROVIDER ?? "sandbox").toLowerCase();
if (!SUPPORTED_PAYMENT_PROVIDERS.includes(provider)) {
throw new BadRequestException("Configured bill-payment provider is not supported by this build.");
}
const amount = new Prisma.Decimal(dto.amount ?? Number(bill.amount));
const scheduledFor = dto.scheduledFor ? this.parseDate(dto.scheduledFor, "scheduled date") : new Date();
const method = dto.method ?? "ach";
const providerResult = await this.initiateWithProvider(provider, {
userId,
billId: bill.id,
amount: amount.toNumber(),
currency: bill.currency,
scheduledFor,
method,
fundingAccountRef: this.optionalString(dto.fundingAccountRef),
memo: this.optionalString(dto.memo),
});
const payment = await (this.prisma as any).billPayment.create({
data: {
userId,
billId: bill.id,
amount,
paidAt: scheduledFor,
method,
status: providerResult.status,
provider,
providerPaymentId: providerResult.providerPaymentId,
confirmationNumber: providerResult.confirmationNumber,
notes: this.optionalString(dto.memo),
metadata: providerResult.metadata as Prisma.InputJsonValue,
},
});
const updated = await this.prisma.bill.update({
where: { id: bill.id },
data: {
status: providerResult.status === "completed" ? "paid" : "scheduled",
paidAt: providerResult.status === "completed" ? scheduledFor : bill.paidAt,
},
include: { payee: true, payments: { orderBy: { paidAt: "desc" } } },
});
await this.prisma.auditLog.create({
data: {
userId,
action: "bill_pay.payment_initiate",
metadata: {
billId: bill.id,
paymentId: payment.id,
provider,
providerPaymentId: providerResult.providerPaymentId,
status: providerResult.status,
},
},
});
await this.notifications.notifyUser(userId, {
type: "bill.payment_initiated",
severity: "info",
title: `Payment initiated: ${bill.name}`,
body: `${this.formatMoney(amount, bill.currency)} is ${providerResult.status} through ${provider}.`,
metadata: { billId: bill.id, paymentId: payment.id, provider },
});
return {
bill: this.withComputedStatus(updated),
payment,
provider: providerResult,
};
}
async summary(userId: string) {
const bills = await this.listBills(userId, "all");
const now = new Date();
@ -232,4 +313,35 @@ export class BillPayService {
private formatMoney(amount: Prisma.Decimal, currency: string) {
return `${currency} ${amount.toFixed(2)}`;
}
private async initiateWithProvider(provider: string, payload: {
userId: string;
billId: string;
amount: number;
currency: string;
scheduledFor: Date;
method: string;
fundingAccountRef?: string | null;
memo?: string | null;
}) {
if (provider !== "sandbox") {
throw new BadRequestException("Bill-payment provider is not available.");
}
const providerPaymentId = `sandbox_${randomUUID().replace(/-/g, "")}`;
return {
providerPaymentId,
confirmationNumber: providerPaymentId.slice(-12).toUpperCase(),
status: payload.scheduledFor.getTime() <= Date.now() ? "completed" : "initiated",
metadata: {
rail: payload.method,
amount: payload.amount,
currency: payload.currency,
scheduledFor: payload.scheduledFor.toISOString(),
fundingAccountRef: payload.fundingAccountRef,
memo: payload.memo,
sandbox: true,
},
};
}
}

View File

@ -130,3 +130,26 @@ export class MarkBillPaidDto {
@IsString()
notes?: string;
}
export class InitiateBillPaymentDto {
@IsOptional()
@IsNumber()
@Min(0.01)
amount?: number;
@IsOptional()
@IsDateString()
scheduledFor?: string;
@IsOptional()
@IsIn(["ach", "bank_bill_pay", "card"])
method?: string;
@IsOptional()
@IsString()
fundingAccountRef?: string;
@IsOptional()
@IsString()
memo?: string;
}