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
|
amount Decimal
|
||||||
paidAt DateTime @default(now())
|
paidAt DateTime @default(now())
|
||||||
method String @default("manual")
|
method String @default("manual")
|
||||||
|
status String @default("completed")
|
||||||
|
provider String?
|
||||||
|
providerPaymentId String?
|
||||||
confirmationNumber String?
|
confirmationNumber String?
|
||||||
notes String?
|
notes String?
|
||||||
metadata Json @default("{}")
|
metadata Json @default("{}")
|
||||||
@ -175,6 +178,8 @@ model BillPayment {
|
|||||||
bill Bill @relation(fields: [billId], references: [id], onDelete: Cascade)
|
bill Bill @relation(fields: [billId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([userId, paidAt])
|
@@index([userId, paidAt])
|
||||||
|
@@index([userId, status])
|
||||||
|
@@index([provider, providerPaymentId])
|
||||||
@@index([billId])
|
@@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 { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||||
import { ok } from "../common/response";
|
import { ok } from "../common/response";
|
||||||
import { BillPayService } from "./bill-pay.service";
|
import { BillPayService } from "./bill-pay.service";
|
||||||
import { CreateBillDto, CreateBillPayeeDto, MarkBillPaidDto, UpdateBillDto } from "./dto";
|
import { CreateBillDto, CreateBillPayeeDto, InitiateBillPaymentDto, MarkBillPaidDto, UpdateBillDto } from "./dto";
|
||||||
|
|
||||||
@Controller("bill-pay")
|
@Controller("bill-pay")
|
||||||
export class BillPayController {
|
export class BillPayController {
|
||||||
@ -42,4 +42,9 @@ export class BillPayController {
|
|||||||
async markPaid(@CurrentUser() userId: string, @Param("id") id: string, @Body() body: MarkBillPaidDto) {
|
async markPaid(@CurrentUser() userId: string, @Param("id") id: string, @Body() body: MarkBillPaidDto) {
|
||||||
return ok(await this.billPayService.markPaid(userId, id, body));
|
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 { BadRequestException, Injectable } from "@nestjs/common";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { NotificationsService } from "../notifications/notifications.service";
|
import { NotificationsService } from "../notifications/notifications.service";
|
||||||
import { PrismaService } from "../prisma/prisma.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 ACTIVE_STATUSES = ["pending", "scheduled"];
|
||||||
|
const SUPPORTED_PAYMENT_PROVIDERS = ["sandbox"];
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BillPayService {
|
export class BillPayService {
|
||||||
@ -126,7 +128,7 @@ export class BillPayService {
|
|||||||
const paidAt = dto.paidAt ? this.parseDate(dto.paidAt, "paid date") : new Date();
|
const paidAt = dto.paidAt ? this.parseDate(dto.paidAt, "paid date") : new Date();
|
||||||
const amount = new Prisma.Decimal(dto.amount ?? Number(bill.amount));
|
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: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
billId: bill.id,
|
billId: bill.id,
|
||||||
@ -168,6 +170,85 @@ export class BillPayService {
|
|||||||
return { bill: this.withComputedStatus(updated), payment };
|
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) {
|
async summary(userId: string) {
|
||||||
const bills = await this.listBills(userId, "all");
|
const bills = await this.listBills(userId, "all");
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@ -232,4 +313,35 @@ export class BillPayService {
|
|||||||
private formatMoney(amount: Prisma.Decimal, currency: string) {
|
private formatMoney(amount: Prisma.Decimal, currency: string) {
|
||||||
return `${currency} ${amount.toFixed(2)}`;
|
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()
|
@IsString()
|
||||||
notes?: string;
|
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