Skip to content
Draft
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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,12 @@ invoices to `EXPIRED` before get, list, stats, verify, cancel, or monitor work.
Expired invoices remain visible in seller history, but they are excluded from
pending/actionable counts and cannot expose QR, pay, verify, or payment-proof
controls. The client also projects stale pending data through `expiresAt` so a
page fails closed while it waits for the next authoritative server response.
### Multi-Asset (XLM & USDC) Support

Quittance supports multi-asset invoicing across native XLM and credit assets such as USDC on Stellar:
- **Native XLM**: No issuer required, verified directly with native payment operations.
- **Credit Assets (e.g. USDC)**: Verified with `asset_type`, `asset_code`, and pinned `asset_issuer`.
- **Trustline UX**: The pay flow inspects trustline status and provides actionable guidance (`op_no_trust` handling) if the buyer wallet needs to add a trustline.

---

Expand Down
5 changes: 5 additions & 0 deletions backend/src/config/stellar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ export const NETWORK_PASSPHRASE =
? StellarSdk.Networks.TESTNET
: StellarSdk.Networks.PUBLIC;

export const STELLAR_EXPLORER_BASE =
STELLAR_NETWORK === 'TESTNET'
? 'https://stellar.expert/explorer/testnet'
: 'https://stellar.expert/explorer/public';

/**
* The SDK refuses a plaintext Horizon URL unless `allowHttp` is set.
*
Expand Down
2 changes: 1 addition & 1 deletion backend/src/routes/invoice.handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export function createInvoiceHandlers(options: InvoiceHandlerOptions): InvoiceHa
stellarQrCode: await generateStellarPaymentQR(
invoice.sellerPublicKey,
invoice.amount.toString(),
invoice.assetCode,
invoice.assetCode || 'XLM',
invoice.memo,
invoice.assetIssuer
),
Expand Down
2 changes: 1 addition & 1 deletion backend/src/services/invoice-memory.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export class InvoiceMemoryService {
sellerName: input.sellerName,
sellerEmail: input.sellerEmail,
amount: input.amount,
assetCode: input.assetCode || 'XLM',
assetCode: (input.assetCode || 'XLM').toUpperCase(),
assetIssuer: input.assetIssuer,
memo,
description: input.description,
Expand Down
2 changes: 1 addition & 1 deletion backend/src/services/invoice.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export class InvoiceService {
input.sellerName || null,
input.sellerEmail || null,
input.amount,
input.assetCode || 'XLM',
(input.assetCode || 'XLM').toUpperCase(),
input.assetIssuer || null,
memo,
input.description || null,
Expand Down
2 changes: 2 additions & 0 deletions backend/src/storage/invoice-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ export interface StoredInvoice {
sellerName?: string;
sellerEmail?: string;
amount: number;
/** Asset code for the invoice (e.g., 'XLM' or 'USDC'). */
assetCode: string;
/** Issuer public key for credit assets (omitted for native XLM). */
assetIssuer?: string;
memo: string;
description?: string;
Expand Down
2 changes: 1 addition & 1 deletion backend/src/storage/memory-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class MemoryStorage {
sellerName: data.sellerName,
sellerEmail: data.sellerEmail,
amount: data.amount!,
assetCode: data.assetCode || 'XLM',
assetCode: (data.assetCode || 'XLM').toUpperCase(),
assetIssuer: data.assetIssuer,
memo: data.memo!,
description: data.description,
Expand Down
2 changes: 1 addition & 1 deletion backend/src/utils/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export const stellarPublicKeySchema = z.string()
export const createInvoiceSchema = z
.object({
amount: z.number().positive().max(1000000000),
assetCode: z.string().default('XLM').optional(),
assetCode: z.string().default('XLM').transform((val) => val.toUpperCase()).optional(),
assetIssuer: stellarPublicKeySchema.optional(),
description: z.string().max(500).optional(),
customerName: z.string().max(255).optional(),
Expand Down
10 changes: 10 additions & 0 deletions backend/tests/invoice-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,16 @@ function runSharedBackendSuite(name: string, createStorage: () => InvoiceStorage
assert.equal(res.body.data.paymentAvailable, true);
});

it('normalizes lowercase assetCode to uppercase on creation', async () => {
const res = await call(
handlers().createInvoice,
createReq({ body: invoiceBody({ assetCode: 'xlm' }) })
);

assert.equal(res.statusCode, 201);
assert.equal(res.body.data.invoice.assetCode, 'XLM');
});

it('accepts seller-selected expiry only within the 1-30 day contract', async () => {
const invoice = await createInvoice({ expiresInDays: 30 });
const lifetime = new Date(invoice.expiresAt).getTime() - new Date(invoice.createdAt).getTime();
Expand Down
17 changes: 17 additions & 0 deletions backend/tests/invoice-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,4 +356,21 @@ describe('InvoiceService (Postgres) seller scoping', () => {
/Invoice not found or already processed/
);
});

it('supports multi-asset invoice creation with USDC and assetIssuer', async () => {
const db = new FakeInvoiceDb();
const service = new InvoiceService(db);
const created = await service.createInvoice(
input(SELLER_A, {
amount: 100,
assetCode: 'USDC',
assetIssuer: USDC_ISSUER,
})
);

assert.equal(created.assetCode, 'USDC');
assert.equal(created.assetIssuer, USDC_ISSUER);
assert.equal(db.rows[0].asset_code, 'USDC');
assert.equal(db.rows[0].asset_issuer, USDC_ISSUER);
});
});
24 changes: 24 additions & 0 deletions backend/tests/payment-verification.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,30 @@ describe('verifyHorizonPayment — rejections', () => {
assert.equal(codeOf(result), 'ASSET_MISMATCH');
});

it('settles a valid USDC credit payment with matching issuer and amount', () => {
const result = verifyHorizonPayment(
input({
expected: expected({ assetCode: 'USDC', assetIssuer: USDC_ISSUER, amount: 50 }),
operations: [
paymentOp({
asset_type: 'credit_alphanum4',
asset_code: 'USDC',
asset_issuer: USDC_ISSUER,
amount: '50.0000000',
}),
],
}),
);

assert.equal(result.ok, true);
if (result.ok) {
assert.equal(result.value.from, PAYER);
assert.equal(result.value.amount, '50.0000000');
assert.equal(result.value.assetCode, 'USDC');
assert.equal(result.value.assetIssuer, USDC_ISSUER);
}
});

it('rejects a transaction with no payment operation', () => {
assert.equal(
codeOf(verifyHorizonPayment(input({ operations: [{ type: 'create_account' }] }))),
Expand Down
1 change: 1 addition & 0 deletions db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ ALTER TABLE invoices ALTER COLUMN expires_at SET NOT NULL;
-- Indexes for performance
CREATE INDEX IF NOT EXISTS idx_invoices_seller ON invoices(seller_public_key);
CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status);
CREATE INDEX IF NOT EXISTS idx_invoices_asset_code ON invoices(asset_code);
CREATE INDEX IF NOT EXISTS idx_invoices_memo ON invoices(memo);
CREATE INDEX IF NOT EXISTS idx_invoices_created_at ON invoices(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_invoices_seller_created_at ON invoices(seller_public_key, created_at DESC);
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ export default function DashboardPage() {
))}
</div>
) : (
<p className="text-2xl font-bold text-gray-900">0.00</p>
<p className="text-2xl font-bold text-gray-900">0.00 <span className="text-sm font-normal text-gray-500">XLM</span></p>
)}
</div>
</div>
Expand Down
8 changes: 5 additions & 3 deletions frontend/app/invoice/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import PaymentStatus from '@/components/PaymentStatus';
import WalletConnect from '@/components/WalletConnect';
import UserProfile from '@/components/UserProfile';
import PaymentReceipt from '@/components/PaymentReceipt';
import AssetLogo from '@/components/AssetLogo';
import { formatAmount, formatDate, getTimeRemaining } from '@/lib/utils';
import { MAIN_CONTENT_ID, describeAmount, statusText } from '@/lib/a11y';
import { ArrowLeft, Share2, Loader2, X } from 'lucide-react';
Expand Down Expand Up @@ -251,12 +252,13 @@ export default function InvoiceDetailPage() {
reader equivalent, rather than an `aria-label` on the
<dd> — ARIA prohibits naming a <dd>, and axe reports it.
*/}
<dd className="text-4xl sm:text-5xl font-bold bg-gradient-to-r from-cyan-700 to-blue-700 bg-clip-text text-transparent">
<dd className="flex items-center gap-3 text-4xl sm:text-5xl font-bold bg-gradient-to-r from-cyan-700 to-blue-700 bg-clip-text text-transparent">
<AssetLogo code={invoice.assetCode || 'XLM'} size={32} showName={false} decorative />
<span aria-hidden="true">
{formatAmount(invoice.amount, 7)} <span className="text-2xl">{invoice.assetCode}</span>
{formatAmount(invoice.amount, 7)} <span className="text-2xl">{invoice.assetCode || 'XLM'}</span>
</span>
<span className="sr-only">
{describeAmount(formatAmount(invoice.amount, 7), invoice.assetCode)}
{describeAmount(formatAmount(invoice.amount, 7), invoice.assetCode || 'XLM')}
</span>
</dd>
</div>
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export default function HomePage() {
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.15, ease: [0.22, 1, 0.36, 1] }}
>
Invoice on Stellar. Get paid. Keep the proof.
Invoice on Stellar in XLM or USDC. Get paid. Keep the proof.
</motion.p>

<motion.div
Expand Down
9 changes: 9 additions & 0 deletions frontend/app/pay/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,15 @@ export default function PaymentPage() {
<PaymentResultPanel state={page.payment} />
</div>

{invoice.assetCode && invoice.assetCode !== 'XLM' && view.showPaymentControls && (
<div className="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-blue-900 flex items-start gap-3">
<div>
<span className="font-semibold block mb-0.5">{invoice.assetCode} Trustline Notice</span>
<span>Please ensure your Stellar wallet has established a trustline for {invoice.assetCode} before submitting payment.</span>
</div>
</div>
)}

<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 sm:gap-8">
<div className="space-y-6">
<PayAmountBlock invoice={invoice} />
Expand Down
9 changes: 7 additions & 2 deletions frontend/components/AssetLogo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,15 @@ export default function AssetLogo({
className = '',
decorative = false,
}: AssetLogoProps) {
const asset = getAssetByCode(code);
const normalizedCode = code ? code.toUpperCase() : 'XLM';
const asset = getAssetByCode(normalizedCode);

if (!asset) {
return <span className={className}>{code}</span>;
return (
<span className={className} {...(decorative ? { 'aria-hidden': true } : {})}>
{normalizedCode}
</span>
);
}

return (
Expand Down
4 changes: 2 additions & 2 deletions frontend/components/InvoiceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,9 @@ export default function InvoiceCard({ invoice }: InvoiceCardProps) {
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
{/* The heading already names the asset — the logo would repeat it. */}
<AssetLogo code={invoice.assetCode} size={24} showName={false} decorative />
<AssetLogo code={invoice.assetCode || 'XLM'} size={24} showName={false} decorative />
<h3 id={headingId} className="text-lg font-bold text-gray-900">
{formatAmount(invoice.amount)} <span className="text-cyan-700">{invoice.assetCode}</span>
{formatAmount(invoice.amount)} <span className="text-cyan-700">{invoice.assetCode || 'XLM'}</span>
<span className="sr-only"> invoice</span>
</h3>
</div>
Expand Down
4 changes: 3 additions & 1 deletion frontend/components/InvoiceForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,9 @@ export default function InvoiceForm({ onSuccess, userWallet }: InvoiceFormProps)
</div>
</div>
<p id="invoice-amount-hint" className="field-hint">
The amount your client pays, in the selected asset.
{assetCode === 'USDC'
? 'The amount your client pays in USDC (requires a USDC trustline on Stellar).'
: 'The amount your client pays, in the selected asset.'}
</p>
</div>

Expand Down
9 changes: 7 additions & 2 deletions frontend/components/PaymentButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,11 +111,16 @@ export default function PaymentButton({
onSuccess?.(txHash);
} catch (error: any) {
const missingTrustline =
assetCode !== 'XLM' && error.message?.toLowerCase().includes('trustline');
assetCode !== 'XLM' && (
error.message?.toLowerCase().includes('trustline') ||
error.message?.toLowerCase().includes('op_no_trust')
);
const title = missingTrustline ? `${assetCode} trustline required` : 'Payment failed';
toast.error(title, {
id: PAY_TOAST_ID,
description: error.message || 'Try again',
description: missingTrustline
? `Please add a trustline for ${assetCode} in your wallet before paying.`
: (error.message || 'Try again'),
duration: missingTrustline ? 10000 : undefined,
});
onError?.(title);
Expand Down
3 changes: 2 additions & 1 deletion frontend/components/PaymentReceipt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ Stellar Blockchain Payment System
URL.revokeObjectURL(url);
};

const amountLabel = describeAmount(formatAmount(invoice.amount, 7), invoice.assetCode);
const activeAssetCode = invoice.assetCode || 'XLM';
const amountLabel = describeAmount(formatAmount(invoice.amount, 7), activeAssetCode);
const canEmail = Boolean(invoice.customerEmail);
const emailReasonId = 'receipt-email-reason';

Expand Down
1 change: 1 addition & 0 deletions frontend/components/PaymentStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { statusText } from '@/lib/a11y';
interface PaymentStatusProps {
status: 'PENDING' | 'PAID' | 'EXPIRED' | 'CANCELLED';
txHash?: string;
assetCode?: string;
/** Removes the large card/icon treatment when embedded in payment details. */
compact?: boolean;
}
Expand Down
6 changes: 5 additions & 1 deletion frontend/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,11 @@ export const invoiceApi = USE_MOCK_API ? mockInvoiceApi : {
sellerName?: string;
sellerEmail?: string;
}) => {
const response = await api.post('/invoices', data);
const normalizedAssetCode = data.assetCode ? data.assetCode.toUpperCase() : 'XLM';
const response = await api.post('/invoices', {
...data,
assetCode: normalizedAssetCode,
});
return response.data;
},

Expand Down
13 changes: 12 additions & 1 deletion frontend/lib/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,18 @@ export const STELLAR_ASSETS: StellarAsset[] = [

// Get asset by code
export const getAssetByCode = (code: string): StellarAsset | undefined => {
return STELLAR_ASSETS.find(asset => asset.code === code);
return STELLAR_ASSETS.find(asset => asset.code.toUpperCase() === code.toUpperCase());
};

// Check if asset is native XLM
export const isNativeAsset = (code: string): boolean => {
return code.toUpperCase() === 'XLM';
};

// Get asset issuer address if applicable
export const getAssetIssuer = (code: string): string | undefined => {
const asset = getAssetByCode(code);
return asset?.issuer;
};

// Format asset display name
Expand Down
10 changes: 5 additions & 5 deletions frontend/lib/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,14 +332,14 @@ export function generateInvoicePDF(invoice: Invoice): string {
</div>

<div style="position: fixed; top: 10px; right: 10px; background: #06b6d4; color: white; padding: 15px; border-radius: 8px; z-index: 1000; max-width: 300px; font-family: Arial, sans-serif;">
<h3 style="margin: 0 0 10px 0; font-size: 14px;">PDF olarak kaydetmek için:</h3>
<h3 style="margin: 0 0 10px 0; font-size: 14px;">To save as PDF:</h3>
<ol style="margin: 0; padding-left: 20px; font-size: 12px;">
<li>Ctrl+P (Windows) veya Cmd+P (Mac)</li>
<li>"Hedef" → "PDF olarak kaydet"</li>
<li>"Yazdır" butonuna bas</li>
<li>Ctrl+P (Windows) or Cmd+P (Mac)</li>
<li>"Destination" → "Save as PDF"</li>
<li>Click "Print"</li>
</ol>
<button onclick="window.print()" style="background: white; color: #06b6d4; border: none; padding: 8px 16px; border-radius: 4px; margin-top: 10px; cursor: pointer; font-weight: bold; font-size: 12px;">
PDF Olarak Kaydet
Save as PDF
</button>
</div>

Expand Down
12 changes: 7 additions & 5 deletions frontend/lib/stellar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,18 +163,20 @@ export const sendPayment = async (
throw error;
}

const normalizedAssetCode = (assetCode || 'XLM').toUpperCase();

// Create asset
const asset =
assetCode === 'XLM'
normalizedAssetCode === 'XLM'
? StellarSdk.Asset.native()
: new StellarSdk.Asset(assetCode, assetIssuer!);
: new StellarSdk.Asset(normalizedAssetCode, assetIssuer!);

if (
assetCode !== 'XLM' &&
normalizedAssetCode !== 'XLM' &&
assetIssuer &&
!hasAssetTrustline(account, assetCode, assetIssuer)
!hasAssetTrustline(account, normalizedAssetCode, assetIssuer)
) {
throw new Error(getTrustlineMessage(assetCode));
throw new Error(getTrustlineMessage(normalizedAssetCode));
}

// Build transaction
Expand Down
3 changes: 2 additions & 1 deletion frontend/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,8 @@ export function isValidEmail(email: string): boolean {
* Format currency
*/
export function formatCurrency(amount: number, currency: string = 'XLM'): string {
return `${formatAmount(amount, 7)} ${currency}`;
const code = (currency || 'XLM').toUpperCase();
return `${formatAmount(amount, 7)} ${code}`;
}

export default {
Expand Down
4 changes: 3 additions & 1 deletion frontend/tests/fixtures/card-expiry-summary.fixture.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export const cardExpirySummaryFixture = [
const cardExpirySummaryFixture = [
{ input: '2000-01-01T00:00:00.000Z', expected: 'Expired' },
];

module.exports = { cardExpirySummaryFixture };
4 changes: 3 additions & 1 deletion frontend/tests/fixtures/dashboard-empty-copy.fixture.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export const dashboardEmptyCopyFixture = [
const dashboardEmptyCopyFixture = [
{ walletConnected: true, output: 'Create your first invoice to get started.' },
{ walletConnected: false, output: 'Connect your wallet to see and create invoices.' },
];

module.exports = { dashboardEmptyCopyFixture };
Loading