Add bill payment initiation adapter
This commit is contained in:
parent
fa7b4b81be
commit
14dec26ccf
@ -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");
|
||||
@ -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])
|
||||
}
|
||||
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user