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
70 changes: 70 additions & 0 deletions backend/src/routes/contributions.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const {
getContributorDashboard,
getContributorDashboardCsv,
} = require('../services/userDashboardService');
const { buildTaxReceiptPdf } = require('../services/taxReceiptPdf');
const { triggerRefund } = require('../services/sorobanService');
const { emitWebhookEventForUser, emitWebhookEventForCampaign, WEBHOOK_EVENTS } = require('../services/webhookDispatcher');
const { assertUserKycVerified } = require('../services/kycService');
Expand Down Expand Up @@ -144,6 +145,39 @@ function validateSubmittedContributionXdr({ signedXdr, unsignedXdr, senderPublic
}
}

async function getTaxReceiptRows(userId, contributionId = null) {
const params = [userId];
let contributionFilter = '';
if (contributionId) {
params.push(contributionId);
contributionFilter = 'AND ctr.id = $2';
}

const { rows } = await db.query(
`SELECT
ctr.id, ctr.amount, ctr.asset, ctr.tx_hash, ctr.created_at,
ctr.sender_public_key,
c.id AS campaign_id, c.title AS campaign_title, c.status AS campaign_status,
creator.name AS campaign_creator_name,
u.name AS contributor_name, u.email AS contributor_email
FROM users u
JOIN contributions ctr ON ctr.sender_public_key = u.wallet_public_key
JOIN campaigns c ON c.id = ctr.campaign_id
LEFT JOIN users creator ON creator.id = c.creator_id
WHERE u.id = $1 ${contributionFilter}
ORDER BY ctr.created_at DESC`,
params
);
return rows;
}

function taxReceiptFilename(receipts, fallback = 'crowdpay-tax-receipts.pdf') {
if (receipts.length === 1) {
return `crowdpay-tax-receipt-${receipts[0].id}.pdf`;
}
return fallback;
}

router.get('/mine', requireAuth, asyncHandler(async (req, res) => {
const rows = await listUserContributions(req.user.userId);
if (rows === null) return res.status(404).json({ error: 'User not found' });
Expand All @@ -164,6 +198,42 @@ router.get('/dashboard/export.csv', requireAuth, asyncHandler(async (req, res) =
res.send(csv);
}));

router.get('/tax-receipts', requireAuth, asyncHandler(async (req, res) => {
const rows = await getTaxReceiptRows(req.user.userId);
res.json({
receipts: rows.map((row) => ({
id: row.id,
amount: row.amount,
asset: row.asset,
tx_hash: row.tx_hash,
created_at: row.created_at,
campaign_id: row.campaign_id,
campaign_title: row.campaign_title,
campaign_status: row.campaign_status,
})),
});
}));

router.get('/tax-receipts/download', requireAuth, asyncHandler(async (req, res) => {
const rows = await getTaxReceiptRows(req.user.userId);
if (!rows.length) return res.status(404).json({ error: 'No contribution receipts found' });

const pdf = buildTaxReceiptPdf(rows);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${taxReceiptFilename(rows)}"`);
res.send(pdf);
}));

router.get('/tax-receipts/:id/download', requireAuth, asyncHandler(async (req, res) => {
const rows = await getTaxReceiptRows(req.user.userId, req.params.id);
if (!rows.length) return res.status(404).json({ error: 'Tax receipt not found' });

const pdf = buildTaxReceiptPdf(rows);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${taxReceiptFilename(rows)}"`);
res.send(pdf);
}));

router.get('/campaign/:campaignId', asyncHandler(async (req, res) => {
const { limit, offset } = parsePagination(req.query, { limit: 20, max: 100 });

Expand Down
108 changes: 108 additions & 0 deletions backend/src/services/taxReceiptPdf.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
function asciiText(value) {
return String(value ?? '')
.replace(/[^\x20-\x7E]/g, '?')
.replace(/\s+/g, ' ')
.trim();
}

function escapePdfText(value) {
return asciiText(value).replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
}

function money(value, asset) {
const amount = Number(value || 0).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 7,
});
return `${amount} ${asset || ''}`.trim();
}

function receiptLines(receipt, index, total) {
const contributedAt = receipt.created_at
? new Date(receipt.created_at).toISOString()
: 'Unknown date';

return [
'Crowdpay Contribution Tax Receipt',
`Receipt: ${receipt.id}`,
total > 1 ? `Receipt ${index + 1} of ${total}` : '',
'',
`Contributor: ${receipt.contributor_name || 'Contributor'}`,
`Contributor email: ${receipt.contributor_email || 'Not provided'}`,
`Contributor wallet: ${receipt.sender_public_key}`,
'',
`Campaign: ${receipt.campaign_title}`,
`Campaign ID: ${receipt.campaign_id}`,
`Campaign creator: ${receipt.campaign_creator_name || 'Not provided'}`,
`Campaign status: ${receipt.campaign_status}`,
'',
`Contribution amount: ${money(receipt.amount, receipt.asset)}`,
`Contribution date: ${contributedAt}`,
`Transaction hash: ${receipt.tx_hash}`,
'',
'Note: This receipt is generated from Crowdpay contribution records.',
'Consult a tax professional to confirm deductibility in your jurisdiction.',
].filter((line) => line !== '');
}

function buildPageContent(lines) {
const commands = ['BT', '/F1 18 Tf', '72 750 Td', `(${escapePdfText(lines[0])}) Tj`, '/F1 10 Tf'];
for (const line of lines.slice(1)) {
commands.push('0 -18 Td');
commands.push(`(${escapePdfText(line)}) Tj`);
}
commands.push('ET');
return commands.join('\n');
}

function buildTaxReceiptPdf(receipts) {
const rows = Array.isArray(receipts) ? receipts : [receipts];
const objects = [
'<< /Type /Catalog /Pages 2 0 R >>',
'',
];

const pageObjectNumbers = [];
const contentObjectNumbers = [];

rows.forEach((receipt, index) => {
const pageObjectNumber = objects.length + 1;
const contentObjectNumber = pageObjectNumber + 1;
pageObjectNumbers.push(pageObjectNumber);
contentObjectNumbers.push(contentObjectNumber);
objects.push('');

const content = buildPageContent(receiptLines(receipt, index, rows.length));
objects.push(`<< /Length ${Buffer.byteLength(content)} >>\nstream\n${content}\nendstream`);
});

objects[1] = `<< /Type /Pages /Kids [${pageObjectNumbers.map((num) => `${num} 0 R`).join(' ')}] /Count ${pageObjectNumbers.length} >>`;

pageObjectNumbers.forEach((pageObjectNumber, index) => {
objects[pageObjectNumber - 1] =
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 ${objects.length + 1} 0 R >> >> /Contents ${contentObjectNumbers[index]} 0 R >>`;
});

objects.push('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>');

const chunks = ['%PDF-1.4\n'];
const offsets = [0];
objects.forEach((object, index) => {
offsets.push(Buffer.byteLength(chunks.join('')));
chunks.push(`${index + 1} 0 obj\n${object}\nendobj\n`);
});

const xrefOffset = Buffer.byteLength(chunks.join(''));
chunks.push(`xref\n0 ${objects.length + 1}\n`);
chunks.push('0000000000 65535 f \n');
offsets.slice(1).forEach((offset) => {
chunks.push(`${String(offset).padStart(10, '0')} 00000 n \n`);
});
chunks.push(`trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`);

return Buffer.from(chunks.join(''), 'ascii');
}

module.exports = {
buildTaxReceiptPdf,
};
9 changes: 9 additions & 0 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const Developer = lazy(() => import('./pages/Developer'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Profile = lazy(() => import('./pages/Profile'));
const NotificationSettings = lazy(() => import('./pages/NotificationSettings'));
const TaxReceipts = lazy(() => import('./pages/TaxReceipts'));
const HowItWorks = lazy(() => import('./pages/HowItWorks'));
const Pricing = lazy(() => import('./pages/Pricing'));
const About = lazy(() => import('./pages/About'));
Expand Down Expand Up @@ -118,6 +119,14 @@ export default function App() {
</PrivateRoute>
}
/>
<Route
path="/tax-receipts"
element={
<PrivateRoute>
<TaxReceipts />
</PrivateRoute>
}
/>
<Route
path="/my-contributions"
element={<Navigate to="/dashboard?tab=contributions" replace />}
Expand Down
11 changes: 8 additions & 3 deletions frontend/src/components/ContributorDashboard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -366,9 +366,14 @@ export default function ContributorDashboard() {
}}
>
<div style={{ fontSize: '0.85rem', color: 'var(--color-text-hint)' }}>Your portfolio</div>
<button type="button" onClick={exportCsv} disabled={exporting} style={{ fontSize: '0.8rem' }}>
{exporting ? 'Exporting…' : 'Export CSV'}
</button>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
<Link to="/tax-receipts" style={{ fontSize: '0.8rem', color: 'var(--color-accent)', fontWeight: 700 }}>
Tax receipts
</Link>
<button type="button" onClick={exportCsv} disabled={exporting} style={{ fontSize: '0.8rem' }}>
{exporting ? 'Exporting…' : 'Export CSV'}
</button>
</div>
</div>

<div
Expand Down
Loading