diff --git a/.github/workflows/actions/init/action.yml b/.github/workflows/actions/init/action.yml index ed481273c..beef12df8 100644 --- a/.github/workflows/actions/init/action.yml +++ b/.github/workflows/actions/init/action.yml @@ -24,10 +24,10 @@ runs: cache: npm cache-dependency-path: package-lock.json - - name: Install and set up firebase-tools + - name: Install and set up vercel & firebase-tools shell: bash run: | - npm -g install firebase-tools@13.29.1 && \ + npm -g install firebase-tools@13.29.1 vercel@39.2.6 && \ firebase setup:emulators:firestore && \ firebase setup:emulators:storage && \ firebase setup:emulators:ui diff --git a/.github/workflows/production-deployment.yml b/.github/workflows/production-deployment.yml index e1df34d24..5ba5a71f8 100644 --- a/.github/workflows/production-deployment.yml +++ b/.github/workflows/production-deployment.yml @@ -34,6 +34,7 @@ jobs: env: VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} steps: - name: Checkout repository uses: actions/checkout@v3 diff --git a/admin/src/collections/Campaigns.ts b/admin/src/collections/Campaigns.ts index 41d3fbf56..b1403cc0f 100644 --- a/admin/src/collections/Campaigns.ts +++ b/admin/src/collections/Campaigns.ts @@ -69,45 +69,35 @@ export const campaignsCollection = buildAuditedCollection({ multiline: true, }, link_website: { - title: 'Website Link', + name: 'Website Link', dataType: 'string', validation: { required: false }, description: 'The link to the website (optional)', }, link_instagram: { - title: 'Instagram Link', + name: 'Instagram Link', dataType: 'string', validation: { required: false }, description: 'The link to the Instagram profile (optional)', }, link_tiktok: { - title: 'TikTok Link', + name: 'TikTok Link', dataType: 'string', validation: { required: false }, description: 'The link to the TikTok profile (optional)', }, link_facebook: { - title: 'Facebook Link', + name: 'Facebook Link', dataType: 'string', validation: { required: false }, description: 'The link to the Facebook profile (optional)', }, link_x: { - title: 'X (formerly Twitter) Link', + name: 'X (formerly Twitter) Link', dataType: 'string', validation: { required: false }, description: 'The link to the X profile (optional)', }, - amount_collected_chf: { - dataType: 'number', - name: 'Collected amount in CHF', - readOnly: true, - }, - contributions: { - dataType: 'number', - name: 'Contributions', - readOnly: true, - }, goal: { dataType: 'number', name: 'Optional Fundraising Goal', @@ -121,6 +111,12 @@ export const campaignsCollection = buildAuditedCollection({ EUR: 'EUR', }, }, + additional_amount_chf: { + dataType: 'number', + name: 'Additional Amount CHF', + description: + 'Amount that is added to the amount raised (e.g. estimated amount that was donated because of the campaign, but not through the campaign page)', + }, end_date: { // @ts-ignore dataType: 'date', @@ -150,9 +146,8 @@ export const campaignsCollection = buildAuditedCollection({ dataType: 'string', name: 'Url Slug', validation: { - required: true, matches: /^[a-z0-9]+(?:-[a-z0-9]+)*$/, - matchMessage: 'Slug must contain only lowercase letters, numbers, and hyphens', + matchesMessage: 'Slug must contain only lowercase letters, numbers, and hyphens', }, description: 'URL-friendly version of the title. Must be unique and contain only lowercase letters, numbers, and hyphens.', diff --git a/codemagic.yaml b/codemagic.yaml new file mode 100644 index 000000000..f6b0e2bae --- /dev/null +++ b/codemagic.yaml @@ -0,0 +1,484 @@ +definitions: + instance_mac_mini_m2: &instance_mac_mini_m2 + instance_type: mac_mini_m2 + max_build_duration: 30 + + env_versions: &env_versions + flutter: 3.22.3 + xcode: 15.4 + cocoapods: 1.16.2 + java: 17 + + scripts: + - &verify_flutter_version + name: Verify Flutter version + script: | + # Verify Flutter version + echo "Verify Flutter version..." + + REQUIRED_VERSION=$(grep flutter .tool-versions | awk '{print $2}') + echo "REQUIRED_VERSION: $REQUIRED_VERSION" + CURRENT_VERSION=$(flutter --version | grep -m 1 "^Flutter" | awk '{print $2}') + echo "CURRENT_VERSION: $CURRENT_VERSION" + + if [ "$CURRENT_VERSION" != "$REQUIRED_VERSION" ]; then + echo "Error: Flutter version $REQUIRED_VERSION is required (current: $CURRENT_VERSION)" + exit 1 + else + echo "Required version: $REQUIRED_VERSION == Current version: $CURRENT_VERSION" + echo "Flutter version is correct" + fi + working_directory: recipients_app + +workflows: + android-staging-firebase-app-distribution-workflow: + name: Android Staging (Firebase App Distribution) + <<: *instance_mac_mini_m2 + + environment: + android_signing: + - social_income_upload_keystore.jks + groups: + - slack + - app-config-stage + - firebase + #- google_play # <-- (Includes GCLOUD_SERVICE_ACCOUNT_CREDENTIALS) + <<: *env_versions + + cache: + cache_paths: + - $HOME/.gradle/caches + - $FLUTTER_ROOT/.pub-cache + + scripts: + - name: Set up key.properties + script: | + #!/usr/bin/env sh + set -e # exit on first failed command + + cat >> "$CM_BUILD_DIR/recipients_app/android/key.properties" < $GOOGLE_APPLICATION_CREDENTIALS + # Define reusable section 'verify_flutter_version' + - *verify_flutter_version + - name: Get Flutter packages + script: | + #!/usr/bin/env sh + set -e # exit on first failed command + + flutter pub get + working_directory: recipients_app + - name: Build AAB with Flutter + script: | + #!/usr/bin/env sh + set -e # exit on first failed command + + # Build the Flutter APK app + flutter build apk --release \ + --flavor stage \ + -t lib/main.dart \ + --dart-define=SURVEY_BASE_URL=$SURVEY_BASE_URL \ + --dart-define=SENTRY_URL=$SENTRY_URL + working_directory: recipients_app + + artifacts: + - recipients_app/build/**/outputs/**/*.apk + - recipients_app/build/**/outputs/**/*.aab + - recipients_app/build/**/outputs/**/mapping.txt + - flutter_drive.log + + publishing: + #google_play: + # credentials: $GCLOUD_SERVICE_ACCOUNT_CREDENTIALS + # track: internal + # submit_as_draft: true + firebase: + firebase_service_account: $FIREBASE_APP_DISTRIBUTION_SERVICE_ACCOUNT + android: + app_id: 1:51495651779:android:08ef862a962bc041185576 + groups: + - internal-testers + artifact_type: "apk" + + # Notifications + email: # See the following link for details about email publishing - https://docs.codemagic.io/publishing-yaml/distribution/#email + recipients: + - sandino@socialincome.org + notify: + success: true # To receive a notification when a build succeeds + failure: false # To not receive a notification when a build fails + slack: # See the following link about how to connect your Slack account - https://docs.codemagic.io/publishing-yaml/distribution/#slack + channel: $SLACK_CHANNEL_NAME + notify_on_build_start: false + notify: + success: true # To receive a notification when a build succeeds + failure: true # To not receive a notification when a build fails + + android-production-workflow: + name: Android Production + <<: *instance_mac_mini_m2 + + environment: + android_signing: + - social_income_upload_keystore.jks + groups: + - slack + - app-config-prod + #- google_play # <-- (Includes GCLOUD_SERVICE_ACCOUNT_CREDENTIALS) + <<: *env_versions + + cache: + cache_paths: + - $HOME/.gradle/caches + - $FLUTTER_ROOT/.pub-cache + + scripts: + - name: Set up key.properties + script: | + #!/usr/bin/env sh + set -e # exit on first failed command + + cat >> "$CM_BUILD_DIR/recipients_app/android/key.properties" < google-services.json + - *verify_flutter_version + - name: Get Flutter packages + script: | + #!/usr/bin/env sh + set -e # exit on first failed command + + flutter pub get + working_directory: recipients_app + - name: Build AAB with Flutter + script: | + #!/usr/bin/env sh + set -e # exit on first failed command + + # Build the Flutter AAB app + flutter build appbundle --release \ + --flavor prod \ + -t lib/main.dart \ + --dart-define=SURVEY_BASE_URL=$SURVEY_BASE_URL \ + --dart-define=SENTRY_URL=$SENTRY_URL + working_directory: recipients_app + + artifacts: + - recipients_app/build/**/outputs/**/*.apk + - recipients_app/build/**/outputs/**/*.aab + - recipients_app/build/**/outputs/**/mapping.txt + - flutter_drive.log + + publishing: + #google_play: + # credentials: $GCLOUD_SERVICE_ACCOUNT_CREDENTIALS + # track: internal + # submit_as_draft: true + + # Notifications + email: # See the following link for details about email publishing - https://docs.codemagic.io/publishing-yaml/distribution/#email + recipients: + #- sandino@socialincome.org + - karin@karinberg.de + notify: + success: true # To receive a notification when a build succeeds + failure: false # To not receive a notification when a build fails + slack: # See the following link about how to connect your Slack account - https://docs.codemagic.io/publishing-yaml/distribution/#slack + channel: $SLACK_CHANNEL_NAME + notify_on_build_start: false + notify: + success: true # To receive a notification when a build succeeds + failure: true # To not receive a notification when a build fails + + ios-staging-testflight-workflow: + name: iOS Staging (Test Flight) + <<: *instance_mac_mini_m2 + + integrations: + app_store_connect: Social Income Recipient App + + environment: + ios_signing: + distribution_type: app_store + bundle_identifier: org.socialincome.app.stage + groups: + - slack + - app-config-stage + vars: + APP_ID: 6464113329 # Apple ID of App "Stage Social Income" + <<: *env_versions + + cache: + cache_paths: + - $FLUTTER_ROOT/.pub-cache + - $HOME/Library/Caches/CocoaPods + + scripts: + - name: Set up code signing settings on Xcode project + script: | + #!/usr/bin/env sh + set -e # exit on first failed command + + xcode-project use-profiles \ + --archive-method=app-store \ + --export-options-plist=$CM_BUILD_DIR/recipients_app/ios/export_options.plist + + # Print the export options plist + more $CM_BUILD_DIR/recipients_app/ios/export_options.plist + - *verify_flutter_version + - name: Get Flutter packages & Install pods + script: | + #!/usr/bin/env sh + set -e # exit on first failed command + + flutter pub get + find . -name "Podfile" -execdir pod install \; + working_directory: recipients_app + - name: Flutter build ipa + script: | + #!/usr/bin/env sh + set -e # exit on first failed command + + # Build the Flutter iOS app + flutter build ipa --release \ + --flavor stage \ + -t lib/main.dart \ + --dart-define=SURVEY_BASE_URL=$SURVEY_BASE_URL \ + --dart-define=SENTRY_URL=$SENTRY_URL \ + --export-options-plist=$CM_BUILD_DIR/recipients_app/ios/export_options.plist + working_directory: recipients_app + + artifacts: + - recipients_app/build/ios/ipa/*.ipa + - /tmp/xcodebuild_logs/*.log + - flutter_drive.log + + publishing: + # Publish to Apple's Testflight via AppStore Connect + app_store_connect: + # Use referenced App Store Connect API key from above to authenticate binary upload + auth: integration + # Whether or not to submit the uploaded build to TestFlight beta review. + # Note: This action is performed during post-processing. + submit_to_testflight: true + + # Notifications + email: # See the following link for details about email publishing - https://docs.codemagic.io/publishing-yaml/distribution/#email + recipients: + - sandino@socialincome.org + notify: + success: true # To receive a notification when a build succeeds + failure: false # To not receive a notification when a build fails + slack: # See the following link about how to connect your Slack account - https://docs.codemagic.io/publishing-yaml/distribution/#slack + channel: $SLACK_CHANNEL_NAME + notify_on_build_start: false + notify: + success: true # To receive a notification when a build succeeds + failure: true # To not receive a notification when a build fails + + ios-staging-firebase-app-distribution-workflow: + name: iOS Staging (Firebase App Distribution) + <<: *instance_mac_mini_m2 + + integrations: + app_store_connect: Social Income Recipient App + + environment: + ios_signing: + distribution_type: ad_hoc + bundle_identifier: org.socialincome.app.stage + groups: + - slack + - app-config-stage + - firebase + vars: + APP_ID: 6464113329 # Apple ID of App "Stage Social Income" + <<: *env_versions + + cache: + cache_paths: + - $FLUTTER_ROOT/.pub-cache + - $HOME/Library/Caches/CocoaPods + + scripts: + # - name: Set up keychain to be used for code signing using Codemagic CLI 'keychain' command + # script: keychain initialize + # - name: Fetch signing files + # script: | + # app-store-connect fetch-signing-files "org.socialincome.app.stage" \ + # --type IOS_APP_ADHOC \ + # --create + # - name: Set up signing certificate + # script: keychain add-certificates + - name: Set up code signing settings on Xcode project + script: | + #!/usr/bin/env sh + set -e # exit on first failed command + + xcode-project use-profiles \ + --archive-method=ad-hoc \ + --export-options-plist=$CM_BUILD_DIR/recipients_app/ios/export_options.plist + + # Print the export options plist + more $CM_BUILD_DIR/recipients_app/ios/export_options.plist + - *verify_flutter_version + - name: Get Flutter packages & Install pods + script: | + #!/usr/bin/env sh + set -e # exit on first failed command + + flutter pub get + find . -name "Podfile" -execdir pod install \; + working_directory: recipients_app + - name: Flutter build ipa + script: | + #!/usr/bin/env sh + set -e # exit on first failed command + + # Build the Flutter iOS app + flutter build ipa --release \ + --flavor stage \ + -t lib/main.dart \ + --dart-define=SURVEY_BASE_URL=$SURVEY_BASE_URL \ + --dart-define=SENTRY_URL=$SENTRY_URL \ + --export-options-plist=$CM_BUILD_DIR/recipients_app/ios/export_options.plist + working_directory: recipients_app + + artifacts: + - recipients_app/build/ios/ipa/*.ipa + - /tmp/xcodebuild_logs/*.log + - flutter_drive.log + + publishing: + # Publish to Firebase App Distribution + firebase: + firebase_service_account: $FIREBASE_APP_DISTRIBUTION_SERVICE_ACCOUNT + ios: + app_id: 1:51495651779:ios:d4d28e75065983fb185576 + groups: + - internal-testers + + # Notifications + email: # See the following link for details about email publishing - https://docs.codemagic.io/publishing-yaml/distribution/#email + recipients: + - sandino@socialincome.org + notify: + success: true # To receive a notification when a build succeeds + failure: false # To not receive a notification when a build fails + slack: # See the following link about how to connect your Slack account - https://docs.codemagic.io/publishing-yaml/distribution/#slack + channel: $SLACK_CHANNEL_NAME + notify_on_build_start: false + notify: + success: true # To receive a notification when a build succeeds + failure: true # To not receive a notification when a build fails + + ios-production-workflow: + name: iOS Production + <<: *instance_mac_mini_m2 + + integrations: + app_store_connect: Social Income Recipient App + + environment: + ios_signing: + distribution_type: app_store + bundle_identifier: org.socialincome.app + groups: + - slack + - app-config-prod + vars: + APP_ID: 6444860109 # Apple ID of Prod App "Social Income" + <<: *env_versions + + cache: + cache_paths: + - $FLUTTER_ROOT/.pub-cache + - $HOME/Library/Caches/CocoaPods + + scripts: + - name: Load Prod Firebase configuration + script: | + #!/usr/bin/env sh + set -e # exit on first failed command + + cd $CM_BUILD_DIR/recipients_app/ios/GoogleServicesConfig + echo $IOS_FIREBASE_SECRET > GoogleService-Info-Prod.plist + - name: Set up code signing settings on Xcode project + script: | + #!/usr/bin/env sh + set -e # exit on first failed command + + xcode-project use-profiles \ + --archive-method=app-store \ + --export-options-plist=$CM_BUILD_DIR/recipients_app/ios/export_options.plist + + # Print the export options plist + more $CM_BUILD_DIR/recipients_app/ios/export_options.plist + - *verify_flutter_version + - name: Get Flutter packages & Install pods + script: | + #!/usr/bin/env sh + set -e # exit on first failed command + + flutter pub get + find . -name "Podfile" -execdir pod install \; + working_directory: recipients_app + - name: Flutter build ipa + script: | + #!/usr/bin/env sh + set -e # exit on first failed command + + # Build the Flutter iOS app + flutter build ipa --release \ + --flavor prod \ + -t lib/main.dart \ + --dart-define=SURVEY_BASE_URL=$SURVEY_BASE_URL \ + --dart-define=SENTRY_URL=$SENTRY_URL \ + --export-options-plist=$CM_BUILD_DIR/recipients_app/ios/export_options.plist + working_directory: recipients_app + + artifacts: + - recipients_app/build/ios/ipa/*.ipa + - /tmp/xcodebuild_logs/*.log + - flutter_drive.log + + publishing: + # Publish to Apple's Testflight via AppStore Connect + app_store_connect: + # Use referenced App Store Connect API key from above to authenticate binary upload + auth: integration + # Whether or not to submit the uploaded build to TestFlight beta review. + # Note: This action is performed during post-processing. + submit_to_testflight: true + + # Notifications + email: # See the following link for details about email publishing - https://docs.codemagic.io/publishing-yaml/distribution/#email + recipients: + - sandino@socialincome.org + notify: + success: true # To receive a notification when a build succeeds + failure: false # To not receive a notification when a build fails + slack: # See the following link about how to connect your Slack account - https://docs.codemagic.io/publishing-yaml/distribution/#slack + channel: $SLACK_CHANNEL_NAME + notify_on_build_start: false + notify: + success: true # To receive a notification when a build succeeds + failure: true # To not receive a notification when a build fails diff --git a/functions/src/webhooks/stripe/index.ts b/functions/src/webhooks/stripe/index.ts index 97efcc295..c48f4629d 100644 --- a/functions/src/webhooks/stripe/index.ts +++ b/functions/src/webhooks/stripe/index.ts @@ -1,7 +1,6 @@ import { DocumentReference, DocumentSnapshot } from 'firebase-admin/firestore'; import { logger } from 'firebase-functions'; import { onRequest } from 'firebase-functions/v2/https'; -import Stripe from 'stripe'; import { FirestoreAdmin } from '../../../../shared/src/firebase/admin/FirestoreAdmin'; import { SendgridSubscriptionClient } from '../../../../shared/src/sendgrid/SendgridSubscriptionClient'; import { StripeEventHandler } from '../../../../shared/src/stripe/StripeEventHandler'; @@ -43,12 +42,14 @@ export default onRequest(async (request, response) => { try { const sig = request.headers['stripe-signature']!; const event = stripeEventHandler.constructWebhookEvent(request.rawBody, sig, STRIPE_WEBHOOK_SECRET); - const charge = event.data.object as Stripe.Charge; switch (event.type) { case 'charge.succeeded': + // The charge.succeeded events do sometimes not contain the balance_transaction, so we need to listen to charge.updated event + // to get the final balance_transaction and update the contribution document with the final amount. + case 'charge.updated': case 'charge.failed': { - const contributionRef = await stripeEventHandler.handleChargeEvent(charge); - logger.info(`Charge event ${event.type} handled for charge ${charge.id}.`); + const contributionRef = await stripeEventHandler.handleChargeEvent(event.data.object); + logger.info(`Charge event ${event.type} handled for charge ${event.data.object.id}.`); if (contributionRef) { logger.info(`Created contribution ${contributionRef.id}. Adding contributor to newsletter.`); try { diff --git a/package-lock.json b/package-lock.json index f6c74f2cc..a331e6443 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6009,9 +6009,9 @@ } }, "node_modules/@next/env": { - "version": "14.2.19", - "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.19.tgz", - "integrity": "sha512-8yWSNi1p+AOsd1QsxZMMkXtdrz8wvYoRxoUa9olmHBspHVLnxpptyKoI574ZF90yq1gXv/CqpchVLBoK8RcN7w==", + "version": "14.2.23", + "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.23.tgz", + "integrity": "sha512-CysUC9IO+2Bh0omJ3qrb47S8DtsTKbFidGm6ow4gXIG6reZybqxbkH2nhdEm1tC8SmgzDdpq3BIML0PWsmyUYA==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -6093,9 +6093,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "14.2.19", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.19.tgz", - "integrity": "sha512-mL0Nn2PNKV0L++F1l69wY3ySCg9ryw9NtvPhAXm952wpxLVWCCDkVb6XTtOeicF8EdstbjZyLRBflEHtv/Wk2w==", + "version": "14.2.23", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.23.tgz", + "integrity": "sha512-WhtEntt6NcbABA8ypEoFd3uzq5iAnrl9AnZt9dXdO+PZLACE32z3a3qA5OoV20JrbJfSJ6Sd6EqGZTrlRnGxQQ==", "cpu": [ "arm64" ], @@ -6109,9 +6109,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "14.2.19", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.19.tgz", - "integrity": "sha512-2HWOTmk+qNa5R+NW8+752jye5JXlQVkGTf6IV+cT2+q3YeRYQPoqYcIr9KSlB8pvgqRqtIGHcioZFvWSeXjxVA==", + "version": "14.2.23", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.23.tgz", + "integrity": "sha512-vwLw0HN2gVclT/ikO6EcE+LcIN+0mddJ53yG4eZd0rXkuEr/RnOaMH8wg/sYl5iz5AYYRo/l6XX7FIo6kwbw1Q==", "cpu": [ "x64" ], @@ -6125,9 +6125,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "14.2.19", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.19.tgz", - "integrity": "sha512-tlbHT+Chnjqh9gSrNNrDAnqFHkoA++wI829bTawoLwAPemji57/qhDp88YteTuUN3rd4U3FcV0f9qiZbyMStFQ==", + "version": "14.2.23", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.23.tgz", + "integrity": "sha512-uuAYwD3At2fu5CH1wD7FpP87mnjAv4+DNvLaR9kiIi8DLStWSW304kF09p1EQfhcbUI1Py2vZlBO2VaVqMRtpg==", "cpu": [ "arm64" ], @@ -6141,9 +6141,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "14.2.19", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.19.tgz", - "integrity": "sha512-v0FD7DDS1yapnJw8JuvvePlqxyNY+OKrfoVDvibc+9ADVVzbINkHTCXIDshMXy/rBKheUpkycgS1lOaovgZQ5Q==", + "version": "14.2.23", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.23.tgz", + "integrity": "sha512-Mm5KHd7nGgeJ4EETvVgFuqKOyDh+UMXHXxye6wRRFDr4FdVRI6YTxajoV2aHE8jqC14xeAMVZvLqYqS7isHL+g==", "cpu": [ "arm64" ], @@ -6157,9 +6157,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "14.2.19", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.19.tgz", - "integrity": "sha512-S+DneEj0Knv8+cilSn8ZP+xU/926eeeueZ4DjQcFy3hZT+2R29wTYBnDeUk+FCVchjzD9s0dvWff6eHDYrJoSA==", + "version": "14.2.23", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.23.tgz", + "integrity": "sha512-Ybfqlyzm4sMSEQO6lDksggAIxnvWSG2cDWnG2jgd+MLbHYn2pvFA8DQ4pT2Vjk3Cwrv+HIg7vXJ8lCiLz79qoQ==", "cpu": [ "x64" ], @@ -6173,9 +6173,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "14.2.19", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.19.tgz", - "integrity": "sha512-RwczHg2q4n4Ls9PJtBDlBNkfl6G7Fd4uvSRIXPM6Inw52q8R+oWduvuqdzlvcygnv78wt1yrQI14ZRiU0BjgVw==", + "version": "14.2.23", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.23.tgz", + "integrity": "sha512-OSQX94sxd1gOUz3jhhdocnKsy4/peG8zV1HVaW6DLEbEmRRtUCUQZcKxUD9atLYa3RZA+YJx+WZdOnTkDuNDNA==", "cpu": [ "x64" ], @@ -6189,9 +6189,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "14.2.19", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.19.tgz", - "integrity": "sha512-42YlHVSq3q1nTxLkikG2dRUsKM6vq8v0jrxbR1M6vlgprWlIoXme31g3SpJvVp52v1SfE9WbyAegQxfSIENDSQ==", + "version": "14.2.23", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.23.tgz", + "integrity": "sha512-ezmbgZy++XpIMTcTNd0L4k7+cNI4ET5vMv/oqNfTuSXkZtSA9BURElPFyarjjGtRgZ9/zuKDHoMdZwDZIY3ehQ==", "cpu": [ "arm64" ], @@ -6205,9 +6205,9 @@ } }, "node_modules/@next/swc-win32-ia32-msvc": { - "version": "14.2.19", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.19.tgz", - "integrity": "sha512-xMEXItVFcT7fV6ndcfnT4ZHm3R0C8398tiC3KInsK+511Or9Jq5G7zagz8aonNRKZkw15zdM1txRslSMRHe4mA==", + "version": "14.2.23", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.23.tgz", + "integrity": "sha512-zfHZOGguFCqAJ7zldTKg4tJHPJyJCOFhpoJcVxKL9BSUHScVDnMdDuOU1zPPGdOzr/GWxbhYTjyiEgLEpAoFPA==", "cpu": [ "ia32" ], @@ -6221,9 +6221,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "14.2.19", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.19.tgz", - "integrity": "sha512-bOkmujDRcqbHO2Mxun7SogL1fwzGT/PYqFZ0+aTBjmkhGhx7V/Dun4MNjnxJEGByGNg2EcwdWzsYcRUnHs8Ivg==", + "version": "14.2.23", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.23.tgz", + "integrity": "sha512-xCtq5BD553SzOgSZ7UH5LH+OATQihydObTrCTvVzOro8QiWYKdBVwcB2Mn2MLMo6DGW9yH1LSPw7jS7HhgJgjw==", "cpu": [ "x64" ], @@ -24720,12 +24720,12 @@ } }, "node_modules/next": { - "version": "14.2.19", - "resolved": "https://registry.npmjs.org/next/-/next-14.2.19.tgz", - "integrity": "sha512-YTOWj8MDofNLfSeHhDgFJK6koeMOrbzEZIL2SQ7yeSA8WWGgfoajI4V21Wn2bqVtM7D3QYWSIK/Sdvqi0ptfQQ==", + "version": "14.2.23", + "resolved": "https://registry.npmjs.org/next/-/next-14.2.23.tgz", + "integrity": "sha512-mjN3fE6u/tynneLiEg56XnthzuYw+kD7mCujgVqioxyPqbmiotUCGJpIZGS/VaPg3ZDT1tvWxiVyRzeqJFm/kw==", "license": "MIT", "dependencies": { - "@next/env": "14.2.19", + "@next/env": "14.2.23", "@swc/helpers": "0.5.5", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", @@ -24740,15 +24740,15 @@ "node": ">=18.17.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "14.2.19", - "@next/swc-darwin-x64": "14.2.19", - "@next/swc-linux-arm64-gnu": "14.2.19", - "@next/swc-linux-arm64-musl": "14.2.19", - "@next/swc-linux-x64-gnu": "14.2.19", - "@next/swc-linux-x64-musl": "14.2.19", - "@next/swc-win32-arm64-msvc": "14.2.19", - "@next/swc-win32-ia32-msvc": "14.2.19", - "@next/swc-win32-x64-msvc": "14.2.19" + "@next/swc-darwin-arm64": "14.2.23", + "@next/swc-darwin-x64": "14.2.23", + "@next/swc-linux-arm64-gnu": "14.2.23", + "@next/swc-linux-arm64-musl": "14.2.23", + "@next/swc-linux-x64-gnu": "14.2.23", + "@next/swc-linux-x64-musl": "14.2.23", + "@next/swc-win32-arm64-msvc": "14.2.23", + "@next/swc-win32-ia32-msvc": "14.2.23", + "@next/swc-win32-x64-msvc": "14.2.23" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -36961,7 +36961,7 @@ "js-cookie": "^3.0.5", "lodash": "^4.17.21", "luxon": "^3.5.0", - "next": "14.2.19", + "next": "14.2.23", "react": "18.3.1", "react-dom": "18.3.1", "react-hot-toast": "^2.4.1", diff --git a/recipients_app/clean_build.sh b/recipients_app/clean_build.sh index 2134d69f9..01bf85e71 100755 --- a/recipients_app/clean_build.sh +++ b/recipients_app/clean_build.sh @@ -13,7 +13,7 @@ fi # Verify Flutter version echo "Verify Flutter version..." REQUIRED_VERSION=$(grep flutter .tool-versions | awk '{print $2}') -CURRENT_VERSION=$(flutter --version | head -n 1 | awk '{print $2}') +CURRENT_VERSION=$(flutter --version | grep -m 1 "^Flutter" | awk '{print $2}') if [ "$CURRENT_VERSION" != "$REQUIRED_VERSION" ]; then echo "Error: Flutter version $REQUIRED_VERSION is required (current: $CURRENT_VERSION)" exit 1 diff --git a/recipients_app/ios/Podfile.lock b/recipients_app/ios/Podfile.lock index ba6dd0339..76837dde9 100644 --- a/recipients_app/ios/Podfile.lock +++ b/recipients_app/ios/Podfile.lock @@ -222,12 +222,12 @@ CHECKOUT OPTIONS: SPEC CHECKSUMS: AppCheckCore: 9feb4300caa596a36416cde10674dc5bec1e022e - cloud_firestore: 003d53b6b8b392600b7769acf9421cc4f23e5911 + cloud_firestore: 28753092b326c43b17251b50aa6c5b3247c939e5 Firebase: 0312a2352584f782ea56f66d91606891d4607f06 - firebase_app_check: 717089a2df93747620263dd6c8e444bde5699b7e - firebase_auth: 5719ddc9f654b813405899480e84971bd8e61235 - firebase_core: a626d00494efa398e7c54f25f1454a64c8abf197 - firebase_messaging: 06391e8f35dc65a00c56580266285263d2861f10 + firebase_app_check: d2513b120608284733805c98e80b5b11c1bf705e + firebase_auth: f17bd6bfc0bc3d83c6df9dc3829e5b6fee10b147 + firebase_core: 3b49a055ff54114cae400581c13671fe53936c36 + firebase_messaging: 30fa3ec8cd0dc8a860b7817548911b97345a0875 FirebaseAppCheck: 148fa858b8070710c8f83d3f545b5f1e85d104a4 FirebaseAppCheckInterop: 6a1757cfd4067d8e00fccd14fcc1b8fd78cfac07 FirebaseAuth: c0f93dcc570c9da2bffb576969d793e95c344fbb @@ -245,19 +245,19 @@ SPEC CHECKSUMS: FirebaseMessaging: 88950ba9485052891ebe26f6c43a52bb62248952 FirebaseSharedSwift: 0274086954b1b2d5fd7e829eccc587044d72a4ba Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778 + flutter_native_splash: 35ddbc7228eafcb3969dcc5f1fbbe27c1145a4f0 GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15 GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 leveldb-library: cc8b8f8e013647a295ad3f8cd2ddf49a6f19be19 nanopb: 438bc412db1928dac798aa6fd75726007be04262 - package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 RecaptchaInterop: 7d1a4a01a6b2cb1610a47ef3f85f0c411434cb21 Sentry: e9215d7b17f7902692b4f8700e061e4f853e3521 - sentry_flutter: 927eed60d66951d1b0f1db37fe94ff5cb7c80231 - url_launcher_ios: 6116280ddcfe98ab8820085d8d76ae7449447586 - webview_flutter_wkwebview: be0f0d33777f1bfd0c9fdcb594786704dbf65f36 + sentry_flutter: ba644c90ccccb202066a5218a593d1c35584cd37 + url_launcher_ios: c14c42eb16c26cfb6c43c2e40ce35a4085431791 + webview_flutter_wkwebview: 3a409e0c27995945d2dabd90079927fba5c34492 PODFILE CHECKSUM: f727177ee8bd2fa0ad40e98c3b4299dfade495ac diff --git a/shared/.env.sample b/shared/.env.sample index a2d028109..05781c806 100644 --- a/shared/.env.sample +++ b/shared/.env.sample @@ -1,3 +1,8 @@ +# These environment variables are required for runnning the tests against the local firebase environment +GCLOUD_PROJECT="demo-social-income-local" +FIRESTORE_EMULATOR_HOST=127.0.0.1:8080 +FIREBASE_AUTH_EMULATOR_HOST=127.0.0.1:9099 + # For the Twilio related tests to work locally, configure the test credentials from Twilio in your .env file TWILIO_SID=ACXXXXXXXXXXXXXXXXXXXX TWILIO_TOKEN=yyyyyyyyyyyyyyyyyyyyy diff --git a/shared/src/stripe/StripeEventHandler.test.ts b/shared/src/stripe/StripeEventHandler.test.ts index 99af6b0e6..0bb6e4364 100644 --- a/shared/src/stripe/StripeEventHandler.test.ts +++ b/shared/src/stripe/StripeEventHandler.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from '@jest/globals'; import { DateTime } from 'luxon'; import Stripe from 'stripe'; +import { clearFirestoreData } from '../../tests/utils'; import { FirestoreAdmin } from '../firebase/admin/FirestoreAdmin'; import { getOrInitializeFirebaseAdmin } from '../firebase/admin/app'; import { toFirebaseAdminTimestamp } from '../firebase/admin/utils'; @@ -14,6 +15,10 @@ describe('stripeWebhook', () => { const firestoreAdmin = new FirestoreAdmin(getOrInitializeFirebaseAdmin({ projectId: projectId })); const stripeWebhook = new StripeEventHandler('DUMMY_KEY', firestoreAdmin); + beforeEach(async () => { + await clearFirestoreData(firestoreAdmin); + }); + // Mock the Stripe API call jest.spyOn(stripeWebhook.stripe.customers, 'retrieve').mockImplementation(async () => { return { @@ -27,7 +32,7 @@ describe('stripeWebhook', () => { expect(initialUser).toBeUndefined(); const ref = await stripeWebhook.storeCharge(testCharge, null); - const contribution = await ref!.get(); + const contribution = await ref.get(); expect(contribution.data()).toEqual(expectedContribution); const createdUser = (await stripeWebhook.findFirestoreUser(testCustomer))!.data(); @@ -48,7 +53,7 @@ describe('stripeWebhook', () => { }); const ref = await stripeWebhook.storeCharge(testCharge, null); - const contribution = await ref!.get(); + const contribution = await ref.get(); expect(contribution.data()).toEqual(expectedContribution); }); @@ -58,7 +63,7 @@ describe('stripeWebhook', () => { }); const ref = await stripeWebhook.storeCharge(testCharge, { campaignId: 'xyz' }); - const contribution = await ref!.get(); + const contribution = await ref.get(); expect(contribution.data()?.campaign_path).toEqual( firestoreAdmin.firestore.collection(CAMPAIGN_FIRESTORE_PATH).doc('xyz'), ); @@ -70,7 +75,7 @@ describe('stripeWebhook', () => { }); const ref = await stripeWebhook.storeCharge(testCharge, null); - const contribution = await ref!.get(); + const contribution = await ref.get(); expect(contribution.data()).toEqual(expectedContribution); }); diff --git a/shared/src/stripe/StripeEventHandler.ts b/shared/src/stripe/StripeEventHandler.ts index bc6927d09..33c3a97da 100644 --- a/shared/src/stripe/StripeEventHandler.ts +++ b/shared/src/stripe/StripeEventHandler.ts @@ -33,12 +33,12 @@ export class StripeEventHandler { const checkoutMetadata = await this.getCheckoutMetadata(charge); - // We only store non-successful charges if the user already exists. - // This prevents us from having users in the database that never made a successful contribution. - if ( - fullCharge.status === 'succeeded' || - (await this.findFirestoreUser(await this.retrieveStripeCustomer(fullCharge.customer as string))) - ) { + const firestoreUser = await this.findFirestoreUser( + await this.retrieveStripeCustomer(fullCharge.customer as string), + ); + if (fullCharge.status === 'succeeded' || firestoreUser) { + // We only store non-successful charges if the user already exists. + // This prevents us from having users in the database that never made a successful contribution. return await this.storeCharge(fullCharge, checkoutMetadata); } return null; @@ -139,26 +139,6 @@ export class StripeEventHandler { } }; - /** - * Increments the total donations of a campaign if the charge is associated with a campaignId. - */ - maybeUpdateCampaign = async (contribution: StripeContribution): Promise => { - if (contribution.campaign_path) { - try { - const campaign = await contribution.campaign_path.get(); - const current_contributions = campaign.data()?.contributions ?? 0; - const current_amount_chf = campaign.data()?.amount_collected_chf ?? 0; - await contribution.campaign_path.update({ - contributions: current_contributions + 1, - amount_collected_chf: current_amount_chf + contribution.amount_chf, - }); - console.log(`Campaign amount ${contribution.campaign_path} updated.`); - } catch (error) { - console.error(`Error updating campaign amount ${contribution.campaign_path}.`, error); - } - } - }; - constructStatus = (status: Stripe.Charge.Status) => { switch (status) { case 'succeeded': @@ -205,9 +185,8 @@ export class StripeEventHandler { const contributionRef = ( userRef.collection(CONTRIBUTION_FIRESTORE_PATH) as CollectionReference ).doc(charge.id); - await contributionRef.set(contribution); - console.info(`Ingested ${charge.id} into firestore for user ${userRef.id}`); - await this.maybeUpdateCampaign(contribution); + await contributionRef.set(contribution, { merge: true }); + console.info(`Updated contribution document: ${contributionRef.path}`); return contributionRef; }; } diff --git a/shared/src/types/campaign.ts b/shared/src/types/campaign.ts index 90c98ef8c..0bc39ebca 100644 --- a/shared/src/types/campaign.ts +++ b/shared/src/types/campaign.ts @@ -17,10 +17,9 @@ export type Campaign = { link_tiktok?: string; link_facebook?: string; link_x?: string; - amount_collected_chf: number; // automatically updated by incoming payments. - contributions: number; // automatically updated by incoming payments. goal?: number; goal_currency?: Currency; + additional_amount_chf?: number; end_date: Timestamp; status: CampaignStatus; public?: boolean; diff --git a/shared/src/types/contribution.ts b/shared/src/types/contribution.ts index 56b70a6b7..0a1818a76 100644 --- a/shared/src/types/contribution.ts +++ b/shared/src/types/contribution.ts @@ -22,13 +22,16 @@ export enum StatusKey { export type Contribution = StripeContribution | BankWireContribution; +/** + * Represents a contribution to Social Income. The amount that ends up on our account is amount_chf - fees_chf. + */ type BaseContribution = { source: ContributionSourceKey; status: StatusKey; created: Timestamp; amount: number; - amount_chf: number; - fees_chf: number; + amount_chf: number; // Amount donated in CHF, including fees + fees_chf: number; // Transaction fees in CHF currency: Currency; campaign_path?: DocumentReference; }; @@ -36,7 +39,7 @@ type BaseContribution = { export type StripeContribution = BaseContribution & { source: ContributionSourceKey.STRIPE; monthly_interval: number; - reference_id: string; // stripe charge id + reference_id: string; // The stripe charge id, see: https://docs.stripe.com/api/charges }; export type BankWireContribution = BaseContribution & { diff --git a/shared/tests/utils.ts b/shared/tests/utils.ts new file mode 100644 index 000000000..11cb593d3 --- /dev/null +++ b/shared/tests/utils.ts @@ -0,0 +1,11 @@ +import { FirestoreAdmin } from '../src/firebase/admin/FirestoreAdmin'; + +export async function clearFirestoreData(firestoreAdmin: FirestoreAdmin) { + const collections = await firestoreAdmin.firestore.listCollections(); + const deletePromises = collections.map(async (collection) => { + const documents = await collection.listDocuments(); + const deleteDocPromises = documents.map((doc) => firestoreAdmin.firestore.recursiveDelete(doc)); + await Promise.all(deleteDocPromises); + }); + await Promise.all(deletePromises); +} diff --git a/website/next.config.js b/website/next.config.js index 03dd0e1f4..190b1a343 100644 --- a/website/next.config.js +++ b/website/next.config.js @@ -14,6 +14,8 @@ module.exports = withSentryConfig(module.exports, { org: 'social-income', project: 'website', + authToken: process.env.SENTRY_AUTH_TOKEN, + // Only print logs for uploading source maps in CI silent: !process.env.CI, diff --git a/website/package.json b/website/package.json index 55dba2c09..d90c2663e 100644 --- a/website/package.json +++ b/website/package.json @@ -38,7 +38,7 @@ "js-cookie": "^3.0.5", "lodash": "^4.17.21", "luxon": "^3.5.0", - "next": "14.2.19", + "next": "14.2.23", "react": "18.3.1", "react-dom": "18.3.1", "react-hot-toast": "^2.4.1", diff --git a/website/src/app/[lang]/[region]/(website)/campaign/[campaign]/page.tsx b/website/src/app/[lang]/[region]/(website)/campaign/[campaign]/page.tsx index ec209e8ec..423756918 100644 --- a/website/src/app/[lang]/[region]/(website)/campaign/[campaign]/page.tsx +++ b/website/src/app/[lang]/[region]/(website)/campaign/[campaign]/page.tsx @@ -6,6 +6,7 @@ import { firestoreAdmin } from '@/firebase-admin'; import { WebsiteLanguage, WebsiteRegion } from '@/i18n'; import { getMetadata } from '@/metadata'; import { Campaign, CAMPAIGN_FIRESTORE_PATH, CampaignStatus } from '@socialincome/shared/src/types/campaign'; +import { Contribution, CONTRIBUTION_FIRESTORE_PATH } from '@socialincome/shared/src/types/contribution'; import { daysUntilTs } from '@socialincome/shared/src/utils/date'; import { getLatestExchangeRate } from '@socialincome/shared/src/utils/exchangeRates'; import { Translator } from '@socialincome/shared/src/utils/i18n'; @@ -85,8 +86,14 @@ export default async function Page({ params }: CampaignPageProps) { ? await getLatestExchangeRate(firestoreAdmin, campaign.goal_currency) : 1.0; - const contributions = campaign.contributions ?? 0; - const amountCollected = Math.round((campaign.amount_collected_chf ?? 0) * exchangeRate); + const contributions = await firestoreAdmin + .collectionGroup(CONTRIBUTION_FIRESTORE_PATH) + .where('campaign_path', '==', firestoreAdmin.firestore.doc([CAMPAIGN_FIRESTORE_PATH, params.campaign].join('/'))) + .get(); + let amountCollected = contributions.docs.reduce((sum, c) => sum + c.data().amount_chf, 0); + amountCollected += campaign.additional_amount_chf || 0; + amountCollected *= exchangeRate; + const percentageCollected = campaign.goal ? Math.round((amountCollected / campaign.goal) * 100) : undefined; const daysLeft = daysUntilTs(campaign.end_date.toDate()); @@ -122,7 +129,7 @@ export default async function Page({ params }: CampaignPageProps) { {translator?.t('campaign.without-goal.collected', { context: { - count: contributions, + count: contributions.docs.length, amount: amountCollected, currency: campaign.goal_currency, total: campaign.goal, @@ -157,7 +164,7 @@ export default async function Page({ params }: CampaignPageProps) { {translator.t('campaign.with-goal.collected-amount', { context: { - count: contributions, + count: contributions.docs.length, amount: amountCollected, currency: campaign.goal_currency, },