-
Notifications
You must be signed in to change notification settings - Fork 4
Feat: ecommerce sales and user's receipts #155
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feat: ecommerce sales and user's receipts #155
Conversation
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the You can disable this status message by setting the WalkthroughImplements client payment ingestion via webhook, adds DB schema and TRPC endpoints for client payments and user receipts, and introduces UI pages/components for Sales (merchant view) and Receipts (user view) with filtering, pagination, and error/empty states. Navigation updated to include a Receipts tab. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor RequestNetwork as Request Network Webhook
participant API as API /webhook (POST)
participant DB as Database
Note over RequestNetwork,API: payment.confirmed (with clientId)
RequestNetwork->>API: POST /api/webhook { clientId, ... }
alt clientId present
API->>DB: Check duplicate in clientPaymentTable
DB-->>API: Duplicate? (no)
API->>DB: Lookup ecommerceClient by rnClientId + user/domain
DB-->>API: ecommerceClient row or null
alt client found
API->>DB: Insert clientPaymentTable (mapped fields)
DB-->>API: Inserted row
API-->>RequestNetwork: 200 OK
else client not found
API-->>RequestNetwork: 400/500 Error
end
else no clientId
Note right of API: Execute existing flows (e.g., recurring)
API-->>RequestNetwork: Response
end
sequenceDiagram
autonumber
actor User as Merchant User
participant Page as /ecommerce/sales
participant TRPC as ecommerceRouter.getAllClientPayments
participant DB as Database
User->>Page: Navigate
Page->>TRPC: Query payments (session user)
TRPC->>DB: SELECT clientPayment + join ecommerceClient
DB-->>TRPC: Rows
TRPC-->>Page: Data
Page->>Page: Render ClientPaymentsTable (filter/pagination)
sequenceDiagram
autonumber
actor EndUser as End User
participant Page as /dashboard/receipts
participant TRPC as ecommerceRouter.getAllUserReceipts
participant DB as Database
EndUser->>Page: Navigate (requires session)
Page->>TRPC: Query receipts (by user email in customerInfo JSON)
TRPC->>DB: SELECT clientPayment WHERE customerInfo.email = user.email
DB-->>TRPC: Rows
TRPC-->>Page: Data
Page->>Page: Render DashboardReceipts (filter/pagination)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested reviewers
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Comment |
82c9b3e
to
77a7575
Compare
@CodeRabbit review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 3
🧹 Nitpick comments (2)
src/components/dashboard/receipts.tsx (1)
94-104
: Clamp the active page when data shrinksWhen a refetch returns fewer receipts,
currentPage
can stay above the newtotalPages
, yielding an empty table even though data exists. Clamp it whenever the filtered length changes.-const totalPages = Math.ceil(filteredReceipts.length / ITEMS_PER_PAGE); +const totalPages = Math.max( + 1, + Math.ceil(filteredReceipts.length / ITEMS_PER_PAGE), +); +if (currentPage > totalPages) { + setCurrentPage(totalPages); +}src/components/ecommerce/sales/blocks/client-payments-table.tsx (1)
174-183
: Clamp pagination after filtered data updatesJust like the receipts table, a refetch that reduces
filteredPayments.length
can leavecurrentPage
>totalPages
, yielding an empty UI despite available rows. Clamp or reset the page when totalPages drops.-const totalPages = Math.ceil(filteredPayments.length / ITEMS_PER_PAGE); +const totalPages = Math.max( + 1, + Math.ceil(filteredPayments.length / ITEMS_PER_PAGE), +); +if (currentPage > totalPages) { + setCurrentPage(totalPages); +}
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (10)
src/app/api/webhook/route.ts
(3 hunks)src/app/dashboard/receipts/page.tsx
(1 hunks)src/app/ecommerce/sales/page.tsx
(2 hunks)src/components/dashboard-navigation.tsx
(2 hunks)src/components/dashboard/receipts.tsx
(1 hunks)src/components/ecommerce/sales/blocks/client-payments-table.tsx
(1 hunks)src/components/ecommerce/sales/index.tsx
(1 hunks)src/lib/types/index.ts
(2 hunks)src/server/db/schema.ts
(5 hunks)src/server/routers/ecommerce.ts
(2 hunks)
async function addClientPayment(webhookBody: any) { | ||
await db.transaction(async (tx) => { | ||
const existingPayment = await tx | ||
.select() | ||
.from(clientPaymentTable) | ||
.where( | ||
and( | ||
eq(clientPaymentTable.txHash, webhookBody.txHash), | ||
eq(clientPaymentTable.requestId, webhookBody.requestId), | ||
), | ||
) | ||
.limit(1); | ||
|
||
if (existingPayment.length > 0) { | ||
console.warn( | ||
`Duplicate payment detected for txHash: ${webhookBody.txHash} and requestId: ${webhookBody.requestId}`, | ||
); | ||
return; | ||
} | ||
|
||
const ecommerceClient = await tx | ||
.select() | ||
.from(ecommerceClientTable) | ||
.where(eq(ecommerceClientTable.rnClientId, webhookBody.clientId)) | ||
.limit(1); | ||
|
||
if (!ecommerceClient.length) { | ||
throw new ResourceNotFoundError( | ||
`No ecommerce client found with client ID: ${webhookBody.clientId}`, | ||
); | ||
} | ||
|
||
const client = ecommerceClient[0]; | ||
|
||
await tx.insert(clientPaymentTable).values({ | ||
id: ulid(), | ||
userId: client.userId, | ||
ecommerceClientId: client.id, | ||
requestId: webhookBody.requestId, | ||
invoiceCurrency: webhookBody.currency, | ||
paymentCurrency: webhookBody.paymentCurrency, | ||
txHash: webhookBody.txHash, | ||
network: webhookBody.network, | ||
amount: webhookBody.amount, | ||
customerInfo: webhookBody.customerInfo || null, | ||
reference: webhookBody.reference || null, | ||
origin: webhookBody.origin, | ||
}); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Make duplicate protection atomic
The “select then insert” guard still lets two concurrent webhook deliveries race past the duplicate check and both insert, so we can double-count the same payment (Request webhooks routinely retry on network hiccups). We need the database to enforce idempotency. Please rely on an atomic insert with ON CONFLICT DO NOTHING
(or a unique constraint + error handling) instead of the manual pre-check.
- const existingPayment = await tx
- .select()
- .from(clientPaymentTable)
- .where(
- and(
- eq(clientPaymentTable.txHash, webhookBody.txHash),
- eq(clientPaymentTable.requestId, webhookBody.requestId),
- ),
- )
- .limit(1);
-
- if (existingPayment.length > 0) {
- console.warn(
- `Duplicate payment detected for txHash: ${webhookBody.txHash} and requestId: ${webhookBody.requestId}`,
- );
- return;
- }
-
- const ecommerceClient = await tx
+ const ecommerceClient = await tx
.select()
.from(ecommerceClientTable)
.where(eq(ecommerceClientTable.rnClientId, webhookBody.clientId))
.limit(1);
@@
- await tx.insert(clientPaymentTable).values({
- id: ulid(),
- userId: client.userId,
- ecommerceClientId: client.id,
- requestId: webhookBody.requestId,
- invoiceCurrency: webhookBody.currency,
- paymentCurrency: webhookBody.paymentCurrency,
- txHash: webhookBody.txHash,
- network: webhookBody.network,
- amount: webhookBody.amount,
- customerInfo: webhookBody.customerInfo || null,
- reference: webhookBody.reference || null,
- origin: webhookBody.origin,
- });
+ const inserted = await tx
+ .insert(clientPaymentTable)
+ .values({
+ id: ulid(),
+ userId: client.userId,
+ ecommerceClientId: client.id,
+ requestId: webhookBody.requestId,
+ invoiceCurrency: webhookBody.currency,
+ paymentCurrency: webhookBody.paymentCurrency,
+ txHash: webhookBody.txHash,
+ network: webhookBody.network,
+ amount: webhookBody.amount,
+ customerInfo: webhookBody.customerInfo || null,
+ reference: webhookBody.reference || null,
+ origin: webhookBody.origin,
+ })
+ .onConflictDoNothing({
+ target: [clientPaymentTable.txHash, clientPaymentTable.requestId],
+ })
+ .returning({ id: clientPaymentTable.id });
+
+ if (!inserted.length) {
+ console.warn(
+ `Duplicate payment detected for txHash: ${webhookBody.txHash} and requestId: ${webhookBody.requestId}`,
+ );
+ return;
+ }
{paginatedPayments.length === 0 ? ( | ||
<TableRow> | ||
<TableCell colSpan={9} className="p-0"> | ||
<EmptyState | ||
icon={<CreditCard className="h-6 w-6 text-zinc-600" />} | ||
title="No client payments" | ||
subtitle={ | ||
activeClientId | ||
? "No payments found for the selected client" | ||
: "No payments received yet" | ||
} | ||
/> | ||
</TableCell> | ||
</TableRow> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Match empty-state colSpan to the header
The header defines 10 columns, but the empty-state row spans only 9, leaving an extra blank column and misaligning borders. Make the colSpan 10.
- <TableCell colSpan={9} className="p-0">
+ <TableCell colSpan={10} className="p-0">
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
{paginatedPayments.length === 0 ? ( | |
<TableRow> | |
<TableCell colSpan={9} className="p-0"> | |
<EmptyState | |
icon={<CreditCard className="h-6 w-6 text-zinc-600" />} | |
title="No client payments" | |
subtitle={ | |
activeClientId | |
? "No payments found for the selected client" | |
: "No payments received yet" | |
} | |
/> | |
</TableCell> | |
</TableRow> | |
{paginatedPayments.length === 0 ? ( | |
<TableRow> | |
<TableCell colSpan={10} className="p-0"> | |
<EmptyState | |
icon={<CreditCard className="h-6 w-6 text-zinc-600" />} | |
title="No client payments" | |
subtitle={ | |
activeClientId | |
? "No payments found for the selected client" | |
: "No payments received yet" | |
} | |
/> | |
</TableCell> | |
</TableRow> |
🤖 Prompt for AI Agents
In src/components/ecommerce/sales/blocks/client-payments-table.tsx around lines
230 to 243, the EmptyState TableCell uses colSpan={9} but the table header
defines 10 columns; update the TableCell colSpan to 10 so the empty-state row
spans all header columns and fixes the border/alignment.
src/server/db/schema.ts
Outdated
export const clientPaymentTable = createTable("client_payment", { | ||
id: text().primaryKey().notNull(), | ||
userId: text() | ||
.notNull() | ||
.references(() => userTable.id, { | ||
onDelete: "cascade", | ||
}), | ||
requestId: text().notNull(), | ||
ecommerceClientId: text() | ||
.notNull() | ||
.references(() => ecommerceClientTable.id, { | ||
onDelete: "cascade", | ||
}), | ||
invoiceCurrency: text().notNull(), | ||
paymentCurrency: text().notNull(), | ||
txHash: text().notNull(), | ||
network: text().notNull(), | ||
amount: text().notNull(), | ||
customerInfo: json().$type<{ | ||
firstName?: string; | ||
lastName?: string; | ||
email?: string; | ||
address?: { | ||
street?: string; | ||
city?: string; | ||
state?: string; | ||
postalCode?: string; | ||
country?: string; | ||
}; | ||
}>(), | ||
reference: text(), | ||
origin: text(), | ||
createdAt: timestamp("created_at").defaultNow(), | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix foreign key reference order to avoid runtime ReferenceError
references(() => ecommerceClientTable.id)
is evaluated while defining clientPaymentTable
, but ecommerceClientTable
is still in the temporal dead zone. This throws ReferenceError: Cannot access 'ecommerceClientTable' before initialization
, blocking the app at startup. Move the entire clientPaymentTable
definition (and its relations export) below the ecommerceClientTable
declaration so the referenced table is initialized first.
-export const clientPaymentTable = createTable("client_payment", {
- ...
-});
-
export const ecommerceClientTable = createTable(
"ecommerce_client",
{
...
},
);
+export const clientPaymentTable = createTable("client_payment", {
+ ...
+});
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
export const clientPaymentTable = createTable("client_payment", { | |
id: text().primaryKey().notNull(), | |
userId: text() | |
.notNull() | |
.references(() => userTable.id, { | |
onDelete: "cascade", | |
}), | |
requestId: text().notNull(), | |
ecommerceClientId: text() | |
.notNull() | |
.references(() => ecommerceClientTable.id, { | |
onDelete: "cascade", | |
}), | |
invoiceCurrency: text().notNull(), | |
paymentCurrency: text().notNull(), | |
txHash: text().notNull(), | |
network: text().notNull(), | |
amount: text().notNull(), | |
customerInfo: json().$type<{ | |
firstName?: string; | |
lastName?: string; | |
email?: string; | |
address?: { | |
street?: string; | |
city?: string; | |
state?: string; | |
postalCode?: string; | |
country?: string; | |
}; | |
}>(), | |
reference: text(), | |
origin: text(), | |
createdAt: timestamp("created_at").defaultNow(), | |
}); | |
export const ecommerceClientTable = createTable( | |
"ecommerce_client", | |
{ | |
... | |
}, | |
); | |
export const clientPaymentTable = createTable("client_payment", { | |
id: text().primaryKey().notNull(), | |
userId: text() | |
.notNull() | |
.references(() => userTable.id, { | |
onDelete: "cascade", | |
}), | |
requestId: text().notNull(), | |
ecommerceClientId: text() | |
.notNull() | |
.references(() => ecommerceClientTable.id, { | |
onDelete: "cascade", | |
}), | |
invoiceCurrency: text().notNull(), | |
paymentCurrency: text().notNull(), | |
txHash: text().notNull(), | |
network: text().notNull(), | |
amount: text().notNull(), | |
customerInfo: json().$type<{ | |
firstName?: string; | |
lastName?: string; | |
email?: string; | |
address?: { | |
street?: string; | |
city?: string; | |
state?: string; | |
postalCode?: string; | |
country?: string; | |
}; | |
}>(), | |
reference: text(), | |
origin: text(), | |
createdAt: timestamp("created_at").defaultNow(), | |
}); |
🤖 Prompt for AI Agents
In src/server/db/schema.ts around lines 180 to 213, the clientPaymentTable is
defined before ecommerceClientTable which causes a ReferenceError when its
foreign key reference is evaluated; move the entire clientPaymentTable
definition (and any export or relations that reference it) to a position after
the ecommerceClientTable declaration so the referenced table is initialized
first, and ensure exports that expose relations are adjusted accordingly to
preserve load order.
Changes
clientPayment
entity that stores all payments made with a client idTesting
It's going to be the easiest to test this alongside this PR running on
localhost:3001
and having easy invoice on 3000.Resolves #152