diff --git a/prisma/migrations/20260717174500_bill_payment_initiation/migration.sql b/prisma/migrations/20260717174500_bill_payment_initiation/migration.sql new file mode 100644 index 0000000..c698932 --- /dev/null +++ b/prisma/migrations/20260717174500_bill_payment_initiation/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3234165..5c98a84 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) } diff --git a/src/bill-pay/bill-pay.controller.ts b/src/bill-pay/bill-pay.controller.ts index 0e89e2a..c4eb281 100644 --- a/src/bill-pay/bill-pay.controller.ts +++ b/src/bill-pay/bill-pay.controller.ts @@ -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)); + } } diff --git a/src/bill-pay/bill-pay.service.ts b/src/bill-pay/bill-pay.service.ts index f794116..968b2f7 100644 --- a/src/bill-pay/bill-pay.service.ts +++ b/src/bill-pay/bill-pay.service.ts @@ -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, + }, + }; + } } diff --git a/src/bill-pay/dto.ts b/src/bill-pay/dto.ts index 9f048ae..1e98fd4 100644 --- a/src/bill-pay/dto.ts +++ b/src/bill-pay/dto.ts @@ -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; +}