diff --git a/apps/api/package.json b/apps/api/package.json index 42c672c..6c7b65d 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -6,7 +6,7 @@ "private": true, "license": "UNLICENSED", "scripts": { - "build": "prisma generate && nest build && cp generated/prisma/package.json dist/generated/prisma/package.json", + "build": "prisma generate && nest build && node -e \"const fs=require('fs'); const source=['generated/prisma/package.json','node_modules/.prisma/client/package.json','node_modules/@prisma/client/package.json'].find((file)=>fs.existsSync(file)); if(source){ fs.mkdirSync('dist/generated/prisma',{recursive:true}); fs.copyFileSync(source,'dist/generated/prisma/package.json'); }\"", "prisma:generate": "prisma generate", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "start": "nest start", diff --git a/apps/api/prisma/migrations/20260418090000_add_notifications/migration.sql b/apps/api/prisma/migrations/20260418090000_add_notifications/migration.sql new file mode 100644 index 0000000..27c6789 --- /dev/null +++ b/apps/api/prisma/migrations/20260418090000_add_notifications/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "Notification" ( + "id" TEXT NOT NULL, + "merchantId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "title" TEXT NOT NULL, + "body" TEXT NOT NULL, + "read" BOOLEAN NOT NULL DEFAULT false, + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Notification_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Notification_merchantId_createdAt_idx" ON "Notification"("merchantId", "createdAt"); + +-- AddForeignKey +ALTER TABLE "Notification" ADD CONSTRAINT "Notification_merchantId_fkey" FOREIGN KEY ("merchantId") REFERENCES "Merchant"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 68d71cf..d5d39bb 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -46,6 +46,7 @@ model Merchant { teamMembers TeamMember[] webhookEvents WebhookEvent[] apiKeys ApiKey[] + notifications Notification[] settlementKey MerchantSettlementKey? } @@ -355,3 +356,17 @@ enum WebhookStatus { FAILED EXHAUSTED } + +model Notification { + id String @id @default(cuid()) + merchantId String + type String + title String + body String + read Boolean @default(false) + metadata Json? + createdAt DateTime @default(now()) + merchant Merchant @relation(fields: [merchantId], references: [id], onDelete: Cascade) + + @@index([merchantId, createdAt]) +} diff --git a/apps/api/src/modules/auth/auth.service.ts b/apps/api/src/modules/auth/auth.service.ts index b218300..f5459ce 100644 --- a/apps/api/src/modules/auth/auth.service.ts +++ b/apps/api/src/modules/auth/auth.service.ts @@ -205,6 +205,10 @@ export class AuthService { }, }); + void this.notifications + .notifyApiKeyCreated(merchantId, apiKey.id, apiKey.name) + .catch(() => undefined); + return { apiKey: plainTextKey, id: apiKey.id, diff --git a/apps/api/src/modules/events/events/events.service.ts b/apps/api/src/modules/events/events/events.service.ts index bb92424..0f5970d 100644 --- a/apps/api/src/modules/events/events/events.service.ts +++ b/apps/api/src/modules/events/events/events.service.ts @@ -9,6 +9,15 @@ interface WebhookEventData { createdAt: Date | string; } +interface NotificationCreatedPayload { + id: string; + type: string; + title: string; + body: string; + metadata?: unknown; + createdAt: Date | string; +} + /** * Events Service * Handles real-time event emission to connected WebSocket clients @@ -242,4 +251,33 @@ export class EventsService { ); } } + + emitNotificationCreated( + merchantId: string, + notification: NotificationCreatedPayload, + ): void { + try { + const normalizedPayload = { + id: notification.id, + type: notification.type, + title: notification.title, + body: notification.body, + metadata: notification.metadata, + createdAt: new Date(notification.createdAt).toISOString(), + }; + + this.gateway.server + .to(`merchant:${merchantId}`) + .emit('notification.created', normalizedPayload); + + this.gateway.server.to(`merchant:${merchantId}`).emit('message', { + event: 'notification.created', + data: normalizedPayload, + }); + } catch (error) { + this.logger.error( + `Failed to emit notification.created: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } } diff --git a/apps/api/src/modules/invoices/invoices.service.ts b/apps/api/src/modules/invoices/invoices.service.ts index 01b29e9..cd75bc2 100644 --- a/apps/api/src/modules/invoices/invoices.service.ts +++ b/apps/api/src/modules/invoices/invoices.service.ts @@ -510,6 +510,10 @@ export class InvoicesService { }); if (newStatus === InvoiceStatus.PAID) { + void this.notifications + .notifyInvoicePaid(merchantId, id, existing.invoiceNumber) + .catch(() => undefined); + await this.webhooks.dispatch(merchantId, 'invoice.paid', { invoiceId: id, customerEmail: existing.customerEmail, diff --git a/apps/api/src/modules/merchant/merchant.module.ts b/apps/api/src/modules/merchant/merchant.module.ts index af33273..b1d285d 100644 --- a/apps/api/src/modules/merchant/merchant.module.ts +++ b/apps/api/src/modules/merchant/merchant.module.ts @@ -3,11 +3,12 @@ import { AuthModule } from '../auth/auth.module'; import { PrismaModule } from '../prisma/prisma.module'; import { MerchantController } from './merchant.controller'; import { MerchantService } from './merchant.service'; +import { NotificationsModule } from '../notifications/notifications.module'; import { MerchantSettlementService } from './merchant-settlement.service'; import { RolesGuard } from './guards/roles.guard'; @Module({ - imports: [AuthModule, PrismaModule], + imports: [AuthModule, PrismaModule, NotificationsModule], controllers: [MerchantController], providers: [MerchantService, MerchantSettlementService, RolesGuard], // Export MerchantSettlementService so AuthService can call .provision() diff --git a/apps/api/src/modules/merchant/merchant.service.ts b/apps/api/src/modules/merchant/merchant.service.ts index 26f5725..7205c6f 100644 --- a/apps/api/src/modules/merchant/merchant.service.ts +++ b/apps/api/src/modules/merchant/merchant.service.ts @@ -10,11 +10,15 @@ import { SettlementDto } from './dto/settlement.dto'; import { BrandingDto } from './dto/branding.dto'; import { InviteMemberDto } from './dto/invite-member.dto'; import { KybSubmissionDto } from './dto/kyb-submission.dto'; +import { NotificationsService } from '../notifications/notifications.service'; import { detectAddressChain, type Chain } from '@useroutr/types'; @Injectable() export class MerchantService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly notifications: NotificationsService, + ) {} // ── Profile ────────────────────────────────────────────────── @@ -138,7 +142,9 @@ export class MerchantService { }, }); - // TODO: send invite email via NotificationsModule once implemented + void this.notifications + .notifyTeamMemberJoined(merchantId, member.email, member.role) + .catch(() => undefined); return member; } diff --git a/apps/api/src/modules/notifications/notifications.controller.ts b/apps/api/src/modules/notifications/notifications.controller.ts new file mode 100644 index 0000000..37d69d6 --- /dev/null +++ b/apps/api/src/modules/notifications/notifications.controller.ts @@ -0,0 +1,47 @@ +import { + Controller, + Get, + HttpCode, + HttpStatus, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { CurrentMerchant } from '../merchant/decorators/current-merchant.decorator'; +import { NotificationsService } from './notifications.service'; + +@Controller('v1/notifications') +@UseGuards(JwtAuthGuard) +export class NotificationsController { + constructor(private readonly notificationsService: NotificationsService) {} + + @Get() + async listNotifications( + @CurrentMerchant('id') merchantId: string, + @Query('limit') limit?: string, + @Query('cursor') cursor?: string, + ) { + return this.notificationsService.listNotifications(merchantId, { + limit: limit ? Number(limit) : undefined, + cursor, + }); + } + + @Patch(':id/read') + @HttpCode(HttpStatus.OK) + async markAsRead( + @CurrentMerchant('id') merchantId: string, + @Param('id') notificationId: string, + ) { + return this.notificationsService.markAsRead(merchantId, notificationId); + } + + @Post('mark-all-read') + @HttpCode(HttpStatus.OK) + async markAllAsRead(@CurrentMerchant('id') merchantId: string) { + return this.notificationsService.markAllAsRead(merchantId); + } +} diff --git a/apps/api/src/modules/notifications/notifications.module.ts b/apps/api/src/modules/notifications/notifications.module.ts index eda5d50..d31052f 100644 --- a/apps/api/src/modules/notifications/notifications.module.ts +++ b/apps/api/src/modules/notifications/notifications.module.ts @@ -1,16 +1,22 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { BullModule } from '@nestjs/bullmq'; +import { PrismaModule } from '../prisma/prisma.module'; +import { EventsModule } from '../events/events.module'; +import { NotificationsController } from './notifications.controller'; import { NotificationsService } from './notifications.service'; import { NotificationsProcessor } from './notifications.processor'; @Module({ imports: [ ConfigModule, + PrismaModule, + EventsModule, BullModule.registerQueue({ name: 'notifications', }), ], + controllers: [NotificationsController], providers: [NotificationsService, NotificationsProcessor], exports: [NotificationsService], }) diff --git a/apps/api/src/modules/notifications/notifications.service.ts b/apps/api/src/modules/notifications/notifications.service.ts index ceebe19..7284815 100644 --- a/apps/api/src/modules/notifications/notifications.service.ts +++ b/apps/api/src/modules/notifications/notifications.service.ts @@ -1,10 +1,49 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectQueue } from '@nestjs/bullmq'; import { ConfigService } from '@nestjs/config'; +import { Notification, Prisma } from '@prisma/client'; import { Queue } from 'bullmq'; -import { EmailJobData, Invoice, Payment, Payout } from './types'; +import { EventsService } from '../events/events/events.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { + EmailJobData, + Invoice as EmailInvoice, + Payment as EmailPayment, + Payout as EmailPayout, +} from './types'; import * as templates from './templates'; +interface NotificationListOptions { + limit?: number; + cursor?: string; +} + +interface CreateNotificationInput { + merchantId: string; + type: string; + title: string; + body: string; + metadata?: Prisma.InputJsonValue | null; +} + +function formatCurrency(amount: number | string, currency = 'USD'): string { + const numericAmount = typeof amount === 'number' ? amount : Number(amount); + + if (!Number.isFinite(numericAmount)) { + return String(amount); + } + + try { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency, + maximumFractionDigits: 2, + }).format(numericAmount); + } catch { + return `${numericAmount.toFixed(2)} ${currency}`; + } +} + @Injectable() export class NotificationsService { private readonly appUrl: string; @@ -13,6 +52,8 @@ export class NotificationsService { @InjectQueue('notifications') private readonly notificationsQueue: Queue, private readonly configService: ConfigService, + private readonly prisma: PrismaService, + private readonly eventsService: EventsService, ) { this.appUrl = this.configService.get( 'FRONTEND_URL', @@ -34,6 +75,249 @@ export class NotificationsService { }); } + async createNotification( + input: CreateNotificationInput, + ): Promise { + const notification = await this.prisma.notification.create({ + data: { + merchantId: input.merchantId, + type: input.type, + title: input.title, + body: input.body, + ...(input.metadata !== undefined + ? { metadata: input.metadata ?? Prisma.JsonNull } + : {}), + }, + }); + + this.eventsService.emitNotificationCreated(input.merchantId, { + id: notification.id, + type: notification.type, + title: notification.title, + body: notification.body, + metadata: notification.metadata ?? undefined, + createdAt: notification.createdAt, + }); + + return notification; + } + + async listNotifications( + merchantId: string, + options: NotificationListOptions = {}, + ) { + const take = Math.min(Math.max(options.limit ?? 50, 1), 50); + + let createdAtCursor: Date | undefined; + if (options.cursor) { + const cursorItem = await this.prisma.notification.findFirst({ + where: { + id: options.cursor, + merchantId, + }, + select: { createdAt: true }, + }); + + createdAtCursor = cursorItem?.createdAt; + } + + const where: Prisma.NotificationWhereInput = { + merchantId, + ...(createdAtCursor ? { createdAt: { lt: createdAtCursor } } : {}), + }; + + const [items, unreadCount, total] = await Promise.all([ + this.prisma.notification.findMany({ + where, + take, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + }), + this.prisma.notification.count({ + where: { + merchantId, + read: false, + }, + }), + this.prisma.notification.count({ where: { merchantId } }), + ]); + + return { + data: items, + meta: { + total, + limit: take, + unreadCount, + nextCursor: items.length === take ? items[items.length - 1]?.id : null, + }, + }; + } + + async markAsRead(merchantId: string, notificationId: string) { + const existing = await this.prisma.notification.findFirst({ + where: { id: notificationId, merchantId }, + }); + + if (!existing) { + throw new NotFoundException('Notification not found'); + } + + if (existing.read) { + return existing; + } + + return this.prisma.notification.update({ + where: { id: notificationId }, + data: { read: true }, + }); + } + + async markAllAsRead(merchantId: string) { + const result = await this.prisma.notification.updateMany({ + where: { + merchantId, + read: false, + }, + data: { read: true }, + }); + + return { + updatedCount: result.count, + }; + } + + async notifyPaymentReceived( + merchantId: string, + paymentId: string, + amount: number | string, + currency = 'USD', + customerEmail?: string, + ) { + return this.createNotification({ + merchantId, + type: 'payment.received', + title: 'Payment received', + body: `${formatCurrency(amount, currency)}${customerEmail ? ` from ${customerEmail}` : ''}`, + metadata: { + paymentId, + amount: String(amount), + currency, + ...(customerEmail ? { customerEmail } : {}), + }, + }); + } + + async notifyPayoutCompleted( + merchantId: string, + payoutId: string, + recipientName: string, + ) { + return this.createNotification({ + merchantId, + type: 'payout.completed', + title: 'Payout completed', + body: `Payout to ${recipientName} completed`, + metadata: { + payoutId, + recipientName, + }, + }); + } + + async notifyPayoutFailed( + merchantId: string, + payoutId: string, + recipientName: string, + failureReason?: string, + ) { + return this.createNotification({ + merchantId, + type: 'payout.failed', + title: 'Payout failed', + body: failureReason + ? `Payout to ${recipientName} failed: ${failureReason}` + : `Payout to ${recipientName} failed`, + metadata: { + payoutId, + recipientName, + ...(failureReason ? { failureReason } : {}), + }, + }); + } + + async notifyInvoicePaid( + merchantId: string, + invoiceId: string, + invoiceNumber?: string | null, + ) { + return this.createNotification({ + merchantId, + type: 'invoice.paid', + title: 'Invoice paid', + body: `Invoice ${invoiceNumber ? `#${invoiceNumber}` : ''} marked as paid`.trim(), + metadata: { + invoiceId, + ...(invoiceNumber ? { invoiceNumber } : {}), + }, + }); + } + + async notifyRefundInitiated(merchantId: string, paymentId: string) { + return this.createNotification({ + merchantId, + type: 'refund.initiated', + title: 'Refund initiated', + body: 'A refund has been started for one of your payments.', + metadata: { paymentId }, + }); + } + + async notifyTeamMemberJoined( + merchantId: string, + email: string, + role: string, + ) { + return this.createNotification({ + merchantId, + type: 'team.member_joined', + title: 'Team member joined', + body: `${email} was added to your team as ${role.toLowerCase()}.`, + metadata: { email, role }, + }); + } + + async notifyApiKeyCreated( + merchantId: string, + keyId: string, + keyName: string, + ) { + return this.createNotification({ + merchantId, + type: 'api_key.created', + title: 'API key created', + body: `API key '${keyName}' was created successfully.`, + metadata: { apiKeyId: keyId, keyName }, + }); + } + + async notifyWebhookFailed( + merchantId: string, + webhookUrl: string, + eventType?: string, + eventId?: string, + ) { + return this.createNotification({ + merchantId, + type: 'webhook.failed', + title: 'Webhook failed', + body: `Webhook delivery to ${webhookUrl} failed`, + metadata: { + webhookUrl, + ...(eventType ? { eventType } : {}), + ...(eventId ? { eventId } : {}), + }, + }); + } + // Auth emails async sendVerificationEmail(email: string, token: string): Promise { await this.dispatch({ @@ -75,7 +359,7 @@ export class NotificationsService { // Payments async sendPaymentReceipt( customerEmail: string, - payment: Payment, + payment: EmailPayment, ): Promise { await this.dispatch({ to: customerEmail, @@ -86,7 +370,7 @@ export class NotificationsService { async sendPaymentNotification( merchantEmail: string, - payment: Payment, + payment: EmailPayment, ): Promise { await this.dispatch({ to: merchantEmail, @@ -98,7 +382,7 @@ export class NotificationsService { // Invoices async sendInvoice( customerEmail: string, - invoice: Invoice, + invoice: EmailInvoice, pdfBuffer: Buffer, ): Promise { await this.dispatch({ @@ -116,7 +400,7 @@ export class NotificationsService { async sendInvoiceReminder( customerEmail: string, - invoice: Invoice, + invoice: EmailInvoice, ): Promise { const now = new Date(); const diff = Math.ceil( @@ -139,7 +423,7 @@ export class NotificationsService { // Payouts async sendPayoutConfirmation( merchantEmail: string, - payout: Payout, + payout: EmailPayout, ): Promise { await this.dispatch({ to: merchantEmail, diff --git a/apps/api/src/modules/payments/payments.module.ts b/apps/api/src/modules/payments/payments.module.ts index cca9527..96d9e3d 100644 --- a/apps/api/src/modules/payments/payments.module.ts +++ b/apps/api/src/modules/payments/payments.module.ts @@ -13,6 +13,7 @@ import { QuotesModule } from '../quotes/quotes.module'; import { WebhooksModule } from '../webhooks/webhooks.module'; import { StripeWebhooksController } from '../webhooks/webhooks.controller'; import { AuthModule } from '../auth/auth.module'; +import { NotificationsModule } from '../notifications/notifications.module'; import { LinksModule } from '../links/links.module'; import { CctpModule } from '../cctp/cctp.module'; @@ -36,6 +37,7 @@ import { CctpModule } from '../cctp/cctp.module'; // (not CctpModule) so the processor can inject PaymentsService without // a module-level circular dependency. BullModule.registerQueue({ name: CCTP_OBSERVE_QUEUE }), + NotificationsModule, ], providers: [PaymentsService, CctpProcessor], controllers: [ diff --git a/apps/api/src/modules/payments/payments.service.ts b/apps/api/src/modules/payments/payments.service.ts index 94376b6..de36842 100644 --- a/apps/api/src/modules/payments/payments.service.ts +++ b/apps/api/src/modules/payments/payments.service.ts @@ -40,6 +40,7 @@ import { ethers } from 'ethers'; import { CreatePaymentDto } from './dto/create-payment.dto'; import { PaymentFiltersDto } from './dto/payment-filters.dto'; import { PaymentResponseDto } from './dto/payment-response.dto'; +import { NotificationsService } from '../notifications/notifications.service'; import * as crypto from 'crypto'; interface CheckoutLineItem { @@ -151,6 +152,7 @@ export class PaymentsService implements OnModuleInit { private readonly cctpService: CctpService, @InjectQueue(CCTP_OBSERVE_QUEUE) private readonly cctpQueue: Queue, private readonly configService: ConfigService, + private readonly notificationsService: NotificationsService, ) { const secretKey = this.configService.get('STRIPE_SECRET_KEY'); this.stripe = secretKey ? new Stripe(secretKey) : null; @@ -208,6 +210,37 @@ export class PaymentsService implements OnModuleInit { updatedPayment.id, ); + const metadata = + updatedPayment.metadata && typeof updatedPayment.metadata === 'object' + ? (updatedPayment.metadata as Record) + : {}; + const customerEmail = + typeof metadata.customerEmail === 'string' + ? metadata.customerEmail + : typeof metadata.email === 'string' + ? metadata.email + : undefined; + + if (status === PaymentStatus.COMPLETED) { + const receivedAmount = updatedPayment.destAmount?.toString() ?? '0'; + + void this.notificationsService + .notifyPaymentReceived( + updatedPayment.merchantId, + updatedPayment.id, + receivedAmount, + updatedPayment.destAsset || 'USD', + customerEmail, + ) + .catch(() => undefined); + } + + if (status === PaymentStatus.REFUNDING) { + void this.notificationsService + .notifyRefundInitiated(updatedPayment.merchantId, updatedPayment.id) + .catch(() => undefined); + } + return updatedPayment; } @@ -391,9 +424,13 @@ export class PaymentsService implements OnModuleInit { const payment = await this.prisma.payment.create({ data: { merchantId: internal.merchant.id, + quoteId: null, status: PaymentStatus.PENDING, // Source fields stay null until the customer picks a method. // Quote stays null too — created when the method is chosen. + sourceChain: null, + sourceAsset: null, + sourceAmount: null, destChain: internal.merchant.settlementChain, destAsset: internal.merchant.settlementAsset, destAmount, diff --git a/apps/api/src/modules/payouts/payouts.module.ts b/apps/api/src/modules/payouts/payouts.module.ts index 581fcc7..2e36b3c 100644 --- a/apps/api/src/modules/payouts/payouts.module.ts +++ b/apps/api/src/modules/payouts/payouts.module.ts @@ -5,9 +5,11 @@ import { PrismaModule } from '../prisma/prisma.module'; import { WebhooksModule } from '../webhooks/webhooks.module'; import { StellarModule } from '../stellar/stellar.module'; import { AuthModule } from '../auth/auth.module'; +import { EventsModule } from '../events/events.module'; +import { NotificationsModule } from '../notifications/notifications.module'; @Module({ - imports: [PrismaModule, WebhooksModule, StellarModule, AuthModule], + imports: [PrismaModule, WebhooksModule, StellarModule, AuthModule, EventsModule, NotificationsModule], providers: [PayoutsService], controllers: [PayoutsController], exports: [PayoutsService], diff --git a/apps/api/src/modules/payouts/payouts.service.ts b/apps/api/src/modules/payouts/payouts.service.ts index 618ed05..bf013b8 100644 --- a/apps/api/src/modules/payouts/payouts.service.ts +++ b/apps/api/src/modules/payouts/payouts.service.ts @@ -9,6 +9,8 @@ import { DestType, Payout, PayoutStatus, Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { WebhooksService } from '../webhooks/webhooks.service'; import { StellarService } from '../stellar/stellar.service'; +import { EventsService } from '../events/events/events.service'; +import { NotificationsService } from '../notifications/notifications.service'; import { CreatePayoutDto, BulkPayoutDto } from './dto/create-payout.dto'; import { PayoutFiltersDto } from './dto/payout-filters.dto'; import { randomUUID } from 'crypto'; @@ -42,6 +44,8 @@ export class PayoutsService { private readonly prisma: PrismaService, private readonly webhooks: WebhooksService, private readonly stellar: StellarService, + private readonly eventsService: EventsService, + private readonly notifications: NotificationsService, ) {} // ── Create single payout ────────────────────────────────────────────────── @@ -254,6 +258,25 @@ export class PayoutsService { where: { id: payout.id }, data: { status: PayoutStatus.FAILED, failureReason }, }); + this.eventsService.emitPayoutStatus( + payout.merchantId, + payout.id, + PayoutStatus.FAILED, + { + amount: failed.amount.toString(), + currency: failed.currency, + failureReason, + updatedAt: new Date(), + }, + ); + void this.notifications + .notifyPayoutFailed( + payout.merchantId, + payout.id, + payout.recipientName, + failureReason, + ) + .catch(() => undefined); this.webhooks .dispatch(payout.merchantId, 'payout.failed', { ...this.webhookPayload(failed), @@ -307,6 +330,25 @@ export class PayoutsService { }, }); + this.eventsService.emitPayoutStatus( + payout.merchantId, + payout.id, + PayoutStatus.COMPLETED, + { + amount: completed.amount.toString(), + currency: completed.currency, + stellarTxHash: txHash, + updatedAt: new Date(), + }, + ); + void this.notifications + .notifyPayoutCompleted( + payout.merchantId, + payout.id, + payout.recipientName, + ) + .catch(() => undefined); + this.webhooks .dispatch(payout.merchantId, 'payout.completed', { ...this.webhookPayload(completed), diff --git a/apps/api/src/modules/webhooks/webhooks.module.ts b/apps/api/src/modules/webhooks/webhooks.module.ts index a1e10ab..5a1532f 100644 --- a/apps/api/src/modules/webhooks/webhooks.module.ts +++ b/apps/api/src/modules/webhooks/webhooks.module.ts @@ -4,11 +4,13 @@ import { WebhooksService } from './webhooks.service'; import { WebhooksProcessor } from './webhooks.processor'; import { WebhooksController } from './webhooks.controller'; import { PrismaModule } from '../prisma/prisma.module'; +import { NotificationsModule } from '../notifications/notifications.module'; import { WEBHOOK_QUEUE_NAME } from './webhooks.constants'; @Module({ imports: [ PrismaModule, + NotificationsModule, BullModule.registerQueue({ name: WEBHOOK_QUEUE_NAME }), ], providers: [WebhooksService, WebhooksProcessor], diff --git a/apps/api/src/modules/webhooks/webhooks.processor.ts b/apps/api/src/modules/webhooks/webhooks.processor.ts index 0e574dc..be124be 100644 --- a/apps/api/src/modules/webhooks/webhooks.processor.ts +++ b/apps/api/src/modules/webhooks/webhooks.processor.ts @@ -3,6 +3,7 @@ import { Processor, WorkerHost, InjectQueue } from '@nestjs/bullmq'; import { Job, Queue } from 'bullmq'; import axios, { AxiosError } from 'axios'; import { PrismaService } from '../prisma/prisma.service'; +import { NotificationsService } from '../notifications/notifications.service'; import { WebhookStatus } from '@prisma/client'; import { WEBHOOK_QUEUE_NAME, @@ -22,6 +23,7 @@ export class WebhooksProcessor extends WorkerHost { constructor( @InjectQueue(WEBHOOK_QUEUE_NAME) private readonly webhookQueue: Queue, private readonly prisma: PrismaService, + private readonly notifications: NotificationsService, ) { super(); } @@ -117,6 +119,12 @@ export class WebhooksProcessor extends WorkerHost { }, }); + if (attempt === 1) { + void this.notifications + .notifyWebhookFailed(merchantId, webhookUrl, eventType, eventId) + .catch(() => undefined); + } + this.logger.log( `Webhook ${eventId} will retry at ${nextRetryAt.toISOString()} (delay: ${delayMs}ms)`, ); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index 7561134..7ab7cf6 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -1,7 +1,6 @@ { "compilerOptions": { "module": "CommonJS", - "moduleResolution": "node", "esModuleInterop": true, "isolatedModules": true, "declaration": true, @@ -12,7 +11,6 @@ "target": "ES2020", "sourceMap": true, "outDir": "./dist", - "baseUrl": "./", "incremental": true, "skipLibCheck": true, "strictNullChecks": true, diff --git a/apps/dashboard/src/app/(dashboard)/layout.tsx b/apps/dashboard/src/app/(dashboard)/layout.tsx index 6e42f32..2c5c12b 100644 --- a/apps/dashboard/src/app/(dashboard)/layout.tsx +++ b/apps/dashboard/src/app/(dashboard)/layout.tsx @@ -4,6 +4,7 @@ import { AppSidebar } from "@/components/app-sidebar"; import { SiteHeader } from "@/components/site-header"; import { SidebarInset, SidebarProvider, SidebarRail } from "@useroutr/ui"; import { useAuth } from "@/providers/AuthProvider"; +import { useRefundEvents } from "@/hooks/useRefundEvents"; export default function DashboardLayout({ children, @@ -12,6 +13,9 @@ export default function DashboardLayout({ }) { const { merchant, isLoading } = useAuth(); + // Subscribe to real-time refund events across all dashboard pages + useRefundEvents(); + if (isLoading) { return (
diff --git a/apps/dashboard/src/app/(dashboard)/links/page.tsx b/apps/dashboard/src/app/(dashboard)/links/page.tsx index ee86758..e779f83 100644 --- a/apps/dashboard/src/app/(dashboard)/links/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/links/page.tsx @@ -94,15 +94,13 @@ export default function PaymentLinksPage() { const { subscribe } = useDashboardSocket(); useEffect(() => { - // Subscribe to payment link payment events - const unsubscribe = subscribe("payment-link.payment", (...args: unknown[]) => { - const payload = args[0] as { linkId: string; amount: number }; - toast(`Payment received: $${payload.amount}`, "success"); + // Refresh the link list when a real-time link payment event arrives. + const unsubscribe = subscribe("payment-link.payment", () => { refetch(); }); return () => unsubscribe(); - }, [subscribe, toast, refetch]); + }, [subscribe, refetch]); const handleCreate = (data: CreatePaymentLinkInput) => { createMutation.mutate(data, { diff --git a/apps/dashboard/src/app/(dashboard)/refunds/page.tsx b/apps/dashboard/src/app/(dashboard)/refunds/page.tsx new file mode 100644 index 0000000..5dfadb1 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/refunds/page.tsx @@ -0,0 +1,18 @@ +import { RefundHistoryTable } from "@/components/payments/RefundHistoryTable"; + +export default function RefundsPage() { + return ( +
+
+

+ Refunds +

+

+ View and manage all refund transactions +

+
+ + +
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/settings/page.tsx b/apps/dashboard/src/app/(dashboard)/settings/page.tsx index 44c0427..d3385de 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/page.tsx @@ -7,6 +7,7 @@ import { useUpdateMerchantProfile, useProvisionSettlement, } from "@/hooks/useSettings"; +import { useToastNotificationPreference } from "@/hooks/useToastNotificationPreference"; import { motion } from "framer-motion"; import { Building2, @@ -33,6 +34,10 @@ export default function SettingsPage() { const { data: merchant, isLoading: isLoadingProfile } = useMerchantProfile(); const updateProfile = useUpdateMerchantProfile(); + const { + enabled: realtimeNotificationsEnabled, + setEnabled: setRealtimeNotificationsEnabled, + } = useToastNotificationPreference(); const provisionSettlement = useProvisionSettlement(); const [name, setName] = useState(""); @@ -62,6 +67,16 @@ export default function SettingsPage() { ); }; + const handleRealtimeNotificationsToggle = (checked: boolean) => { + setRealtimeNotificationsEnabled(checked); + toast( + checked + ? "Real-time notifications enabled." + : "Real-time notifications disabled.", + checked ? "success" : "info", + ); + }; + if (isLoadingProfile) { return (
@@ -254,6 +269,39 @@ export default function SettingsPage() {
+ + {[ { label: "Email notifications", diff --git a/apps/dashboard/src/app/globals.css b/apps/dashboard/src/app/globals.css index 7003643..178963d 100644 --- a/apps/dashboard/src/app/globals.css +++ b/apps/dashboard/src/app/globals.css @@ -7,10 +7,107 @@ @import "tw-animate-css"; @import "@useroutr/ui/globals.css"; +@source "../../../../packages/ui/src"; + @custom-variant dark (&:is(.dark *)); -:root { - --header-height: 3.5rem; +@theme inline { + /* ── Colors ── */ + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-border: var(--border); + --color-ring: var(--ring); + --color-input: var(--input); + --color-success: var(--success); + --color-success-foreground: var(--success-foreground); + --color-warning: var(--warning); + --color-warning-foreground: var(--warning-foreground); + + /* ── Chart palette ── */ + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + + /* ── Brand palette ── */ + --color-ink: var(--ink); + --color-ink2: var(--ink2); + --color-ink3: var(--ink3); + --color-blue: var(--blue); + --color-blue2: var(--blue2); + --color-teal: var(--teal); + --color-amber: var(--amber); + --color-red: var(--red); + --color-green: var(--green); + --color-purple: var(--purple); + + /* ── Font families ── */ + --font-display: "Inter", "Trebuchet MS", sans-serif; + --font-body: "Open Sans", sans-serif; + --font-mono: "IBM Plex Mono", "JetBrains Mono", Menlo, monospace; + + /* ── Radius ── */ + --radius-sm: var(--radius-sm); + --radius-md: var(--radius-md); + --radius-lg: var(--radius-lg); + --radius-xl: var(--radius-xl); + --radius-2xl: var(--radius-2xl); + + /* ── Sidebar ── */ + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + + /* ── Dark mode page tokens ── */ + --color-bg-page: var(--bg-page); + --color-bg-card: var(--bg-card); + --color-bg-sidebar: var(--bg-sidebar); + --color-text-primary: var(--text-primary); + --color-text-secondary: var(--text-secondary); + --color-border-color: var(--border-color); + --color-input-bg: var(--input-bg); + --color-input-border: var(--input-border); + --color-code-bg: var(--code-bg); + + /* ── Chart theming tokens ── */ + --color-chart-tick: var(--chart-tick); + --color-chart-grid: var(--chart-grid); + --color-chart-tooltip-bg: var(--chart-tooltip-bg); + --color-chart-tooltip-text: var(--chart-tooltip-text); + + /* ── Status tokens ── */ + --color-status-success-bg: var(--status-success-bg); + --color-status-success-text: var(--status-success-text); + --color-status-warning-bg: var(--status-warning-bg); + --color-status-warning-text: var(--status-warning-text); + --color-status-error-bg: var(--status-error-bg); + --color-status-error-text: var(--status-error-text); + --color-status-info-bg: var(--status-info-bg); + --color-status-info-text: var(--status-info-text); + --color-status-purple-bg: var(--status-purple-bg); + --color-status-purple-text: var(--status-purple-text); + --color-status-amber-bg: var(--status-amber-bg); + --color-status-amber-text: var(--status-amber-text); } @layer base { diff --git a/apps/dashboard/src/app/layout.tsx b/apps/dashboard/src/app/layout.tsx index 595381e..e55d6f0 100644 --- a/apps/dashboard/src/app/layout.tsx +++ b/apps/dashboard/src/app/layout.tsx @@ -4,8 +4,11 @@ import { Hanken_Grotesk, Fraunces, JetBrains_Mono } from "next/font/google"; import { ThemeProvider } from "@/providers/ThemeProvider"; import { QueryProvider } from "@/providers/QueryProvider"; import { AuthProvider } from "@/providers/AuthProvider"; +import { RealtimeToastNotifications } from "@/providers/RealtimeToastNotifications"; +import { NotificationsProvider } from "@/providers/NotificationsProvider"; import "./globals.css"; import { ToastProvider } from "@useroutr/ui"; +import { TooltipProvider } from "@/components/ui/tooltip"; // Hanken Grotesk — display + body. Same family as the marketing site so the // brand voice is unbroken between useroutr.com and dashboard.useroutr.com. @@ -56,7 +59,14 @@ export default function RootLayout({ - {children} + + + + + {children} + + + diff --git a/apps/dashboard/src/components/analytics/AnalyticsDashboard.tsx b/apps/dashboard/src/components/analytics/AnalyticsDashboard.tsx index 00b11f1..40726fd 100644 --- a/apps/dashboard/src/components/analytics/AnalyticsDashboard.tsx +++ b/apps/dashboard/src/components/analytics/AnalyticsDashboard.tsx @@ -111,9 +111,9 @@ const PERIOD_OPTIONS: Array<{ value: Period; label: string }> = [ ]; const PAYMENT_COLORS: Record = { - card: "#3b82f6", - crypto: "#14b8a6", - bank: "#f59e0b", + card: "var(--blue)", + crypto: "var(--teal)", + bank: "var(--amber)", }; const WEEKDAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; @@ -782,7 +782,7 @@ function RevenueChart({ x2={width} y1={y} y2={y} - stroke="rgba(148,163,184,0.22)" + stroke="var(--chart-grid)" strokeDasharray="4 6" /> ); @@ -806,7 +806,7 @@ function RevenueChart({ width={barWidth} height={barHeight} rx={8} - fill={active ? "#1d4ed8" : "#3b82f6"} + fill={active ? "var(--blue)" : "var(--blue2)"} opacity={active ? 1 : 0.82} /> {point.label} @@ -826,13 +826,13 @@ function RevenueChart({ width={106} height={30} rx={10} - fill="rgba(15,23,42,0.95)" + fill="var(--chart-tooltip-bg)" /> {formatCompactMoney(point.amount)} @@ -880,7 +880,7 @@ function PaymentMethodCard({ cy="70" r={radius} fill="none" - stroke="rgba(148,163,184,0.18)" + stroke="var(--chart-grid)" strokeWidth="20" /> {segments.map((segment) => { @@ -983,8 +983,8 @@ function ConversionCard({ > - - + + cell.count), 1); const getColor = (count: number) => { const alpha = count / maxCount; - return `rgba(239,68,68,${0.1 + alpha * 0.75})`; + return `color-mix(in srgb, var(--red) ${Math.round((0.1 + alpha * 0.75) * 100)}%, transparent)`; }; return ( diff --git a/apps/dashboard/src/components/app-sidebar.tsx b/apps/dashboard/src/components/app-sidebar.tsx index 9059916..30b91bc 100644 --- a/apps/dashboard/src/components/app-sidebar.tsx +++ b/apps/dashboard/src/components/app-sidebar.tsx @@ -10,6 +10,7 @@ import { Settings, LifeBuoy, Send, + RotateCcw, } from "lucide-react"; import { NavMain } from "@/components/nav-main"; @@ -28,6 +29,7 @@ import { const navMain = [ { title: "Overview", url: "/", icon: Home }, { title: "Payments", url: "/payments", icon: CreditCard }, + { title: "Refunds", url: "/refunds", icon: RotateCcw }, { title: "Payment Links", url: "/links", icon: Link2 }, { title: "Invoices", url: "/invoices", icon: FileText }, { title: "Payouts", url: "/payouts", icon: ArrowLeftRight }, diff --git a/apps/dashboard/src/components/invoices/InvoiceDetailSheet.tsx b/apps/dashboard/src/components/invoices/InvoiceDetailSheet.tsx index 3d7e594..060eac1 100644 --- a/apps/dashboard/src/components/invoices/InvoiceDetailSheet.tsx +++ b/apps/dashboard/src/components/invoices/InvoiceDetailSheet.tsx @@ -1,4 +1,4 @@ -"use client"; +"use client"; import { useState } from "react"; import { @@ -34,15 +34,15 @@ import { SendInvoiceModal } from "./SendInvoiceModal"; import { InvoicePdfPreview } from "./InvoicePdfPreview"; import type { Invoice, InvoiceStatus } from "@/hooks/useInvoices"; -// ── Status badge ─────────────────────────────────────────────────────────────── +// ── Status badge ─────────────────────────────────────────────────────────────── const STATUS_CONFIG: Record = { DRAFT: { label: "Draft", className: "bg-muted text-muted-foreground" }, - SENT: { label: "Sent", className: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300" }, - VIEWED: { label: "Viewed", className: "bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300" }, - PARTIALLY_PAID: { label: "Partially Paid", className: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300" }, - PAID: { label: "Paid", className: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300" }, - OVERDUE: { label: "Overdue", className: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300" }, + SENT: { label: "Sent", className: "bg-status-info-bg text-status-info-text" }, + VIEWED: { label: "Viewed", className: "bg-status-purple-bg text-status-purple-text" }, + PARTIALLY_PAID: { label: "Partially Paid", className: "bg-status-amber-bg text-status-amber-text" }, + PAID: { label: "Paid", className: "bg-status-success-bg text-status-success-text" }, + OVERDUE: { label: "Overdue", className: "bg-status-error-bg text-status-error-text" }, CANCELLED: { label: "Cancelled", className: "bg-muted text-muted-foreground" }, }; @@ -55,7 +55,7 @@ function StatusBadge({ status }: { status: InvoiceStatus }) { ); } -// ── Activity timeline ───────────────────────────────────────────────────────── +// ── Activity timeline ───────────────────────────────────────────────────────── interface TimelineEvent { key: string; @@ -239,7 +239,7 @@ function ActivityTimeline({ invoice }: { invoice: Invoice }) { ); } -// ── Props ────────────────────────────────────────────────────────────────────── +// ── Props ────────────────────────────────────────────────────────────────────── interface InvoiceDetailSheetProps { invoice: Invoice | null; @@ -254,7 +254,7 @@ interface InvoiceDetailSheetProps { isSendPending?: boolean; } -// ── Helpers ──────────────────────────────────────────────────────────────────── +// ── Helpers ──────────────────────────────────────────────────────────────────── function fmtCurrency(amount: string | number, currency: string) { const num = typeof amount === "string" ? parseFloat(amount) : amount; @@ -263,7 +263,7 @@ function fmtCurrency(amount: string | number, currency: string) { } function fmtDate(iso?: string | null) { - if (!iso) return "—"; + if (!iso) return "—"; return new Date(iso).toLocaleDateString("en-US", { year: "numeric", month: "long", @@ -271,7 +271,7 @@ function fmtDate(iso?: string | null) { }); } -// ── Component ────────────────────────────────────────────────────────────────── +// ── Component ────────────────────────────────────────────────────────────────── export function InvoiceDetailSheet({ invoice, @@ -305,7 +305,7 @@ export function InvoiceDetailSheet({ <> - {/* ── Header ── */} + {/* ── Header ── */}
@@ -334,7 +334,7 @@ export function InvoiceDetailSheet({
- {/* ── Scrollable body ── */} + {/* ── Scrollable body ── */}
@@ -402,7 +402,7 @@ export function InvoiceDetailSheet({

{fmtDate(invoice.dueDate)} @@ -412,7 +412,7 @@ export function InvoiceDetailSheet({ {invoice.paidAt && (

Paid On

-

{fmtDate(invoice.paidAt)}

+

{fmtDate(invoice.paidAt)}

)} @@ -464,8 +464,8 @@ export function InvoiceDetailSheet({ {discount > 0 && (
Discount - - −{fmtCurrency(discount, invoice.currency)} + + −{fmtCurrency(discount, invoice.currency)}
)} @@ -476,7 +476,7 @@ export function InvoiceDetailSheet({
{amountPaid > 0 && ( <> -
+
Amount Paid {fmtCurrency(amountPaid, invoice.currency)}
@@ -494,10 +494,10 @@ export function InvoiceDetailSheet({

Payment Details

-
+
- - + + {invoice.status === "PAID" ? "Fully paid" : "Partially paid"}
@@ -517,7 +517,7 @@ export function InvoiceDetailSheet({ {amountDue > 0 && (

Balance due

-

+

{fmtCurrency(amountDue, invoice.currency)}

@@ -554,7 +554,7 @@ export function InvoiceDetailSheet({
- {/* ── Footer actions ── */} + {/* ── Footer actions ── */}
{/* Primary row: Edit + Send */} {isDraft && ( @@ -615,7 +615,7 @@ export function InvoiceDetailSheet({ - {/* ── Send modal (rendered outside sheet to avoid z-index nesting issues) ── */} + {/* ── Send modal (rendered outside sheet to avoid z-index nesting issues) ── */} - {/* ── PDF preview dialog ── */} + {/* ── PDF preview dialog ── */} 0"), unitPrice: z .number({ invalid_type_error: "Must be a number" }) - .nonnegative("Price ≥ 0"), + .nonnegative("Price ≥ 0"), }); const InvoiceSchema = z.object({ @@ -44,7 +44,7 @@ const InvoiceSchema = z.object({ type FormErrors = Partial>; -// ── Line item helpers ────────────────────────────────────────────────────────── +// ── Line item helpers ────────────────────────────────────────────────────────── interface LineItemRow { id: string; @@ -80,7 +80,7 @@ function computeTotals( return { subtotal, taxAmount, total }; } -// ── Props ────────────────────────────────────────────────────────────────────── +// ── Props ────────────────────────────────────────────────────────────────────── interface InvoiceDrawerProps { open: boolean; @@ -92,7 +92,7 @@ interface InvoiceDrawerProps { isLoading?: boolean; } -// ── Component ────────────────────────────────────────────────────────────────── +// ── Component ────────────────────────────────────────────────────────────────── export function InvoiceDrawer({ open, @@ -104,12 +104,12 @@ export function InvoiceDrawer({ }: InvoiceDrawerProps) { const isEditing = !!invoice; - // ── Customer fields ────────────────────────────────────────────────────────── + // ── Customer fields ────────────────────────────────────────────────────────── const [customerEmail, setCustomerEmail] = useState(invoice?.customerEmail ?? ""); const [customerName, setCustomerName] = useState(invoice?.customerName ?? ""); const [invoiceNumber, setInvoiceNumber] = useState(invoice?.invoiceNumber ?? ""); - // ── Line items ─────────────────────────────────────────────────────────────── + // ── Line items ─────────────────────────────────────────────────────────────── const [rows, setRows] = useState(() => { if (invoice?.lineItems?.length) { return invoice.lineItems.map((li) => ({ @@ -122,7 +122,7 @@ export function InvoiceDrawer({ return [emptyRow()]; }); - // ── Pricing ────────────────────────────────────────────────────────────────── + // ── Pricing ────────────────────────────────────────────────────────────────── const [currency, setCurrency] = useState(invoice?.currency ?? "USD"); const [taxRate, setTaxRate] = useState( invoice?.taxRate ? String(Number(invoice.taxRate) * 100) : "", @@ -131,21 +131,21 @@ export function InvoiceDrawer({ invoice?.discount ? String(Number(invoice.discount)) : "", ); - // ── Meta ───────────────────────────────────────────────────────────────────── + // ── Meta ───────────────────────────────────────────────────────────────────── const [dueDate, setDueDate] = useState( invoice?.dueDate ? invoice.dueDate.split("T")[0] : "", ); const [notes, setNotes] = useState(invoice?.notes ?? ""); - // ── Errors ─────────────────────────────────────────────────────────────────── + // ── Errors ─────────────────────────────────────────────────────────────────── const [errors, setErrors] = useState({}); - // ── Computed totals ─────────────────────────────────────────────────────────── + // ── Computed totals ─────────────────────────────────────────────────────────── const taxRatePct = parseFloat(taxRate) || 0; const discountAmt = parseFloat(discount) || 0; const { subtotal, taxAmount, total } = computeTotals(rows, taxRatePct, discountAmt); - // ── Row handlers ────────────────────────────────────────────────────────────── + // ── Row handlers ────────────────────────────────────────────────────────────── const addRow = () => setRows((prev) => [...prev, emptyRow()]); const removeRow = useCallback((id: string) => { @@ -166,7 +166,7 @@ export function InvoiceDrawer({ [], ); - // ── Validation ──────────────────────────────────────────────────────────────── + // ── Validation ──────────────────────────────────────────────────────────────── const validate = (): CreateInvoiceInput | null => { const lineItemsForValidation = rows.map(rowToItem); @@ -259,7 +259,7 @@ export function InvoiceDrawer({ - {/* ── Scrollable body ── */} + {/* ── Scrollable body ── */}
{/* Client details */} @@ -466,7 +466,7 @@ export function InvoiceDrawer({ {discountAmt > 0 && (
Discount - −{fmtCurrency(discountAmt)} + −{fmtCurrency(discountAmt)}
)} @@ -496,7 +496,7 @@ export function InvoiceDrawer({