@@ -14,6 +14,7 @@ export interface CampaignInput {
1414 imageUrl ?: string ;
1515 externalLink ?: string ;
1616 } ;
17+ maxPerContributor ?: number ;
1718}
1819
1920export interface PledgeInput {
@@ -42,6 +43,7 @@ export interface CampaignRecord {
4243 imageUrl ?: string ;
4344 externalLink ?: string ;
4445 } ;
46+ maxPerContributor ?: number ;
4547}
4648
4749export interface CampaignProgress {
@@ -90,6 +92,7 @@ interface CampaignRow {
9092 claimed_at : number | null ;
9193 deleted_at : number | null ;
9294 metadata_json : string | null ;
95+ max_per_contributor : number | null ;
9396}
9497
9598interface PledgeRow {
@@ -136,6 +139,7 @@ function rowToCampaign(row: CampaignRow): CampaignRecord {
136139 claimedAt : row . claimed_at ?? undefined ,
137140 deletedAt : row . deleted_at ?? undefined ,
138141 metadata : row . metadata_json ? JSON . parse ( row . metadata_json ) : undefined ,
142+ maxPerContributor : row . max_per_contributor ?? undefined ,
139143 } ;
140144}
141145
@@ -180,6 +184,19 @@ function getPledgeByTransactionHash(transactionHash: string): PledgeRecord | und
180184 return row ? rowToPledge ( row ) : undefined ;
181185}
182186
187+ function getContributorPledgedTotal ( campaignId : string , contributor : string ) : number {
188+ const db = getDb ( ) ;
189+ const row = db
190+ . prepare (
191+ `SELECT COALESCE(SUM(amount), 0) AS total
192+ FROM pledges
193+ WHERE campaign_id = ? AND contributor = ? AND refunded_at IS NULL` ,
194+ )
195+ . get ( campaignId , contributor ) as { total : number } ;
196+
197+ return row . total ;
198+ }
199+
183200export function initCampaignStore ( ) : void {
184201 initDb ( ) ;
185202}
@@ -256,8 +273,8 @@ export function listCampaigns(options?: ListCampaignsOptions): ListCampaignsResu
256273 if ( options ?. searchQuery && options . searchQuery . trim ( ) ) {
257274 const searchTerm = `%${ options . searchQuery . trim ( ) . toLowerCase ( ) } %` ;
258275 whereClauses . push ( `(
259- LOWER(id) LIKE ? OR
260- LOWER(title) LIKE ? OR
276+ LOWER(id) LIKE ? OR
277+ LOWER(title) LIKE ? OR
261278 LOWER(creator) LIKE ?
262279 )` ) ;
263280 params . push ( searchTerm , searchTerm , searchTerm ) ;
@@ -374,18 +391,20 @@ export function createCampaign(input: CampaignInput): CampaignRecord {
374391 deadline : input . deadline ,
375392 createdAt : now ,
376393 metadata : input . metadata ,
394+ maxPerContributor : input . maxPerContributor ,
377395 } ;
378396
379397 db . prepare (
380398 `INSERT INTO campaigns (
381- id, creator, title, description, asset_code, target_amount, pledged_amount, deadline, created_at, claimed_at, metadata_json
399+ id, creator, title, description, asset_code, target_amount, pledged_amount, deadline, created_at, claimed_at, metadata_json, max_per_contributor
382400 ) VALUES (
383- @id, @creator, @title, @description, @assetCode, @targetAmount, @pledgedAmount, @deadline, @createdAt, @claimedAt, @metadataJson
401+ @id, @creator, @title, @description, @assetCode, @targetAmount, @pledgedAmount, @deadline, @createdAt, @claimedAt, @metadataJson, @maxPerContributor
384402 )` ,
385403 ) . run ( {
386404 ...campaign ,
387405 claimedAt : null ,
388406 metadataJson : campaign . metadata ? JSON . stringify ( campaign . metadata ) : null ,
407+ maxPerContributor : campaign . maxPerContributor ?? null ,
389408 } ) ;
390409
391410 recordEvent (
@@ -406,6 +425,25 @@ export function createCampaign(input: CampaignInput): CampaignRecord {
406425 return campaign ;
407426}
408427
428+ function checkContributorLimit (
429+ campaign : CampaignRecord ,
430+ contributor : string ,
431+ amount : number ,
432+ ) : void {
433+ if ( campaign . maxPerContributor === undefined ) {
434+ return ;
435+ }
436+
437+ const alreadyPledged = getContributorPledgedTotal ( campaign . id , contributor ) ;
438+ if ( alreadyPledged + amount > campaign . maxPerContributor ) {
439+ throw toServiceError (
440+ `Contributor limit exceeded. Max: ${ campaign . maxPerContributor } , Already pledged: ${ alreadyPledged } , Attempted: ${ amount } ` ,
441+ 400 ,
442+ "CONTRIBUTOR_LIMIT_EXCEEDED" ,
443+ ) ;
444+ }
445+ }
446+
409447export function addPledge ( campaignId : string , input : PledgeInput ) : CampaignRecord {
410448 const db = getDb ( ) ;
411449 const campaign = getCampaign ( campaignId ) ;
@@ -422,6 +460,8 @@ export function addPledge(campaignId: string, input: PledgeInput): CampaignRecor
422460 ) ;
423461 }
424462
463+ checkContributorLimit ( campaign , input . contributor , input . amount ) ;
464+
425465 const createdAt = nowInSeconds ( ) ;
426466 const roundedAmount = round ( input . amount ) ;
427467 const nextPledgedAmount = round ( campaign . pledgedAmount + roundedAmount ) ;
@@ -489,6 +529,8 @@ export function reconcileOnChainPledge(
489529 ) ;
490530 }
491531
532+ checkContributorLimit ( campaign , input . contributor , input . amount ) ;
533+
492534 const db = getDb ( ) ;
493535 const createdAt = input . confirmedAt ?? nowInSeconds ( ) ;
494536 const roundedAmount = round ( input . amount ) ;
@@ -600,7 +642,6 @@ function reconcileOnChainClaim(
600642 ) ;
601643 }
602644
603- // Idempotency: if already claimed with this tx hash, return current state
604645 if ( campaign . claimedAt ) {
605646 return campaign ;
606647 }
@@ -724,4 +765,4 @@ export function refundContributor(
724765 campaign : getCampaign ( campaignId ) ! ,
725766 refundedAmount,
726767 } ;
727- }
768+ }
0 commit comments