Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions apps/api/src/modules/invoices/invoices.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,23 @@ export class InvoicesController {
return this.invoicesService.recordPayment(id, merchantId, dto);
}

// ── GET /v1/invoices/:id/checkout ─────────────────────────────────────────
// Public — returns display data for the consumer-facing invoice page.
@Get(':id/checkout')
@PublicRoute()
async getCheckoutData(@Param('id') id: string) {
return this.invoicesService.getPublicCheckoutData(id);
}

// ── POST /v1/invoices/:id/pay ──────────────────────────────────────────────
// Public — creates a payment session for the invoice, returns paymentId.
@Post(':id/pay')
@PublicRoute()
@HttpCode(HttpStatus.CREATED)
async initiatePayment(@Param('id') id: string) {
return this.invoicesService.initiatePayment(id);
}

// ── GET /v1/invoices/:id/track ─────────────────────────────────────────────
// Public endpoint — email clients load this pixel when the customer opens the email.
// Updates invoice SENT → VIEWED and returns a 1×1 transparent GIF.
Expand Down
151 changes: 151 additions & 0 deletions apps/api/src/modules/invoices/invoices.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,157 @@ export class InvoicesService {
return url;
}

// ── Public checkout data (no auth — customer-facing) ──────────────────────

async getPublicCheckoutData(invoiceId: string) {
const invoice = await this.prisma.invoice.findUnique({
where: { id: invoiceId },
});
if (!invoice) throw new NotFoundException('Invoice not found');

const merchant = await this.prisma.merchant.findUnique({
where: { id: invoice.merchantId },
select: {
name: true,
companyName: true,
logoUrl: true,
brandColor: true,
email: true,
},
});

return {
id: invoice.id,
invoiceNumber: invoice.invoiceNumber ?? null,
status: invoice.status,
currency: invoice.currency,
total: invoice.total.toString(),
amountPaid: invoice.amountPaid.toString(),
subtotal: invoice.subtotal.toString(),
taxAmount: invoice.taxAmount?.toString() ?? null,
discount: invoice.discount?.toString() ?? null,
dueDate: invoice.dueDate?.toISOString() ?? null,
paidAt: invoice.paidAt?.toISOString() ?? null,
notes: invoice.notes ?? null,
customerName: invoice.customerName ?? null,
lineItems: invoice.lineItems,
merchant: {
name: merchant?.companyName ?? merchant?.name ?? 'Merchant',
logo: merchant?.logoUrl ?? null,
brandColor: merchant?.brandColor ?? null,
email: merchant?.email ?? null,
},
};
}

// ── Initiate payment (creates quote + payment, no auth) ────────────────────

async initiatePayment(invoiceId: string): Promise<{ paymentId: string }> {
const invoice = await this.prisma.invoice.findUnique({
where: { id: invoiceId },
});
if (!invoice) throw new NotFoundException('Invoice not found');

const payableStatuses: InvoiceStatus[] = [
InvoiceStatus.SENT,
InvoiceStatus.VIEWED,
InvoiceStatus.PARTIALLY_PAID,
InvoiceStatus.OVERDUE,
];
if (!payableStatuses.includes(invoice.status)) {
throw new BadRequestException(
`Invoice cannot be paid in status: ${invoice.status}`,
);
}

const merchant = await this.prisma.merchant.findUnique({
where: { id: invoice.merchantId },
select: {
name: true,
companyName: true,
logoUrl: true,
settlementAsset: true,
settlementChain: true,
settlementAddress: true,
},
});
if (!merchant) throw new NotFoundException('Merchant not found');

const amountDue =
toNumber(invoice.total) - toNumber(invoice.amountPaid);

if (amountDue <= 0) {
throw new BadRequestException('Invoice balance is already settled');
}

// TTL: use invoice due date if set, otherwise 7 days
const expiresAt = invoice.dueDate
? new Date(
Math.max(
invoice.dueDate.getTime(),
Date.now() + 60 * 60 * 1000, // at least 1 h from now
),
)
: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);

const settlementAsset = merchant.settlementAsset ?? 'USDC';
const settlementChain = merchant.settlementChain ?? 'stellar';
const feeBps = 0; // invoice amounts are pre-agreed, no additional fee
const feeAmount = 0;

// Create a 1:1 quote — invoice amounts are already final fiat figures
const quote = await this.prisma.quote.create({
data: {
fromChain: 'fiat',
fromAsset: invoice.currency,
fromAmount: amountDue,
toChain: settlementChain,
toAsset: settlementAsset,
toAmount: amountDue,
rate: 1,
feeBps,
feeAmount,
expiresAt,
},
});

const lineItems = Array.isArray(invoice.lineItems)
? (invoice.lineItems as Array<{ description: string; qty: number; unitPrice: number; amount: number }>).map((li) => ({
label: li.description,
amount: li.amount,
}))
: [];

const payment = await this.prisma.payment.create({
data: {
merchantId: invoice.merchantId,
quoteId: quote.id,
status: 'PENDING',
sourceChain: 'fiat',
sourceAsset: invoice.currency,
sourceAmount: amountDue,
destChain: settlementChain,
destAsset: settlementAsset,
destAmount: amountDue,
destAddress: merchant.settlementAddress ?? 'pending',
metadata: {
invoiceId: invoice.id,
invoiceNumber: invoice.invoiceNumber ?? null,
description: `Invoice ${invoice.invoiceNumber ?? invoice.id.slice(0, 8).toUpperCase()}`,
merchantLogo: merchant.logoUrl ?? null,
lineItems,
paymentMethods: ['card', 'bank'],
},
},
});

this.logger.log(
`Invoice payment initiated: invoice=${invoiceId}, payment=${payment.id}`,
);

return { paymentId: payment.id };
}

async getPdfBuffer(id: string, merchantId: string): Promise<Buffer> {
const invoice = await this.getById(id, merchantId);

Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/modules/payments/payments.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export interface CheckoutPaymentResponse {
description?: string;
lineItems?: CheckoutLineItem[];
expiresAt?: string;
paymentMethods?: string[];
}

export interface CardSessionResponse {
Expand Down Expand Up @@ -349,6 +350,9 @@ export class PaymentsService implements OnModuleInit {
const description = this.readString(metadata.description);
const merchantLogo = this.readString(metadata.merchantLogo);
const lineItems = this.readLineItems(metadata.lineItems);
const paymentMethods = Array.isArray(metadata.paymentMethods)
? (metadata.paymentMethods as string[])
: undefined;

return {
id: payment.id,
Expand All @@ -368,6 +372,7 @@ export class PaymentsService implements OnModuleInit {
},
],
expiresAt: payment.quote.expiresAt.toISOString(),
paymentMethods,
};
}

Expand Down
11 changes: 11 additions & 0 deletions apps/checkout/app/invoice/[invoiceId]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { use } from "react";
import { InvoiceCheckoutClient } from "@/components/InvoiceCheckoutClient";

export default function InvoicePage({
params,
}: {
params: Promise<{ invoiceId: string }>;
}) {
const { invoiceId } = use(params);
return <InvoiceCheckoutClient invoiceId={invoiceId} />;
}
Loading
Loading