@@ -194,7 +194,7 @@ export function getPledgeByTransactionHash(transactionHash: string): PledgeRecor
194194 return row ? rowToPledge ( row ) : undefined ;
195195}
196196
197- function getContributorPledgedTotal ( campaignId : string , contributor : string ) : number {
197+ export function getContributorPledgedTotal ( campaignId : string , contributor : string ) : number {
198198 const db = getDb ( ) ;
199199 const row = db
200200 . prepare (
@@ -743,65 +743,83 @@ export function addPledge(campaignId: string, input: PledgeInput): CampaignRecor
743743 throw toServiceError ( 'Campaign is no longer accepting pledges.' , 400 , 'INVALID_CAMPAIGN_STATE' ) ;
744744 }
745745
746- checkContributorLimit ( campaign , input . contributor , input . amount ) ;
747-
748746 const createdAt = nowInSeconds ( ) ;
749747 const roundedAmount = round ( input . amount ) ;
750- const nextPledgedAmount = round ( campaign . pledgedAmount + roundedAmount ) ;
751- if ( nextPledgedAmount > campaign . targetAmount ) {
752- throw toServiceError (
753- 'Pledge exceeds campaign funding cap.' ,
754- 400 ,
755- 'CAMPAIGN_FUNDING_CAP_EXCEEDED' ,
756- ) ;
757- }
758- db . prepare (
759- `INSERT INTO pledges (campaign_id, contributor, amount, asset_code, created_at, refunded_at, transaction_hash)
760- VALUES (?, ?, ?, ?, ?, NULL, NULL)` ,
761- ) . run ( campaignId , input . contributor , roundedAmount , assetCode , createdAt ) ;
762748
763- db . prepare ( `UPDATE campaigns SET pledged_amount = pledged_amount + ? WHERE id = ?` ) . run (
764- roundedAmount ,
765- campaignId ,
766- ) ;
749+ db . transaction ( ( ) => {
750+ // Re-check contributor limit within transaction to prevent race conditions
751+ const existingPledged = getContributorPledgedTotal ( campaignId , input . contributor ) ;
752+ if ( campaign . maxPerContributor !== undefined && campaign . maxPerContributor > 0 ) {
753+ if ( existingPledged + roundedAmount > campaign . maxPerContributor ) {
754+ throw toServiceError (
755+ 'Pledge exceeds maximum allowed per contributor.' ,
756+ 400 ,
757+ 'MAX_PER_CONTRIBUTOR_EXCEEDED' ,
758+ ) ;
759+ }
760+ }
767761
768- recordEvent (
769- campaignId ,
770- 'pledged' ,
771- createdAt ,
772- input . contributor ,
773- roundedAmount ,
774- {
775- newTotalPledged : nextPledgedAmount ,
776- assetCode,
777- source : 'backend-mvp' ,
778- } ,
779- { source : 'local' } as BlockchainMetadata ,
780- ) ;
762+ // Re-check campaign funding cap within transaction
763+ const currentPledgedAmount = db
764+ . prepare ( `SELECT pledged_amount FROM campaigns WHERE id = ?` )
765+ . get ( campaignId ) as { pledged_amount : number } ;
766+ const nextPledgedAmount = round ( currentPledgedAmount . pledged_amount + roundedAmount ) ;
767+ if ( nextPledgedAmount > campaign . targetAmount ) {
768+ throw toServiceError (
769+ 'Pledge exceeds campaign funding cap.' ,
770+ 400 ,
771+ 'CAMPAIGN_FUNDING_CAP_EXCEEDED' ,
772+ ) ;
773+ }
781774
782- // Check if contributor has reached their limit and record event
783- if (
784- campaign . maxPerContributor !== undefined &&
785- campaign . maxPerContributor > 0
786- ) {
787- const newContributorTotal = round (
788- getContributorPledgedTotal ( campaignId , input . contributor ) ,
775+ db . prepare (
776+ `INSERT INTO pledges (campaign_id, contributor, amount, asset_code, created_at, refunded_at, transaction_hash)
777+ VALUES (?, ?, ?, ?, ?, NULL, NULL)` ,
778+ ) . run ( campaignId , input . contributor , roundedAmount , assetCode , createdAt ) ;
779+
780+ db . prepare ( `UPDATE campaigns SET pledged_amount = pledged_amount + ? WHERE id = ?` ) . run (
781+ roundedAmount ,
782+ campaignId ,
789783 ) ;
790- if ( newContributorTotal >= campaign . maxPerContributor ) {
791- recordEvent (
792- campaignId ,
793- "pledge_limit_reached" ,
794- createdAt ,
795- input . contributor ,
796- newContributorTotal ,
797- {
798- maxPerContributor : campaign . maxPerContributor ,
799- assetCode,
800- } ,
801- { source : "local" } as BlockchainMetadata ,
784+
785+ recordEvent (
786+ campaignId ,
787+ 'pledged' ,
788+ createdAt ,
789+ input . contributor ,
790+ roundedAmount ,
791+ {
792+ newTotalPledged : nextPledgedAmount ,
793+ assetCode,
794+ source : 'backend-mvp' ,
795+ } ,
796+ { source : 'local' } as BlockchainMetadata ,
797+ ) ;
798+
799+ // Check if contributor has reached their limit and record event
800+ if (
801+ campaign . maxPerContributor !== undefined &&
802+ campaign . maxPerContributor > 0
803+ ) {
804+ const newContributorTotal = round (
805+ getContributorPledgedTotal ( campaignId , input . contributor ) ,
802806 ) ;
807+ if ( newContributorTotal >= campaign . maxPerContributor ) {
808+ recordEvent (
809+ campaignId ,
810+ "pledge_limit_reached" ,
811+ createdAt ,
812+ input . contributor ,
813+ newContributorTotal ,
814+ {
815+ maxPerContributor : campaign . maxPerContributor ,
816+ assetCode,
817+ } ,
818+ { source : "local" } as BlockchainMetadata ,
819+ ) ;
820+ }
803821 }
804- }
822+ } ) ( ) ;
805823
806824 return getCampaign ( campaignId ) ! ;
807825}
@@ -850,23 +868,36 @@ export function reconcileOnChainPledge(
850868 throw toServiceError ( 'Campaign is no longer accepting pledges.' , 400 , 'INVALID_CAMPAIGN_STATE' ) ;
851869 }
852870
853- checkContributorLimit ( campaign , input . contributor , input . amount ) ;
854-
855871 const db = getDb ( ) ;
856872 const createdAt = input . confirmedAt ?? nowInSeconds ( ) ;
857873 const roundedAmount = round ( input . amount ) ;
858874 const assetCode = ( input . assetCode || campaign . assetCode ) . toUpperCase ( ) ;
859- const nextPledgedAmount = round ( campaign . pledgedAmount + roundedAmount ) ;
860-
861- if ( nextPledgedAmount > campaign . targetAmount ) {
862- throw toServiceError (
863- 'Pledge exceeds campaign funding cap.' ,
864- 400 ,
865- 'CAMPAIGN_FUNDING_CAP_EXCEEDED' ,
866- ) ;
867- }
868875
869876 const insertedNewPledge = db . transaction ( ( ) => {
877+ // Re-check contributor limit within transaction to prevent race conditions
878+ const existingPledged = getContributorPledgedTotal ( campaignId , input . contributor ) ;
879+ if ( campaign . maxPerContributor !== undefined && campaign . maxPerContributor > 0 ) {
880+ if ( existingPledged + roundedAmount > campaign . maxPerContributor ) {
881+ throw toServiceError (
882+ 'Pledge exceeds maximum allowed per contributor.' ,
883+ 400 ,
884+ 'MAX_PER_CONTRIBUTOR_EXCEEDED' ,
885+ ) ;
886+ }
887+ }
888+
889+ // Re-check campaign funding cap within transaction
890+ const currentPledgedAmount = db
891+ . prepare ( `SELECT pledged_amount FROM campaigns WHERE id = ?` )
892+ . get ( campaignId ) as { pledged_amount : number } ;
893+ const nextPledgedAmount = round ( currentPledgedAmount . pledged_amount + roundedAmount ) ;
894+ if ( nextPledgedAmount > campaign . targetAmount ) {
895+ throw toServiceError (
896+ 'Pledge exceeds campaign funding cap.' ,
897+ 400 ,
898+ 'CAMPAIGN_FUNDING_CAP_EXCEEDED' ,
899+ ) ;
900+ }
870901 const result = db
871902 . prepare (
872903 `INSERT OR IGNORE INTO pledges (
0 commit comments