diff --git a/README.md b/README.md index ef9b88a..f69e29a 100644 --- a/README.md +++ b/README.md @@ -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. --- diff --git a/backend/src/config/stellar.ts b/backend/src/config/stellar.ts index a82beb1..0b6e8e9 100644 --- a/backend/src/config/stellar.ts +++ b/backend/src/config/stellar.ts @@ -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. * diff --git a/backend/src/routes/invoice.handlers.ts b/backend/src/routes/invoice.handlers.ts index 83eca93..f24b31f 100644 --- a/backend/src/routes/invoice.handlers.ts +++ b/backend/src/routes/invoice.handlers.ts @@ -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 ), diff --git a/backend/src/services/invoice-memory.service.ts b/backend/src/services/invoice-memory.service.ts index 1b27bb8..a7537ae 100644 --- a/backend/src/services/invoice-memory.service.ts +++ b/backend/src/services/invoice-memory.service.ts @@ -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, diff --git a/backend/src/services/invoice.service.ts b/backend/src/services/invoice.service.ts index 988c7f2..f747585 100644 --- a/backend/src/services/invoice.service.ts +++ b/backend/src/services/invoice.service.ts @@ -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, diff --git a/backend/src/storage/invoice-storage.ts b/backend/src/storage/invoice-storage.ts index e104b54..580d475 100644 --- a/backend/src/storage/invoice-storage.ts +++ b/backend/src/storage/invoice-storage.ts @@ -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; diff --git a/backend/src/storage/memory-storage.ts b/backend/src/storage/memory-storage.ts index 3ade755..4380e94 100644 --- a/backend/src/storage/memory-storage.ts +++ b/backend/src/storage/memory-storage.ts @@ -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, diff --git a/backend/src/utils/validation.ts b/backend/src/utils/validation.ts index ac15b23..d911637 100644 --- a/backend/src/utils/validation.ts +++ b/backend/src/utils/validation.ts @@ -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(), diff --git a/backend/tests/invoice-handlers.test.ts b/backend/tests/invoice-handlers.test.ts index 19c8c51..4aa1a46 100644 --- a/backend/tests/invoice-handlers.test.ts +++ b/backend/tests/invoice-handlers.test.ts @@ -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(); diff --git a/backend/tests/invoice-service.test.ts b/backend/tests/invoice-service.test.ts index df2c079..71a0dc3 100644 --- a/backend/tests/invoice-service.test.ts +++ b/backend/tests/invoice-service.test.ts @@ -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); + }); }); diff --git a/backend/tests/payment-verification.test.ts b/backend/tests/payment-verification.test.ts index e2468e9..58cf019 100644 --- a/backend/tests/payment-verification.test.ts +++ b/backend/tests/payment-verification.test.ts @@ -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' }] }))), diff --git a/db/schema.sql b/db/schema.sql index 5f5f026..d8c7d71 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -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); diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index e45b577..851b1a4 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/dashboard/page.tsx @@ -254,7 +254,7 @@ export default function DashboardPage() { ))} ) : ( -

0.00

+

0.00 XLM

)} diff --git a/frontend/app/invoice/[id]/page.tsx b/frontend/app/invoice/[id]/page.tsx index 49bfe23..59ae405 100644 --- a/frontend/app/invoice/[id]/page.tsx +++ b/frontend/app/invoice/[id]/page.tsx @@ -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'; @@ -251,12 +252,13 @@ export default function InvoiceDetailPage() { reader equivalent, rather than an `aria-label` on the
— ARIA prohibits naming a
, and axe reports it. */} -
+
+ - {describeAmount(formatAmount(invoice.amount, 7), invoice.assetCode)} + {describeAmount(formatAmount(invoice.amount, 7), invoice.assetCode || 'XLM')}
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 0e274cf..eefb875 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -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. + {invoice.assetCode && invoice.assetCode !== 'XLM' && view.showPaymentControls && ( +
+
+ {invoice.assetCode} Trustline Notice + Please ensure your Stellar wallet has established a trustline for {invoice.assetCode} before submitting payment. +
+
+ )} +
diff --git a/frontend/components/AssetLogo.tsx b/frontend/components/AssetLogo.tsx index 9209c47..3a81c4c 100644 --- a/frontend/components/AssetLogo.tsx +++ b/frontend/components/AssetLogo.tsx @@ -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 {code}; + return ( + + {normalizedCode} + + ); } return ( diff --git a/frontend/components/InvoiceCard.tsx b/frontend/components/InvoiceCard.tsx index 8312631..518bf05 100644 --- a/frontend/components/InvoiceCard.tsx +++ b/frontend/components/InvoiceCard.tsx @@ -80,9 +80,9 @@ export default function InvoiceCard({ invoice }: InvoiceCardProps) {
{/* The heading already names the asset — the logo would repeat it. */} - +

- {formatAmount(invoice.amount)} {invoice.assetCode} + {formatAmount(invoice.amount)} {invoice.assetCode || 'XLM'} invoice

diff --git a/frontend/components/InvoiceForm.tsx b/frontend/components/InvoiceForm.tsx index c94bff6..3a4d7c4 100644 --- a/frontend/components/InvoiceForm.tsx +++ b/frontend/components/InvoiceForm.tsx @@ -147,7 +147,9 @@ export default function InvoiceForm({ onSuccess, userWallet }: InvoiceFormProps)

- 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.'}

diff --git a/frontend/components/PaymentButton.tsx b/frontend/components/PaymentButton.tsx index a65c55a..d28c8cd 100644 --- a/frontend/components/PaymentButton.tsx +++ b/frontend/components/PaymentButton.tsx @@ -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); diff --git a/frontend/components/PaymentReceipt.tsx b/frontend/components/PaymentReceipt.tsx index aa67db5..5073f93 100644 --- a/frontend/components/PaymentReceipt.tsx +++ b/frontend/components/PaymentReceipt.tsx @@ -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'; diff --git a/frontend/components/PaymentStatus.tsx b/frontend/components/PaymentStatus.tsx index 5a63513..8f88ffc 100644 --- a/frontend/components/PaymentStatus.tsx +++ b/frontend/components/PaymentStatus.tsx @@ -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; } diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index b0d4208..d249e2b 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -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; }, diff --git a/frontend/lib/assets.ts b/frontend/lib/assets.ts index 47345b5..fc51ab4 100644 --- a/frontend/lib/assets.ts +++ b/frontend/lib/assets.ts @@ -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 diff --git a/frontend/lib/export.ts b/frontend/lib/export.ts index 2d8c531..77be84a 100644 --- a/frontend/lib/export.ts +++ b/frontend/lib/export.ts @@ -332,14 +332,14 @@ export function generateInvoicePDF(invoice: Invoice): string {
-

PDF olarak kaydetmek için:

+

To save as PDF:

    -
  1. Ctrl+P (Windows) veya Cmd+P (Mac)
  2. -
  3. "Hedef" → "PDF olarak kaydet"
  4. -
  5. "Yazdır" butonuna bas
  6. +
  7. Ctrl+P (Windows) or Cmd+P (Mac)
  8. +
  9. "Destination" → "Save as PDF"
  10. +
  11. Click "Print"
diff --git a/frontend/lib/stellar.ts b/frontend/lib/stellar.ts index 30febcc..043fc56 100644 --- a/frontend/lib/stellar.ts +++ b/frontend/lib/stellar.ts @@ -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 diff --git a/frontend/lib/utils.ts b/frontend/lib/utils.ts index 905c8b1..538afe6 100644 --- a/frontend/lib/utils.ts +++ b/frontend/lib/utils.ts @@ -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 { diff --git a/frontend/tests/fixtures/card-expiry-summary.fixture.js b/frontend/tests/fixtures/card-expiry-summary.fixture.js index 9869e61..dc60d5b 100644 --- a/frontend/tests/fixtures/card-expiry-summary.fixture.js +++ b/frontend/tests/fixtures/card-expiry-summary.fixture.js @@ -1,3 +1,5 @@ -export const cardExpirySummaryFixture = [ +const cardExpirySummaryFixture = [ { input: '2000-01-01T00:00:00.000Z', expected: 'Expired' }, ]; + +module.exports = { cardExpirySummaryFixture }; diff --git a/frontend/tests/fixtures/dashboard-empty-copy.fixture.js b/frontend/tests/fixtures/dashboard-empty-copy.fixture.js index 94fb88f..ccafd4a 100644 --- a/frontend/tests/fixtures/dashboard-empty-copy.fixture.js +++ b/frontend/tests/fixtures/dashboard-empty-copy.fixture.js @@ -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 }; diff --git a/frontend/tests/fixtures/invoice-share-path.fixture.js b/frontend/tests/fixtures/invoice-share-path.fixture.js index a8cf123..1995133 100644 --- a/frontend/tests/fixtures/invoice-share-path.fixture.js +++ b/frontend/tests/fixtures/invoice-share-path.fixture.js @@ -1,5 +1,7 @@ -export const invoiceSharePathFixture = [ +const invoiceSharePathFixture = [ { input: 'invoice-123', output: '/pay/invoice-123' }, { input: 'invoice with spaces', output: '/pay/invoice%20with%20spaces' }, { input: '', output: '/pay/' }, ]; + +module.exports = { invoiceSharePathFixture }; diff --git a/frontend/tests/fixtures/landing-feature-bullets.fixture.js b/frontend/tests/fixtures/landing-feature-bullets.fixture.js index 2580824..266a5e4 100644 --- a/frontend/tests/fixtures/landing-feature-bullets.fixture.js +++ b/frontend/tests/fixtures/landing-feature-bullets.fixture.js @@ -1,5 +1,7 @@ -export const landingFeatureBulletsFixture = [ +const landingFeatureBulletsFixture = [ { n: '01', title: 'Create' }, { n: '02', title: 'Get paid' }, { n: '03', title: 'Keep proof' }, ]; + +module.exports = { landingFeatureBulletsFixture }; diff --git a/frontend/tests/fixtures/network-display-name.fixture.js b/frontend/tests/fixtures/network-display-name.fixture.js index b0560ae..81e0d57 100644 --- a/frontend/tests/fixtures/network-display-name.fixture.js +++ b/frontend/tests/fixtures/network-display-name.fixture.js @@ -1,6 +1,8 @@ -export const networkDisplayNameFixture = [ +const networkDisplayNameFixture = [ { input: 'Test SDF Network ; September 2015', output: 'Testnet' }, { input: 'Public Global Stellar Network ; September 2015', output: 'Public' }, { input: 'mainnet', output: 'Public' }, { input: 'custom network', output: 'Unknown network' }, ]; + +module.exports = { networkDisplayNameFixture }; diff --git a/frontend/tests/pay-page-status-matrix.test.js b/frontend/tests/pay-page-status-matrix.test.js index aa16195..7c91ddd 100644 --- a/frontend/tests/pay-page-status-matrix.test.js +++ b/frontend/tests/pay-page-status-matrix.test.js @@ -92,6 +92,17 @@ test('component visibility is derived consistently for every invoice status', () assert.equal(matrix.CANCELLED.showPaymentControls, false); }); +test('pay page view handles multi-asset invoices (XLM, USDC) correctly', () => { + const usdcInvoice = { + status: 'PENDING', + assetCode: 'USDC', + amount: 50, + }; + const view = getPayPageView(usdcInvoice); + assert.equal(view.showPaymentControls, true); + assert.equal(view.expired, false); +}); + /* * Text equivalents (issue #289). *