Skip to content

Commit 7c0363d

Browse files
authored
Merge pull request #128 from 0xdevcollins/feat/invoice-checkout-consumer-page
feat: consumer-facing invoice checkout page (#71)
2 parents 72983aa + e5231c2 commit 7c0363d

File tree

7 files changed

+759
-2
lines changed

7 files changed

+759
-2
lines changed

apps/api/src/modules/invoices/invoices.controller.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,23 @@ export class InvoicesController {
124124
return this.invoicesService.recordPayment(id, merchantId, dto);
125125
}
126126

127+
// ── GET /v1/invoices/:id/checkout ─────────────────────────────────────────
128+
// Public — returns display data for the consumer-facing invoice page.
129+
@Get(':id/checkout')
130+
@PublicRoute()
131+
async getCheckoutData(@Param('id') id: string) {
132+
return this.invoicesService.getPublicCheckoutData(id);
133+
}
134+
135+
// ── POST /v1/invoices/:id/pay ──────────────────────────────────────────────
136+
// Public — creates a payment session for the invoice, returns paymentId.
137+
@Post(':id/pay')
138+
@PublicRoute()
139+
@HttpCode(HttpStatus.CREATED)
140+
async initiatePayment(@Param('id') id: string) {
141+
return this.invoicesService.initiatePayment(id);
142+
}
143+
127144
// ── GET /v1/invoices/:id/track ─────────────────────────────────────────────
128145
// Public endpoint — email clients load this pixel when the customer opens the email.
129146
// Updates invoice SENT → VIEWED and returns a 1×1 transparent GIF.

apps/api/src/modules/invoices/invoices.service.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,157 @@ export class InvoicesService {
601601
return url;
602602
}
603603

604+
// ── Public checkout data (no auth — customer-facing) ──────────────────────
605+
606+
async getPublicCheckoutData(invoiceId: string) {
607+
const invoice = await this.prisma.invoice.findUnique({
608+
where: { id: invoiceId },
609+
});
610+
if (!invoice) throw new NotFoundException('Invoice not found');
611+
612+
const merchant = await this.prisma.merchant.findUnique({
613+
where: { id: invoice.merchantId },
614+
select: {
615+
name: true,
616+
companyName: true,
617+
logoUrl: true,
618+
brandColor: true,
619+
email: true,
620+
},
621+
});
622+
623+
return {
624+
id: invoice.id,
625+
invoiceNumber: invoice.invoiceNumber ?? null,
626+
status: invoice.status,
627+
currency: invoice.currency,
628+
total: invoice.total.toString(),
629+
amountPaid: invoice.amountPaid.toString(),
630+
subtotal: invoice.subtotal.toString(),
631+
taxAmount: invoice.taxAmount?.toString() ?? null,
632+
discount: invoice.discount?.toString() ?? null,
633+
dueDate: invoice.dueDate?.toISOString() ?? null,
634+
paidAt: invoice.paidAt?.toISOString() ?? null,
635+
notes: invoice.notes ?? null,
636+
customerName: invoice.customerName ?? null,
637+
lineItems: invoice.lineItems,
638+
merchant: {
639+
name: merchant?.companyName ?? merchant?.name ?? 'Merchant',
640+
logo: merchant?.logoUrl ?? null,
641+
brandColor: merchant?.brandColor ?? null,
642+
email: merchant?.email ?? null,
643+
},
644+
};
645+
}
646+
647+
// ── Initiate payment (creates quote + payment, no auth) ────────────────────
648+
649+
async initiatePayment(invoiceId: string): Promise<{ paymentId: string }> {
650+
const invoice = await this.prisma.invoice.findUnique({
651+
where: { id: invoiceId },
652+
});
653+
if (!invoice) throw new NotFoundException('Invoice not found');
654+
655+
const payableStatuses: InvoiceStatus[] = [
656+
InvoiceStatus.SENT,
657+
InvoiceStatus.VIEWED,
658+
InvoiceStatus.PARTIALLY_PAID,
659+
InvoiceStatus.OVERDUE,
660+
];
661+
if (!payableStatuses.includes(invoice.status)) {
662+
throw new BadRequestException(
663+
`Invoice cannot be paid in status: ${invoice.status}`,
664+
);
665+
}
666+
667+
const merchant = await this.prisma.merchant.findUnique({
668+
where: { id: invoice.merchantId },
669+
select: {
670+
name: true,
671+
companyName: true,
672+
logoUrl: true,
673+
settlementAsset: true,
674+
settlementChain: true,
675+
settlementAddress: true,
676+
},
677+
});
678+
if (!merchant) throw new NotFoundException('Merchant not found');
679+
680+
const amountDue =
681+
toNumber(invoice.total) - toNumber(invoice.amountPaid);
682+
683+
if (amountDue <= 0) {
684+
throw new BadRequestException('Invoice balance is already settled');
685+
}
686+
687+
// TTL: use invoice due date if set, otherwise 7 days
688+
const expiresAt = invoice.dueDate
689+
? new Date(
690+
Math.max(
691+
invoice.dueDate.getTime(),
692+
Date.now() + 60 * 60 * 1000, // at least 1 h from now
693+
),
694+
)
695+
: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
696+
697+
const settlementAsset = merchant.settlementAsset ?? 'USDC';
698+
const settlementChain = merchant.settlementChain ?? 'stellar';
699+
const feeBps = 0; // invoice amounts are pre-agreed, no additional fee
700+
const feeAmount = 0;
701+
702+
// Create a 1:1 quote — invoice amounts are already final fiat figures
703+
const quote = await this.prisma.quote.create({
704+
data: {
705+
fromChain: 'fiat',
706+
fromAsset: invoice.currency,
707+
fromAmount: amountDue,
708+
toChain: settlementChain,
709+
toAsset: settlementAsset,
710+
toAmount: amountDue,
711+
rate: 1,
712+
feeBps,
713+
feeAmount,
714+
expiresAt,
715+
},
716+
});
717+
718+
const lineItems = Array.isArray(invoice.lineItems)
719+
? (invoice.lineItems as Array<{ description: string; qty: number; unitPrice: number; amount: number }>).map((li) => ({
720+
label: li.description,
721+
amount: li.amount,
722+
}))
723+
: [];
724+
725+
const payment = await this.prisma.payment.create({
726+
data: {
727+
merchantId: invoice.merchantId,
728+
quoteId: quote.id,
729+
status: 'PENDING',
730+
sourceChain: 'fiat',
731+
sourceAsset: invoice.currency,
732+
sourceAmount: amountDue,
733+
destChain: settlementChain,
734+
destAsset: settlementAsset,
735+
destAmount: amountDue,
736+
destAddress: merchant.settlementAddress ?? 'pending',
737+
metadata: {
738+
invoiceId: invoice.id,
739+
invoiceNumber: invoice.invoiceNumber ?? null,
740+
description: `Invoice ${invoice.invoiceNumber ?? invoice.id.slice(0, 8).toUpperCase()}`,
741+
merchantLogo: merchant.logoUrl ?? null,
742+
lineItems,
743+
paymentMethods: ['card', 'bank'],
744+
},
745+
},
746+
});
747+
748+
this.logger.log(
749+
`Invoice payment initiated: invoice=${invoiceId}, payment=${payment.id}`,
750+
);
751+
752+
return { paymentId: payment.id };
753+
}
754+
604755
async getPdfBuffer(id: string, merchantId: string): Promise<Buffer> {
605756
const invoice = await this.getById(id, merchantId);
606757

apps/api/src/modules/payments/payments.service.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export interface CheckoutPaymentResponse {
4242
description?: string;
4343
lineItems?: CheckoutLineItem[];
4444
expiresAt?: string;
45+
paymentMethods?: string[];
4546
}
4647

4748
export interface CardSessionResponse {
@@ -349,6 +350,9 @@ export class PaymentsService implements OnModuleInit {
349350
const description = this.readString(metadata.description);
350351
const merchantLogo = this.readString(metadata.merchantLogo);
351352
const lineItems = this.readLineItems(metadata.lineItems);
353+
const paymentMethods = Array.isArray(metadata.paymentMethods)
354+
? (metadata.paymentMethods as string[])
355+
: undefined;
352356

353357
return {
354358
id: payment.id,
@@ -368,6 +372,7 @@ export class PaymentsService implements OnModuleInit {
368372
},
369373
],
370374
expiresAt: payment.quote.expiresAt.toISOString(),
375+
paymentMethods,
371376
};
372377
}
373378

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { use } from "react";
2+
import { InvoiceCheckoutClient } from "@/components/InvoiceCheckoutClient";
3+
4+
export default function InvoicePage({
5+
params,
6+
}: {
7+
params: Promise<{ invoiceId: string }>;
8+
}) {
9+
const { invoiceId } = use(params);
10+
return <InvoiceCheckoutClient invoiceId={invoiceId} />;
11+
}

0 commit comments

Comments
 (0)