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
15 changes: 2 additions & 13 deletions .claude/launch.json
Original file line number Diff line number Diff line change
@@ -1,18 +1,7 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "www-dev",
"runtimeExecutable": "/Users/0xdevcollins/.nvm/versions/node/v24.12.0/bin/npm",
"runtimeArgs": ["run", "dev", "--workspace=www"],
"port": 3000
},
{
"name": "dashboard-dev",
"runtimeExecutable": "/Users/0xdevcollins/.nvm/versions/node/v24.12.0/bin/npm",
"runtimeArgs": ["run", "dev", "--workspace=dashboard"],
"port": 3001
}
{ "name": "www", "runtimeExecutable": "npm", "runtimeArgs": ["run", "dev", "--workspace=www", "--", "-p", "3005"], "port": 3005 },
{ "name": "dashboard", "runtimeExecutable": "npm", "runtimeArgs": ["run", "dev", "--workspace=dashboard", "--", "-p", "3001"], "port": 3001 }
]
}

3 changes: 0 additions & 3 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,6 @@ CCTP_USE_FORWARDING="true"
MONEYGRAM_HOME_DOMAIN="extstellar.moneygram.com" # testnet
# MONEYGRAM_HOME_DOMAIN="stellar.moneygram.com" # mainnet

# ── Stripe (card payments) ───────────────────────────────────────────────
STRIPE_SECRET_KEY="sk_test_..."
STRIPE_WEBHOOK_SECRET="whsec_..."

# ── Email (Resend) ───────────────────────────────────────────────────────
RESEND_API_KEY="re_..."
Expand Down
22 changes: 22 additions & 0 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -306,13 +306,35 @@ model Payout {
failureReason String?
batchId String?
idempotencyKey String? @unique
recipientId String?
createdAt DateTime @default(now())
merchant Merchant @relation(fields: [merchantId], references: [id])
recipient Recipient? @relation(fields: [recipientId], references: [id])

@@index([merchantId])
@@index([status])
@@index([batchId])
@@index([createdAt])
@@index([recipientId])
}

// Saved payout destination. Reconstructed to match migration
// 20260625193826_update after a merge dropped the model definition.
model Recipient {
id String @id @default(cuid())
merchantId String
name String
type DestType
details Json
isDefault Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
merchant Merchant @relation(fields: [merchantId], references: [id], onDelete: Cascade)
payouts Payout[]

@@unique([merchantId, name])
@@index([merchantId])
@@index([type])
}

enum DestType {
Expand Down
3 changes: 1 addition & 2 deletions apps/api/src/common/interceptors/idempotency.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { tap } from 'rxjs/operators';
const HEADER_NAME = 'idempotency-key';
const HEADER_MAX_LENGTH = 255;

/** How long a successful response is replayable. Matches Stripe's window. */
/** How long a successful response is replayable. */
const CACHE_TTL_SECONDS = 60 * 60 * 24; // 24h

/** Shape we persist per (owner, route, key). */
Expand Down Expand Up @@ -51,7 +51,6 @@ interface AuthedRequest extends Request {
* - On a hit, replays the original status + body. Cache window is 24h.
* - On a hit with a DIFFERENT request body, throws 422 — the integrator
* almost certainly has a bug (reusing a key with different params).
* Stripe behaves the same way.
* - Cache writes are fire-and-forget; a Redis blip should never fail the
* request itself, just degrade us to non-idempotent for the next retry.
*/
Expand Down
24 changes: 16 additions & 8 deletions apps/api/src/modules/payments/payments.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,7 +384,9 @@ export class PaymentsService implements OnModuleInit {
shortCode: string,
opts: { amount?: number } = {},
): Promise<{ id: string }> {
this.logger.log(`Creating link-initiated payment for shortCode=${shortCode}`);
this.logger.log(
`Creating link-initiated payment for shortCode=${shortCode}`,
);

// `resolve` enforces 404 (no link), 410 (inactive/expired/exhausted)
// and increments viewCount as a side-effect. That side-effect is
Expand Down Expand Up @@ -447,10 +449,12 @@ export class PaymentsService implements OnModuleInit {
try {
await this.linksService.markUsed(internal.id, payment.id);
} catch (err) {
await this.prisma.payment.delete({ where: { id: payment.id } }).catch(() => {
// Best-effort cleanup — the payment is meaningless without the link.
// Don't mask the underlying conflict by throwing the cleanup error.
});
await this.prisma.payment
.delete({ where: { id: payment.id } })
.catch(() => {
// Best-effort cleanup — the payment is meaningless without the link.
// Don't mask the underlying conflict by throwing the cleanup error.
});
throw err;
}

Expand Down Expand Up @@ -1129,8 +1133,12 @@ export class PaymentsService implements OnModuleInit {
payload: {
paymentId: payment.id,
merchantId: payment.merchantId,
amount: payment.sourceAmount ? this.toNumber(payment.sourceAmount) : 0,
currency: this.getCardCurrency(payment.sourceAsset ?? '').toUpperCase(),
amount: payment.sourceAmount
? this.toNumber(payment.sourceAmount)
: 0,
currency: this.getCardCurrency(
payment.sourceAsset ?? '',
).toUpperCase(),
provider: 'stripe',
stripePaymentIntentId: paymentIntent.id,
settlementStatus: 'queued',
Expand Down Expand Up @@ -1214,7 +1222,7 @@ export class PaymentsService implements OnModuleInit {
};
}

// Same auto-fill pattern as createCardSession: link-initiated payments
// Auto-fill pattern: link-initiated payments
// arrive here without source fields. The bank-transfer choice implies
// a USD-denominated transfer sized to the destination amount.
if (!payment.sourceAmount || !payment.sourceAsset) {
Expand Down
58 changes: 35 additions & 23 deletions apps/api/src/modules/payouts/payouts.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,30 +334,42 @@ async createBulk(merchantId: string, dto: BulkPayoutDto): Promise<BulkPayoutResu
const sourceAsset = 'native';
const sourceAmount = payout.amount.toString();

const { paths } = await this.stellar.findStrictSendPaths({
sourceAsset,
sourceAmount,
destinationAssets: [destAsset],
destinationAccount: destAddress,
});
let txHash: string;

if (sourceAsset === destAsset) {
// Same asset (e.g. XLM → XLM) — no conversion needed. A strict-send path
// query would be rejected by Horizon, so send a direct payment instead.
txHash = await this.stellar.sendPayment({
asset: sourceAsset,
amount: sourceAmount,
destinationAccount: destAddress,
});
} else {
const { paths } = await this.stellar.findStrictSendPaths({
sourceAsset,
sourceAmount,
destinationAssets: [destAsset],
destinationAccount: destAddress,
});

const bestPath = paths[0];
// Convert asset objects to the 'native' | 'CODE:issuer' strings parseAsset expects
const pathStrings: string[] = (bestPath?.path ?? []).map((a) =>
assetToString(a as AssetObject),
);
const destMin = (
parseFloat(bestPath?.destinationAmount ?? sourceAmount) * 0.99
).toFixed(7);

const txHash = await this.stellar.executePathPayment({
sourceAsset,
sourceAmount,
destinationAccount: destAddress,
destinationAsset: destAsset,
destinationMinAmount: destMin,
path: pathStrings,
});
const bestPath = paths[0];
// Convert asset objects to the 'native' | 'CODE:issuer' strings parseAsset expects
const pathStrings: string[] = (bestPath?.path ?? []).map((a) =>
assetToString(a as AssetObject),
);
const destMin = (
parseFloat(bestPath?.destinationAmount ?? sourceAmount) * 0.99
).toFixed(7);

txHash = await this.stellar.executePathPayment({
sourceAsset,
sourceAmount,
destinationAccount: destAddress,
destinationAsset: destAsset,
destinationMinAmount: destMin,
path: pathStrings,
});
}

const completed = await this.prisma.payout.update({
where: { id: payout.id },
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/modules/recipients/recipients.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
import { CurrentMerchant } from '../merchant/decorators/current-merchant.decorator';

@Controller('v1/recipients')
@Controller('recipients')
@UseGuards(CombinedAuthGuard)
export class RecipientsController {
constructor(private readonly recipientsService: RecipientsService) {}
Expand Down
35 changes: 35 additions & 0 deletions apps/api/src/modules/stellar/stellar.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,41 @@ export class StellarService {
return result.hash;
}

/**
* Direct payment for same-asset transfers (e.g. XLM → XLM). A path payment
* requires a conversion route through the DEX; Horizon rejects a strict-send
* path query when the source and destination asset are identical, so those
* transfers must use a plain payment operation instead.
*/
async sendPayment(params: {
asset: string;
amount: string;
destinationAccount: string;
}): Promise<string> {
this.logger.log('Executing Stellar direct payment');

const keypair = this.requireKeypair();
const account = await this.horizonServer.loadAccount(keypair.publicKey());

const tx = new StellarSdk.TransactionBuilder(account, {
fee: StellarSdk.BASE_FEE,
networkPassphrase: this.networkPassphrase,
})
.addOperation(
StellarSdk.Operation.payment({
destination: params.destinationAccount,
asset: this.parseAsset(params.asset),
amount: params.amount,
}),
)
.setTimeout(30)
.build();

tx.sign(keypair);
const result = await this.horizonServer.submitTransaction(tx);
return result.hash;
}

// ── Fee collector ──────────────────────────────────────────────────────────
//
// Soroban fee-collector deducts the platform fee from the gross amount and
Expand Down
25 changes: 14 additions & 11 deletions apps/dashboard/src/app/(dashboard)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 { NotificationsProvider } from "@/providers/NotificationsProvider";
import { useRefundEvents } from "@/hooks/useRefundEvents";

export default function DashboardLayout({
Expand Down Expand Up @@ -37,16 +38,18 @@ export default function DashboardLayout({
}

return (
<div className="[--header-height:calc(--spacing(14))]">
<SidebarProvider className="flex flex-col">
<SiteHeader />
<div className="flex flex-1">
<AppSidebar variant="sidebar" collapsible="none" />
<SidebarInset>
<div className="flex-1 p-6">{children}</div>
</SidebarInset>
</div>
</SidebarProvider>
</div>
<NotificationsProvider>
<div className="[--header-height:calc(--spacing(14))]">
<SidebarProvider className="flex flex-col">
<SiteHeader />
<div className="flex flex-1">
<AppSidebar variant="sidebar" collapsible="none" />
<SidebarInset>
<div className="flex-1 p-6">{children}</div>
</SidebarInset>
</div>
</SidebarProvider>
</div>
</NotificationsProvider>
);
}
1 change: 1 addition & 0 deletions apps/dashboard/src/app/(dashboard)/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
Wallet,
CheckCircle2,
AlertCircle,
Radio,
} from "lucide-react";

const fadeUp = {
Expand Down
32 changes: 25 additions & 7 deletions apps/dashboard/src/app/(dashboard)/settings/recipients/page.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,47 @@
"use client";

import { useQuery } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "@useroutr/ui";
import { Plus, Search } from "lucide-react";
import { api } from "@/lib/api";
import { Recipient } from "@useroutr/types";
import { RecipientsTable } from "@/components/recipients/RecipientsTable";
import { CreateRecipientDialog } from "@/components/recipients/CreateRecipientDialog";

interface RecipientsListResponse {
data: Recipient[];
total: number;
}

export default function RecipientsPage() {
const [createOpen, setCreateOpen] = useState(false);
const queryClient = useQueryClient();
const recipientsQuery = useQuery({
queryKey: ["recipients"],
queryFn: async () => {
const res = await fetch("/api/v1/recipients");
if (!res.ok) throw new Error("Failed to fetch recipients");
return res.json();
},
queryFn: () => api.get<RecipientsListResponse>("/recipients"),
});

// Dialogs dispatch this after create/edit/delete so the list stays fresh.
useEffect(() => {
const refetch = () =>
queryClient.invalidateQueries({ queryKey: ["recipients"] });
window.addEventListener("recipients:refetch", refetch);
return () => window.removeEventListener("recipients:refetch", refetch);
}, [queryClient]);

return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="font-display text-xl font-semibold text-foreground">
Recipients
</h2>
<div className="flex items-center gap-2">
<CreateRecipientDialog />
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Add recipient
</Button>
<CreateRecipientDialog open={createOpen} onOpenChange={setCreateOpen} />
<Button variant="outline" size="sm">
<Search className="h-4 w-4 mr-2" />
Filter
Expand Down
21 changes: 16 additions & 5 deletions apps/dashboard/src/components/payouts/CreatePayoutDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
"use client";

import { useState } from 'react';
import { Button } from '@useroutr/ui';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
ShadSelect as Select,
ShadSelectContent as SelectContent,
ShadSelectItem as SelectItem,
ShadSelectTrigger as SelectTrigger,
ShadSelectValue as SelectValue,
} from '@useroutr/ui';
import { RecipientSelect } from '@/components/recipients/RecipientSelect';

export function CreatePayoutDialog() {
Expand Down
11 changes: 8 additions & 3 deletions apps/dashboard/src/components/payouts/PayoutFilterBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,14 @@ export function PayoutFilterBar({
</div>
<div className="w-64">
<DateRangePicker
value={selectedDateRange}
onValueChange={onDateRangeChange}
placeholder="Date range"
startDate={selectedDateRange?.from ? selectedDateRange.from.toISOString().slice(0, 10) : undefined}
endDate={selectedDateRange?.to ? selectedDateRange.to.toISOString().slice(0, 10) : undefined}
onStartChange={(d) =>
onDateRangeChange?.({ from: d ? new Date(d) : undefined, to: selectedDateRange?.to })
}
onEndChange={(d) =>
onDateRangeChange?.({ from: selectedDateRange?.from, to: d ? new Date(d) : undefined })
}
/>
</div>
{onBatchIdChange && (
Expand Down
Loading
Loading