diff --git a/.env.template b/.env.template index 3a30764..61dde71 100644 --- a/.env.template +++ b/.env.template @@ -1,2 +1,2 @@ STRIPE_API_KEY= -STRIPE_PUBLISHABLE_KEY= \ No newline at end of file +STRIPE_WEBHOOK_SECRET= diff --git a/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md b/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md index c10946f..2b7487a 100644 --- a/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md +++ b/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md @@ -5,7 +5,7 @@ about: Request a new feature or enhancement -## Summary +# Summary diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..4e3fc91 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,54 @@ +# Copilot Instructions for AI Coding Agents + +## Project Overview + +- This is a template for creating ColdBox modules, following conventions for modular, testable, and maintainable ColdFusion (CFML) code. +- The root directory is the module root. Key files: `ModuleConfig.cfc` (module config), `box.json` (metadata), and `build/Build.cfc` (build logic). +- The `test-harness` app is a full ColdBox app for module testing, with specs in `test-harness/tests/specs`. + +## Architecture & Patterns + +- Modules are installed into the ColdBox's application `modules` or `modules_app` convention, they can alo be hierarchical. +- Modules are self-contained: all code, config, and tests live under the module root. +- Business logic and public APIs go in the `models/` directory. Only this folder is documented by default via DocBox. +- Build and release automation is handled by CommandBox tasks in `build/`. +- TestBox is used for all testing, with specs organized under `test-harness/tests/specs`. +- Engine support is managed via `server-*.json` files for different CFML engines. + +## Developer Workflows + +- **Setup:** Run `box task run taskFile=build/SetupTemplate` to initialize a new module from this template. +- **Build:** Use `box task run build/Build.cfc` for building and packaging. +- **Test:** Use `box testbox run bundles=test-harness/tests` (or run the VS Code task `Run TestBox Bundle`). +- **API Docs:** Generated via the build task, only for `models/` by default. +- **CI/CD:** GitHub Actions in `.github/workflows` automate test, build, and deploy. Environment variables (e.g., `FORGEBOX_TOKEN`, `AWS_ACCESS_KEY`) are required for deployment. + +## Conventions & Customizations + +- Follow Ortus/ColdBox standards for formatting (`.cfformat.json`), linting (`.cflintrc`), and markdown (`.markdownlint.json`). +- Extend or customize build/test logic by editing files in `build/`. +- Add new engine support by copying and editing `server-xx@x.json` files. +- Place all module-specific tests/specs in `test-harness/tests/specs`. +- For ORM-based modules, enable ORM fixtures in the test harness as needed. + +## Integration Points + +- Modules are loaded into the test harness via `config/ColdBox.cfc` after aspects load. +- External dependencies are managed via `box.json` and ForgeBox, both in the root and the test harness. +- CI/CD integrates with ForgeBox and AWS S3 for publishing and docs if this becomes an Ortus module. + +## Examples + +- To add a new model: place CFC in `models/`, document with DocBox comments. +- To add a test: create a spec in `test-harness/tests/specs/`. +- To add a build step: edit `build/Build.cfc`. +- To add a new handler: create a CFC in `handlers/` and create an integration test in `test-harness/tests/specs/integration/`. +- To add a new interceptor create them in the `interceptors/` directory and register them in the `ModuleConfig.cfc`. +- To add a new view: create a file in `views/` +- To add a new layout: create a file in `layouts/` + +## References + +- See `README.md` for full setup, workflow, and learning resources and document the module here but first removing all the boilerplate text. +- See `.github/workflows/` for CI/CD automation details. +- See `build/Build.cfc` and `test-harness/tests/specs/` for build and test patterns. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..990561c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,19 @@ +version: 2 +updates: + # GitHub Actions - updates uses: statements in workflows + - package-ecosystem: "github-actions" + directory: "/" # Where your .github/workflows/ folder is + schedule: + interval: "monthly" + + # Gradle - updates dependencies in build.gradle or build.gradle.kts + - package-ecosystem: "gradle" + directory: "/" # Adjust if build.gradle is in a subfolder + schedule: + interval: "monthly" + + # NPM + - package-ecosystem: "npm" + directory: "/" # adjust if needed + schedule: + interval: "monthly" diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml new file mode 100644 index 0000000..048d86f --- /dev/null +++ b/.github/workflows/cron.yml @@ -0,0 +1,93 @@ +name: Daily compatibility + +on: + schedule: + - cron: "0 7 * * *" + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + locked-matrix: + uses: ./.github/workflows/tests.yml + + dependency-freshness: + name: Dependency freshness / Lucee 7 + runs-on: ubuntu-latest + continue-on-error: true + timeout-minutes: 20 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 + with: + distribution: temurin + java-version: "21" + - uses: ortus-boxlang/setup-boxlang@ff3947fc932eb2e0e2e44fa8e88760aa7ec5b26a # 1.5.0 + with: + with-commandbox: true + - name: Resolve current compatible dependencies + run: | + box package set dependencies.stripecfml='*' + box install --force + cd test-harness + box package set dependencies.coldbox='be' + box install --force + - name: Run freshness suite + run: | + box server start serverConfigFile=server-lucee@7.json --noSaveSettings + curl --retry 60 --retry-delay 1 --retry-all-errors --fail 'http://127.0.0.1:60299/?fwreinit=1' + box testbox run runner='http://127.0.0.1:60299/tests/runner.cfm' + - name: Stop server + if: always() + run: box server stop serverConfigFile=server-lucee@7.json || true + + scheduled-failure-issue: + name: Maintain persistent compatibility failure issue + needs: locked-matrix + if: ${{ always() && github.event_name == 'schedule' }} + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ github.token }} + ISSUE_TITLE: "[CI] Persistent daily compatibility failure" + steps: + - name: Open one issue after consecutive failures + if: needs.locked-matrix.result == 'failure' + env: + CURRENT_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + previous_conclusion=$(gh api --method GET \ + "repos/${GITHUB_REPOSITORY}/actions/workflows/cron.yml/runs" \ + -f event=schedule \ + -f status=completed \ + -f per_page=1 \ + --jq '.workflow_runs[0].conclusion // ""') + test "$previous_conclusion" = "failure" || exit 0 + + existing=$(gh issue list \ + --repo "$GITHUB_REPOSITORY" \ + --state open \ + --search "${ISSUE_TITLE} in:title" \ + --json number \ + --jq '.[0].number // empty') + test -z "$existing" || exit 0 + + gh issue create \ + --repo "$GITHUB_REPOSITORY" \ + --title "$ISSUE_TITLE" \ + --body "The required compatibility matrix failed on two consecutive scheduled runs. Latest failure: ${CURRENT_RUN_URL}. Close this issue only after the required matrix is green." + + - name: Close the issue after recovery + if: needs.locked-matrix.result == 'success' + run: | + existing=$(gh issue list \ + --repo "$GITHUB_REPOSITORY" \ + --state open \ + --search "${ISSUE_TITLE} in:title" \ + --json number \ + --jq '.[0].number // empty') + test -n "$existing" || exit 0 + gh issue close "$existing" \ + --repo "$GITHUB_REPOSITORY" \ + --comment "The required daily compatibility matrix recovered in ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}." diff --git a/.github/workflows/live-stripe.yml b/.github/workflows/live-stripe.yml new file mode 100644 index 0000000..80d7f4b --- /dev/null +++ b/.github/workflows/live-stripe.yml @@ -0,0 +1,56 @@ +name: Live Stripe contract + +on: + schedule: + - cron: "0 9 * * 2" + workflow_dispatch: + workflow_call: + secrets: + STRIPE_API_KEY: + description: Stripe test-mode secret key + required: true + +permissions: + contents: read + +jobs: + stripe-test-mode: + name: Checkout, payment, refund, and webhook + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 + with: + distribution: temurin + java-version: "21" + - uses: ortus-boxlang/setup-boxlang@ff3947fc932eb2e0e2e44fa8e88760aa7ec5b26a # 1.5.0 + with: + with-commandbox: true + - run: box run-script install:dependencies + - name: Start reference engine + run: | + box server start serverConfigFile=server-lucee@6.json --noSaveSettings + curl --retry 60 --retry-delay 1 --retry-all-errors --fail 'http://127.0.0.1:60299/?fwreinit=1' + - name: Run secret-bearing contract with scrubbed TestBox output + env: + CBPAYMENTS_LIVE_REQUIRED: "true" + STRIPE_API_KEY: ${{ secrets.STRIPE_API_KEY }} + run: | + mkdir -p test-harness/tests/results + box testbox run \ + runner='http://127.0.0.1:60299/tests/runner.cfm' \ + bundles='tests.specs.live.StripeLiveSpec' \ + outputFile='test-harness/tests/results/live-stripe' \ + outputFormats='json,antjunit' + build/secret-scan.sh + - name: Stop reference engine + if: always() + run: box server stop serverConfigFile=server-lucee@6.json || true + - name: Upload scrubbed live-test evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: live-stripe-results + path: test-harness/tests/results + if-no-files-found: warn diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index eb04c42..966eae5 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1,34 +1,62 @@ -name: Pull Requests +name: Pull requests on: - push: - branches-ignore: - - "main" - - "master" - - "development" - - "releases/v*" pull_request: - branches: - - "releases/v*" - - development permissions: - checks: write - pull-requests: write + contents: read + +concurrency: + group: pr-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: tests: uses: ./.github/workflows/tests.yml - secrets: inherit - # Format PR - format_check: - name: Checks Source Code Formatting - runs-on: ubuntu-20.04 - steps: - - name: Checkout Repository - uses: actions/checkout@v4 + live-stripe: + name: Required Stripe test-mode contract + if: github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' + uses: ./.github/workflows/live-stripe.yml + secrets: + STRIPE_API_KEY: ${{ secrets.STRIPE_API_KEY }} - - uses: Ortus-Solutions/commandbox-action@v1.0.2 + quality-and-package: + name: Quality and packaged-consumer gate + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Check out exact source + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Set up Java + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 + with: + distribution: temurin + java-version: "21" + - name: Set up BoxLang and CommandBox + uses: ortus-boxlang/setup-boxlang@ff3947fc932eb2e0e2e44fa8e88760aa7ec5b26a # 1.5.0 + with: + with-commandbox: true + - name: Install locked dependencies + run: box run-script install:dependencies + - name: Validate package metadata and formatting + run: | + build/validate-package.sh + box run-script format:check + npx --yes markdownlint-cli@0.45.0 'readme.md' 'changelog.md' 'docs/**/*.md' 'providers/**/*.md' + git diff --check + - name: Build release-shaped artifacts + run: | + box run-script build:module + build/package-provider.sh + - name: Audit and boot the packaged module + run: | + archive=$(find .artifacts/cbpayments -name 'cbpayments-*.zip' ! -name '*-docs-*' -type f | head -1) + provider_archive=$(find .artifacts/cbpayments-monei -name 'cbpayments-monei-*.zip' -type f | head -1) + build/release-dry-run.sh "$archive" + build/package-smoke.sh "$archive" "$provider_archive" + - name: Upload package evidence + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - cmd: run-script format:check + name: cbpayments-pr-artifacts + path: .artifacts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 57d0a5f..2426c7b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,180 +1,227 @@ -name: Build a Release +name: Build and publish on: - # If you push to master|main this will trigger a stable release push: branches: - - master - main - - # Reusable workflow : Usually called by a `snapshot` workflow workflow_call: inputs: snapshot: - description: 'Is this a snapshot build?' - required: false - default: false - type: boolean + description: Publish a prerelease snapshot + required: false + default: false + type: boolean + workflow_dispatch: + inputs: + snapshot: + description: Publish a prerelease snapshot + required: false + default: false + type: boolean permissions: - checks: write - pull-requests: write - contents: write + contents: read -env: - MODULE_ID: cbpayments - SNAPSHOT: ${{ inputs.snapshot || false }} +concurrency: + group: publish-${{ github.ref }} + cancel-in-progress: false jobs: - ########################################################################################## - # Build & Publish - ########################################################################################## - build: - name: Build & Publish - runs-on: ubuntu-20.04 - steps: - - name: Checkout Repository - uses: actions/checkout@v4 - - - name: Setup CommandBox - uses: Ortus-Solutions/setup-commandbox@v2.0.1 - with: - forgeboxAPIKey: ${{ secrets.FORGEBOX_TOKEN }} + tests: + uses: ./.github/workflows/tests.yml - - name: "Setup Environment Variables For Build Process" - id: current_version + live-stripe: + name: Required Stripe test-mode contract + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Require the documented publication branch + env: + IS_SNAPSHOT: ${{ inputs.snapshot || false }} run: | - echo "VERSION=`cat box.json | jq '.version' -r`" >> $GITHUB_ENV - box package set version=@build.version@+@build.number@ - # master or snapshot - echo "Github Ref is $GITHUB_REF" - echo "BRANCH=master" >> $GITHUB_ENV - if [ $GITHUB_REF == 'refs/heads/development' ] - then - echo "BRANCH=development" >> $GITHUB_ENV + if [[ "$IS_SNAPSHOT" == "true" ]]; then + test "$GITHUB_REF" = "refs/heads/development" + else + test "$GITHUB_REF" = "refs/heads/main" fi - - - name: Update changelog [unreleased] with latest version - uses: thomaseizinger/keep-a-changelog-new-release@1.3.0 - if: env.SNAPSHOT == 'false' + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 with: - changelogPath: ./changelog.md - tag: v${{ env.VERSION }} - - - name: Build ${{ env.MODULE_ID }} - run: | - npm install -g markdownlint-cli - markdownlint changelog.md --fix - box install commandbox-docbox - box task run taskfile=build/Build target=run :version=${{ env.VERSION }} :projectName=${{ env.MODULE_ID }} :buildID=${{ github.run_number }} :branch=${{ env.BRANCH }} - - - name: Commit Changelog To Master - uses: EndBug/add-and-commit@v9.1.4 - if: env.SNAPSHOT == 'false' + distribution: temurin + java-version: "21" + - uses: ortus-boxlang/setup-boxlang@ff3947fc932eb2e0e2e44fa8e88760aa7ec5b26a # 1.5.0 with: - author_name: Github Actions - author_email: info@ortussolutions.com - message: 'Finalized changelog for v${{ env.VERSION }}' - add: changelog.md - - - name: Tag Version - uses: rickstaa/action-create-tag@v1.7.2 - if: env.SNAPSHOT == 'false' + with-commandbox: true + - name: Install locked dependencies + run: box run-script install:dependencies + - name: Start reference engine + run: | + box server start serverConfigFile=server-lucee@6.json --noSaveSettings + curl --retry 60 --retry-delay 1 --retry-all-errors --fail 'http://127.0.0.1:60299/?fwreinit=1' + - name: Run live Stripe contract + env: + CBPAYMENTS_LIVE_REQUIRED: "true" + STRIPE_API_KEY: ${{ secrets.STRIPE_API_KEY }} + run: | + mkdir -p test-harness/tests/results + box testbox run \ + runner='http://127.0.0.1:60299/tests/runner.cfm' \ + bundles='tests.specs.live.StripeLiveSpec' \ + outputFile='test-harness/tests/results/live-stripe' \ + outputFormats='json,antjunit' + build/secret-scan.sh + - name: Stop reference engine + if: always() + run: box server stop serverConfigFile=server-lucee@6.json || true + - name: Upload scrubbed live-test evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - tag: "v${{ env.VERSION }}" - force_push_tag: true - message: "Latest Release v${{ env.VERSION }}" + name: live-stripe-results-${{ github.run_id }} + path: test-harness/tests/results + if-no-files-found: warn - - name: Upload Build Artifacts - if: success() - uses: actions/upload-artifact@v4 + build: + name: Build once and verify candidate + needs: + - tests + - live-stripe + runs-on: ubuntu-latest + timeout-minutes: 25 + outputs: + version: ${{ steps.version.outputs.version }} + source-sha: ${{ steps.version.outputs.source-sha }} + snapshot: ${{ steps.version.outputs.snapshot }} + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 with: - name: ${{ env.MODULE_ID }} - path: | - .artifacts/**/* - changelog.md - - - name: Upload Binaries to S3 - uses: jakejarvis/s3-sync-action@master + distribution: temurin + java-version: "21" + - uses: ortus-boxlang/setup-boxlang@ff3947fc932eb2e0e2e44fa8e88760aa7ec5b26a # 1.5.0 with: - args: --acl public-read + with-commandbox: true + - name: Install locked dependencies + run: box run-script install:dependencies + - name: Validate source gates + run: | + build/validate-package.sh + box run-script format:check + npx --yes markdownlint-cli@0.45.0 'readme.md' 'changelog.md' 'docs/**/*.md' 'providers/**/*.md' + build/secret-scan.sh + git diff --check + - name: Select immutable candidate version + id: version env: - AWS_S3_BUCKET: "downloads.ortussolutions.com" - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_ACCESS_SECRET }} - SOURCE_DIR: ".artifacts/${{ env.MODULE_ID }}" - DEST_DIR: "ortussolutions/coldbox-modules/${{ env.MODULE_ID }}" - - - name: Upload API Docs to S3 - uses: jakejarvis/s3-sync-action@master - with: - args: --acl public-read + IS_SNAPSHOT: ${{ inputs.snapshot || false }} + run: | + base_version=$(jq -r '.version' box.json) + if [[ "$IS_SNAPSHOT" == "true" ]]; then + version="${base_version}-snapshot.${GITHUB_RUN_NUMBER}" + else + version="$base_version" + fi + box package set version="$version" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "source-sha=$GITHUB_SHA" >> "$GITHUB_OUTPUT" + echo "snapshot=$IS_SNAPSHOT" >> "$GITHUB_OUTPUT" + - name: Build candidate once env: - AWS_S3_BUCKET: "apidocs.ortussolutions.com" - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_ACCESS_SECRET }} - SOURCE_DIR: ".tmp/apidocs" - DEST_DIR: "coldbox-modules/${{ env.MODULE_ID }}/${{ env.VERSION }}" - - - name: Publish To ForgeBox + VERSION: ${{ steps.version.outputs.version }} + run: >- + box task run taskFile=build/Build.cfc + :projectName=cbpayments + :version="$VERSION" + :buildID="$GITHUB_RUN_NUMBER" + :branch=main + - name: Verify exact built artifact + env: + VERSION: ${{ steps.version.outputs.version }} run: | - cd .tmp/${{ env.MODULE_ID }} - cat box.json - box forgebox publish --force - - - name: Create Github Release - uses: taiki-e/create-gh-release-action@v1.8.0 - continue-on-error: true - if: env.SNAPSHOT == 'false' + archive=".artifacts/cbpayments/${VERSION}/cbpayments-${VERSION}.zip" + build/secret-scan.sh "$archive" + build/package-smoke.sh "$archive" + sha256sum "$archive" > release-manifest.sha256 + sha512sum "$archive" > release-manifest.sha512 + - name: Upload immutable candidate + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - title: ${{ env.VERSION }} - changelog: changelog.md - token: ${{ secrets.GITHUB_TOKEN }} - ref: refs/tags/v${{ env.VERSION }} - - ########################################################################################## - # Prep Next Release - ########################################################################################## - prep_next_release: - name: Prep Next Release - if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' - runs-on: ubuntu-20.04 - needs: [ build ] + name: cbpayments-${{ steps.version.outputs.version }} + path: | + .artifacts + .tmp/cbpayments + .tmp/apidocs + release-manifest.sha256 + release-manifest.sha512 + if-no-files-found: error + + publish: + name: Publish and verify every destination + needs: build + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: write + environment: ${{ needs.build.outputs.snapshot == 'true' && 'snapshot' || 'release' }} steps: - # Checkout development - - name: Checkout Repository - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: - ref: development - - - name: Setup CommandBox - uses: Ortus-Solutions/setup-commandbox@v2.0.1 + fetch-depth: 0 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: - forgeboxAPIKey: ${{ secrets.FORGEBOX_TOKEN }} - - - name: Download build artifacts - uses: actions/download-artifact@v4 + name: cbpayments-${{ needs.build.outputs.version }} + - uses: ortus-boxlang/setup-boxlang@ff3947fc932eb2e0e2e44fa8e88760aa7ec5b26a # 1.5.0 with: - name: ${{ env.MODULE_ID }} - path: .tmp - - # Copy the changelog to the development branch - - name: Copy Changelog + with-commandbox: true + forgeboxAPIKey: ${{ secrets.FORGEBOX_TOKEN }} + - name: Verify downloaded candidate identity run: | - cp .tmp/changelog.md changelog.md - - # Bump to next version - - name: Bump Version + sha256sum --check release-manifest.sha256 + sha512sum --check release-manifest.sha512 + - name: Publish binary and API docs + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_ACCESS_SECRET }} + AWS_DEFAULT_REGION: us-east-1 + VERSION: ${{ needs.build.outputs.version }} run: | - box bump --minor --!TagVersion - - # Commit it back to development - - name: Commit Version Bump - uses: EndBug/add-and-commit@v9.1.4 - with: - author_name: Github Actions - author_email: info@ortussolutions.com - message: 'Version bump' - add: | - box.json - changelog.md + aws s3 sync ".artifacts/cbpayments/${VERSION}" "s3://downloads.ortussolutions.com/ortussolutions/coldbox-modules/cbpayments/${VERSION}" --no-progress + aws s3 sync ".tmp/apidocs" "s3://apidocs.ortussolutions.com/coldbox-modules/cbpayments/${VERSION}" --delete --no-progress + - name: Publish exact candidate metadata to ForgeBox + run: box forgebox publish directory=.tmp/cbpayments --force + - name: Create immutable stable tag and GitHub release + if: needs.build.outputs.snapshot != 'true' + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ needs.build.outputs.version }} + SOURCE_SHA: ${{ needs.build.outputs.source-sha }} + run: | + if git rev-parse "v${VERSION}" >/dev/null 2>&1; then + test "$(git rev-list -n 1 "v${VERSION}")" = "$SOURCE_SHA" + else + git tag -a "v${VERSION}" "$SOURCE_SHA" -m "cbpayments ${VERSION}" + git push origin "v${VERSION}" + fi + gh release view "v${VERSION}" >/dev/null 2>&1 || gh release create "v${VERSION}" \ + ".artifacts/cbpayments/${VERSION}/cbpayments-${VERSION}.zip" \ + ".artifacts/cbpayments/${VERSION}/cbpayments-${VERSION}.zip.sha256" \ + ".artifacts/cbpayments/${VERSION}/cbpayments-${VERSION}.zip.sha512" \ + --verify-tag --title "cbpayments ${VERSION}" --notes-file changelog.md + - name: Verify publication destinations + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ needs.build.outputs.version }} + SOURCE_SHA: ${{ needs.build.outputs.source-sha }} + IS_SNAPSHOT: ${{ needs.build.outputs.snapshot }} + run: | + binary_url="https://downloads.ortussolutions.com/ortussolutions/coldbox-modules/cbpayments/${VERSION}/cbpayments-${VERSION}.zip" + curl --fail --silent --show-error --location "$binary_url" --output published.zip + expected=$(cut -d' ' -f1 release-manifest.sha256) + test "$(sha256sum published.zip | cut -d' ' -f1)" = "$expected" + curl --fail --silent --show-error "https://apidocs.ortussolutions.com/coldbox-modules/cbpayments/${VERSION}/index.html" >/dev/null + box forgebox show cbpayments --json | jq -e --arg version "$VERSION" '.. | strings | select(. == $version)' >/dev/null + if [[ "$IS_SNAPSHOT" != "true" ]]; then + test "$(git ls-remote origin "refs/tags/v${VERSION}" | cut -f1)" = "$SOURCE_SHA" + test "$(gh release view "v${VERSION}" --json tagName --jq .tagName)" = "v${VERSION}" + test "$(gh release view "v${VERSION}" --json assets --jq '.assets | length')" -ge 3 + fi diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml index 4d0c229..0eeb05f 100644 --- a/.github/workflows/snapshot.yml +++ b/.github/workflows/snapshot.yml @@ -1,53 +1,20 @@ -name: Build Snapshot +name: Development snapshot on: push: branches: - - 'development' + - development + workflow_dispatch: -# Unique group name per workflow-branch/tag combo concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -permissions: - checks: write - pull-requests: write - contents: write + group: snapshot-${{ github.ref }} + cancel-in-progress: false jobs: - ########################################################################################## - # Module Tests - ########################################################################################## - tests: - secrets: inherit - uses: ./.github/workflows/tests.yml - - ########################################################################################## - # Format Source Code - ########################################################################################## - format: - name: Code Auto-Formatting - runs-on: ubuntu-20.04 - steps: - - uses: actions/checkout@v4 - - - name: Auto-format - uses: Ortus-Solutions/commandbox-action@v1.0.2 - with: - cmd: run-script format - - - name: Commit Format Changes - uses: stefanzweifel/git-auto-commit-action@v5 - with: - commit_message: Apply cfformat changes - - ########################################################################################## - # Release it - ########################################################################################## release: uses: ./.github/workflows/release.yml - needs: [ tests, format ] secrets: inherit + permissions: + contents: write with: snapshot: true diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index da236c3..9f72178 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,117 +1,82 @@ -name: Test Suites +name: Test suites -# We are a reusable Workflow only on: workflow_call: - secrets: - STRIPE_API_KEY: - required: true - SLACK_WEBHOOK_URL: - required: false + +permissions: + contents: read jobs: tests: - name: Tests - runs-on: ubuntu-20.04 - env: - DB_USER: root - DB_PASSWORD: root + name: ${{ matrix.cfengine }} / ColdBox ${{ matrix.coldbox-version }} + runs-on: ubuntu-latest continue-on-error: ${{ matrix.experimental }} + timeout-minutes: 20 strategy: fail-fast: false matrix: - cfengine: [ "lucee@5", "adobe@2018", "adobe@2021", "adobe@2023" ] - coldboxVersion: [ "^7.0.0" ] - experimental: [ false ] - # Here we tests all engines against ColdBox@BE + cfengine: + - boxlang@1 + - boxlang-cfml@1 + - lucee@6 + - lucee@7 + - adobe@2023 + - adobe@2025 + coldbox-version: + - 8.1.0+34 + experimental: + - false include: - - coldboxVersion: "be" - cfengine: "lucee@5" - experimental: true - - coldboxVersion: "be" - cfengine: "lucee@6" - experimental: true - - coldboxVersion: "be" - cfengine: "adobe@2021" + - cfengine: lucee@7 + coldbox-version: be experimental: true - - coldboxVersion: "be" - cfengine: "adobe@2023" - experimental: true - - coldboxVersion: "be" - cfengine: "boxlang@1" - experimental: true - steps: - - name: Checkout Repository - uses: actions/checkout@v4 - - name: Setup Java - uses: actions/setup-java@v4 + steps: + - name: Check out exact source + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Set up Java + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 with: - distribution: "temurin" - java-version: "11" - - - name: Setup CommandBox CLI - uses: Ortus-Solutions/setup-commandbox@v2.0.1 - - - name: Setup Environment For Testing Process - run: | - # Setup .env - touch .env - # ENV - printf "STRIPE_API_KEY=${{ secrets.STRIPE_API_KEY }}\n" >> .env - - - name: Install Test Harness with ColdBox ${{ matrix.coldboxVersion }} + distribution: temurin + java-version: "21" + - name: Set up BoxLang and CommandBox + uses: ortus-boxlang/setup-boxlang@ff3947fc932eb2e0e2e44fa8e88760aa7ec5b26a # 1.5.0 + with: + with-commandbox: true + - name: Install locked dependencies run: | - box run-script install:dependencies - - - name: Start ${{ matrix.cfengine }} Server + box install + cd test-harness + box package set dependencies.coldbox='${{ matrix.coldbox-version }}' + box install + - name: Start ${{ matrix.cfengine }} run: | - box server start serverConfigFile="server-${{ matrix.cfengine }}.json" --noSaveSettings --debug - curl http://127.0.0.1:60299 - - - name: Run Tests + box server start serverConfigFile='server-${{ matrix.cfengine }}.json' --noSaveSettings + for attempt in $(seq 1 60); do + if curl --fail --silent 'http://127.0.0.1:60299/?fwreinit=1' >/dev/null; then + exit 0 + fi + sleep 1 + done + box server log serverConfigFile='server-${{ matrix.cfengine }}.json' + exit 1 + - name: Run TestBox contract suite run: | mkdir -p test-harness/tests/results - box testbox run --verbose outputFile=test-harness/tests/results/test-results outputFormats=json,antjunit - - - name: Publish Test Results - uses: EnricoMi/publish-unit-test-result-action@v2 + box testbox run \ + runner='http://127.0.0.1:60299/tests/runner.cfm' \ + outputFile='test-harness/tests/results/test-results' \ + outputFormats='json,antjunit' + - name: Show server log after a failure + if: failure() + run: box server log serverConfigFile='server-${{ matrix.cfengine }}.json' + - name: Stop server if: always() - with: - junit_files: test-harness/tests/results/**/*.xml - check_name: "${{ matrix.cfengine }} ColdBox ${{ matrix.coldboxVersion }} Test Results" - - - name: Upload Test Results to Artifacts + run: box server stop serverConfigFile='server-${{ matrix.cfengine }}.json' || true + - name: Upload test evidence if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: test-results-${{ matrix.cfengine }}-${{ matrix.coldboxVersion }} - path: | - test-harness/tests/results/**/* - - - name: Show Server Log On Failures - if: ${{ failure() }} - run: | - box server log serverConfigFile="server-${{ matrix.cfengine }}.json" - - - name: Upload Debug Logs To Artifacts - if: ${{ failure() }} - uses: actions/upload-artifact@v4 - with: - name: Failure Debugging Info - ${{ matrix.cfengine }} - ${{ matrix.coldboxVersion }} - path: | - .engine/**/logs/* - .engine/**/WEB-INF/cfusion/logs/* - - # - name: Slack Notifications - # # Only on failures and NOT in pull requests - # if: ${{ failure() && !startsWith( 'pull_request', github.event_name ) }} - # uses: rtCamp/action-slack-notify@v2 - # env: - # SLACK_CHANNEL: coding - # SLACK_COLOR: ${{ job.status }} # or a specific color like 'green' or '#ff00ff' - # SLACK_ICON_EMOJI: ":bell:" - # SLACK_MESSAGE: '${{ github.repository }} tests failed :cry:' - # SLACK_TITLE: ${{ github.repository }} Tests For ${{ matrix.cfengine }} with ColdBox ${{ matrix.coldboxVersion }} failed - # SLACK_USERNAME: CI - # SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }} + name: test-results-${{ matrix.cfengine }}-${{ matrix.coldbox-version }} + path: test-harness/tests/results + if-no-files-found: warn diff --git a/.gitignore b/.gitignore index 34d1a10..b8a3776 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ .artifacts/** .tmp/** .DS_Store +/test-results.json +/test-harness/tests/results/ # Engine + Secrets .env @@ -13,6 +15,8 @@ test-harness/docbox/** test-harness/testbox/** test-harness/logs/** test-harness/modules/** +!test-harness/modules/cbpayments-fixture/ +!test-harness/modules/cbpayments-fixture/** # modules modules/** diff --git a/.vscode/settings.json b/.vscode/settings.json index 2506a17..3637b24 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,14 +1,6 @@ { - "cfml.mappings": [ - { - "logicalPath": "/coldbox", - "directoryPath": "./test-harness/coldbox", - "isPhysicalDirectoryPath": false - }, - { - "logicalPath": "/testbox", - "directoryPath": "./test-harness/testbox", - "isPhysicalDirectoryPath": false - } - ] + "boxlang.mappings": { + "/coldbox" : "${workspaceFolder}/test-harness/coldbox", + "/testbox" : "${workspaceFolder}/test-harness/testbox" + } } diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d865ac7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,37 @@ +# cbpayments development guide + +## Architecture boundaries + +- `PaymentService@cbpayments` owns named Processor registration, lazy Provider construction, capability dispatch, and lifecycle. +- Providers implement `IPaymentProvider` plus only the capability interfaces they advertise. +- Provider adapters normalize requests and results. Raw SDK response structs must never leave `models/providers/`. +- Consuming applications own customers, invoices, ledgers, authorization, durable idempotency allocation, webhook storage, retries, and reconciliation. + +## Security rules + +- Never accept, log, persist, or fixture PAN, CVV, authorization headers, API keys, webhook secrets, client secrets, payment tokens, raw webhook bodies, or unfiltered provider responses. +- Mutating operations require an idempotency key. +- Public results and interception data must pass through the central redactor and provider-specific allow-lists. +- Keep Stripe card entry on Checkout or Stripe UI components. Do not add Charges, Sources, Tokens, Card Element, or local recurring-payment loops. + +## Contract ownership + +- Registry and dispatch: `models/PaymentService.cfc` +- Lifecycle and safe provider helpers: `models/providers/AbstractPaymentProvider.cfc` +- Capability APIs: `models/contracts/capabilities/` +- Request and result shapes: `models/contracts/requests/` and `models/contracts/results/` +- Security validation and redaction: `models/util/` +- Provider behavior: `models/providers/` +- Independently packaged Provider modules: `providers/` +- Integration and contract proof: `test-harness/tests/specs/` + +## Commands + +- Install: `box run-script install:dependencies` +- Format: `box run-script format` +- Format check: `box run-script format:check` +- Start Lucee 6: `box run-script start:lucee` +- Test: `box testbox run` +- Build package: `box run-script build:module` + +Use fake `sk_test_cbpayments_*` and `whsec_cbpayments_*` values only. Every public behavior change requires focused and integration coverage before the full suite. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8dada3e --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/ModuleConfig.cfc b/ModuleConfig.cfc index e109495..798df03 100644 --- a/ModuleConfig.cfc +++ b/ModuleConfig.cfc @@ -1,43 +1,75 @@ /** - * Copyright Since 2005 ColdBox Framework by Luis Majano and Ortus Solutions, Corp - * www.ortussolutions.com - * --- + * cbpayments ColdBox module. */ component { - // Module Properties - this.title = "cbpayments"; - this.author = "Ortus Solutions"; - this.webURL = "https://www.ortussolutions.com"; - this.description = "A module providing a common interface and API for processing payments and subscriptions'"; - this.version = "@build.version@+@build.number@"; + this.title = "cbpayments"; + this.author = "Ortus Solutions"; + this.webURL = "https://github.com/coldbox-modules/cbpayments"; + this.description = "Provider-neutral named payment Processors for ColdBox applications"; + this.version = "@build.version@+@build.number@"; + this.modelNamespace = "cbpayments"; + this.cfmapping = "cbpayments"; + this.dependencies = [ "stripecfml" ]; + this.applicationHelper = [ "helpers/Mixins.cfm" ]; - // Model Namespace - this.modelNamespace = "cbpayments"; - - // CF Mapping - this.cfmapping = "cbpayments"; + function configure(){ + var moduleInvocationPath = reReplace( + getMetadata( this ).name, + "[.]ModuleConfig$", + "" + ); + var webhookInterceptionPoints = createObject( + "component", + "#moduleInvocationPath#.models.contracts.WebhookEventTypes" + ).init().interceptionPoints(); + settings = { + "defaultProcessor" : "default", + "processors" : { + "default" : { + "provider" : "NullProvider@cbpayments", + "properties" : {} + } + }, + "webhooks" : { "toleranceSeconds" : 300 }, + "logging" : { "includeProviderRequestIds" : true } + }; - // Dependencies - this.dependencies = []; + var customInterceptionPoints = [ + "cbpaymentsOnProcessorStart", + "cbpaymentsOnProcessorShutdown", + "cbpaymentsPreOperation", + "cbpaymentsPostOperation", + "cbpaymentsOnOperationFailure", + "cbpaymentsOnWebhookVerified", + "cbpaymentsOnWebhookRejected" + ]; + customInterceptionPoints.append( webhookInterceptionPoints, true ); + interceptorSettings = { customInterceptionPoints : customInterceptionPoints }; - /** - * Configure Module - */ - function configure(){ - settings = {}; + wirebox.registerDSL( "cbpayments", "#moduleMapping#.dsl.cbpaymentsDSL" ); } - /** - * Fired when the module is registered and activated. - */ function onLoad(){ + var paymentService = wirebox.getInstance( "PaymentService@cbpayments" ); + paymentService + .validateSettings() + .registerAppProcessors() + .validateDefaultProcessor(); + } + + function afterAspectsLoad( event, interceptData, rc, prc, buffer ){ + wirebox.getInstance( "PaymentService@cbpayments" ).registerModuleContributions(); + } + + function onColdBoxShutdown( event, interceptData, rc, prc, buffer ){ + wirebox.getInstance( "PaymentService@cbpayments" ).shutdown(); } - /** - * Fired when the module is unregistered and unloaded - */ function onUnload(){ + if ( !isNull( wirebox ) ) { + wirebox.getInstance( "PaymentService@cbpayments" ).reset(); + } } } diff --git a/box.json b/box.json index 3497201..bb8fe9a 100644 --- a/box.json +++ b/box.json @@ -1,64 +1,69 @@ { - "name":"cbpayments", - "version":"1.0.0", - "location":"https://downloads.ortussolutions.com/ortussolutions/coldbox-modules/cbpayments/@build.version@/cbpayments-@build.version@.zip", - "author":"Ortus Solutions ", - "homepage":"https://github.com/coldbox-modules/cbpayments", - "documentation":"https://github.com/coldbox-modules/cbpayments", - "repository":{ - "type":"git", - "url":"https://github.com/coldbox-modules/cbpayments" - }, - "bugs":"https://github.com/coldbox-modules/cbpayments", - "shortDescription":"Description goes here", - "slug":"cbpayments", - "type":"modules", - "keywords":"", - "license":[ - { - "type":"Apache2", - "url":"http://www.apache.org/licenses/LICENSE-2.0.html" - } - ], - "contributors":[], - "dependencies":{ - "stripecfml":"^3.6.0", - "mementifier":"^3.4.0+2" - }, - "devDependencies":{ - "commandbox-cfformat":"*", - "commandbox-docbox":"*", - "commandbox-dotenv":"*", - "commandbox-cfconfig":"*" - }, - "ignore":[ + "name" : "cbpayments", + "version" : "1.0.0", + "location" : "https://downloads.ortussolutions.com/ortussolutions/coldbox-modules/cbpayments/@build.version@/cbpayments-@build.version@.zip", + "author" : "Ortus Solutions ", + "homepage" : "https://github.com/coldbox-modules/cbpayments", + "documentation" : "https://github.com/coldbox-modules/cbpayments", + "repository" : { "type" : "git", "url" : "https://github.com/coldbox-modules/cbpayments" }, + "bugs" : "https://github.com/coldbox-modules/cbpayments", + "shortDescription" : "Provider-neutral named payment Processors for ColdBox applications", + "slug" : "cbpayments", + "type" : "modules", + "keywords":"payments,stripe,monei,checkout,payment intents,webhooks,coldbox", + "license" : [ + { "type" : "Apache2", "url" : "http://www.apache.org/licenses/LICENSE-2.0.html" } + ], + "contributors" : [ + ], + "dependencies" :{ + "stripecfml":"4.1.0" + }, + "devDependencies" :{ + "commandbox-boxlang":"1.22.0", + "commandbox-cfformat":"0.21.0", + "commandbox-docbox":"2.5.0+5" + }, + "ignore":[ "**/.*", "test-harness", - "/server*.json" + "/providers", + "/server*.json" ], - "scripts":{ - "build:module":"task run taskFile=build/Build.cfc :projectName=`package show slug` :version=`package show version`", - "build:docs":"task run taskFile=build/Build.cfc target=docs :projectName=`package show slug` :version=`package show version`", - "install:dependencies":"install --force && cd test-harness && install --force", - "release":"recipe build/release.boxr", - "format":"cfformat run helpers,models,test-harness/tests/,ModuleConfig.cfc --overwrite", - "format:watch":"cfformat watch helpers,models,test-harness/tests/,ModuleConfig.cfc ./.cfformat.json", - "format:check":"cfformat check helpers,models,test-harness/tests/,ModuleConfig.cfc ./.cfformat.json", - "start:lucee":"server start serverConfigFile=server-lucee@5.json", - "start:2018":"server start serverConfigFile=server-adobe@2018.json", - "start:2021":"server start serverConfigFile=server-adobe@2021.json", - "stop:lucee":"server stop serverConfigFile=server-lucee@5.json", - "stop:2018":"server stop serverConfigFile=server-adobe@2018.json", - "stop:2021":"server stop serverConfigFile=server-adobe@2021.json", - "logs:lucee":"server log serverConfigFile=server-lucee@5.json --follow", - "logs:2018":"server log serverConfigFile=server-adobe@2018.json --follow", - "logs:2021":"server log serverConfigFile=server-adobe@2021.json --follow" + "scripts":{ + "build:module":"task run taskFile=build/Build.cfc :projectName=`package show slug` :version=`package show version`", + "build:docs":"task run taskFile=build/Build.cfc target=docs :projectName=`package show slug` :version=`package show version`", + "package:smoke":"bash build/package-smoke.sh", + "install:dependencies":"install && cd test-harness && install", + "release":"recipe build/release.boxr", + "format":"cfformat run dsl,helpers,models,providers,test-harness/modules/cbpayments-fixture,test-harness/tests,test-harness/monei-http-stub.cfm,build/Build.cfc,build/package-smoke-harness,ModuleConfig.cfc --overwrite", + "format:watch":"cfformat watch dsl,helpers,models,providers,test-harness/modules/cbpayments-fixture,test-harness/tests,test-harness/monei-http-stub.cfm,build/Build.cfc,build/package-smoke-harness,ModuleConfig.cfc ./.cfformat.json", + "format:check":"cfformat check dsl,helpers,models,providers,test-harness/modules/cbpayments-fixture,test-harness/tests,test-harness/monei-http-stub.cfm,build/Build.cfc,build/package-smoke-harness,ModuleConfig.cfc ./.cfformat.json", + "start:boxlang-native" : "server start serverConfigFile=server-boxlang@1.json", + "start:boxlang" : "server start serverConfigFile=server-boxlang-cfml@1.json", + "start:lucee" : "server start serverConfigFile=server-lucee@6.json", + "start:2023" : "server start serverConfigFile=server-adobe@2023.json", + "start:2025" : "server start serverConfigFile=server-adobe@2025.json", + "stop:boxlang-native" : "server stop serverConfigFile=server-boxlang@1.json", + "stop:boxlang" : "server stop serverConfigFile=server-boxlang-cfml@1.json", + "stop:lucee" : "server stop serverConfigFile=server-lucee@6.json", + "stop:2023" : "server stop serverConfigFile=server-adobe@2023.json", + "stop:2025" : "server stop serverConfigFile=server-adobe@2025.json", + "logs:boxlang-native" : "server log serverConfigFile=server-boxlang@1.json --follow", + "logs:boxlang" : "server log serverConfigFile=server-boxlang-cfml@1.json", + "logs:lucee" : "server log serverConfigFile=server-lucee@6.json --follow", + "logs:2023" : "server log serverConfigFile=server-adobe@2023.json --follow", + "logs:2025" : "server log serverConfigFile=server-adobe@2025.json --follow", + "forget:boxlang-native" : "server forget serverConfigFile=server-boxlang@1.json", + "forget:lucee" : "server forget serverConfigFile=server-lucee@6.json", + "forget:2023" : "server forget serverConfigFile=server-adobe@2023.json", + "forget:2025" : "server forget serverConfigFile=server-adobe@2025.json", + "forget:boxlang" : "server forget serverConfigFile=server-boxlang-cfml@1.json" }, - "testbox":{ + "testbox":{ "runner":"http://localhost:60299/tests/runner.cfm" - }, - "installPaths":{ - "stripecfml":"modules/stripecfml/", - "mementifier":"modules/mementifier/" - } + }, + "installPaths":{ + "stripecfml":"modules/stripecfml/" + } } diff --git a/build/Build.cfc b/build/Build.cfc index b15a671..b732a55 100644 --- a/build/Build.cfc +++ b/build/Build.cfc @@ -12,16 +12,20 @@ component { variables.cwd = getCWD().reReplace( "\.$", "" ); variables.artifactsDir = cwd & "/.artifacts"; variables.buildDir = cwd & "/.tmp"; - variables.apidDocsDir = variables.buildDir & "/apidocs"; + variables.apidDocsDir = variables.buildDir & "/apidocs"; variables.apiDocsURL = "http://localhost:60299/apidocs/"; variables.testRunner = "http://localhost:60299/tests/runner.cfm"; // Source Excludes Not Added to final binary variables.excludes = [ + "AGENTS.md", "build", + "modules", "node-modules", + "providers", "resources", "test-harness", + "test-results[^/]*$", "(package|package-lock).json", "webpack.config.js", "server-.*\.json", @@ -43,10 +47,7 @@ component { } ); // Create Mappings - fileSystemUtil.createMapping( - "coldbox", - variables.cwd & "test-harness/coldbox" - ); + fileSystemUtil.createMapping( "coldbox", variables.cwd & "test-harness/coldbox" ); return this; } @@ -55,9 +56,9 @@ component { * Run the build process: test, build source, docs, checksums * * @projectName The project name used for resources and slugs - * @version The version you are building - * @buldID The build identifier - * @branch The branch you are building + * @version The version you are building + * @buldID The build identifier + * @branch The branch you are building */ function run( required projectName, @@ -65,6 +66,11 @@ component { buildID = createUUID(), branch = "development" ){ + // If branch == development, then we are building a snapshot + if ( branch == "development" ) { + arguments.version = arguments.version & "-snapshot"; + } + // Create project mapping fileSystemUtil.createMapping( arguments.projectName, variables.cwd ); @@ -94,10 +100,10 @@ component { command( "testbox run" ) .params( - runner = variables.testRunner, - verbose = true, - outputFile = "#variables.cwd#/test-harness/results/test-results", - outputFormats="json,antjunit" + runner = variables.testRunner, + verbose = true, + outputFile = "#variables.cwd#/test-harness/results/test-results", + outputFormats = "json,antjunit" ) .run(); @@ -111,9 +117,9 @@ component { * Build the source * * @projectName The project name used for resources and slugs - * @version The version you are building - * @buldID The build identifier - * @branch The branch you are building + * @version The version you are building + * @buldID The build identifier + * @branch The branch you are building */ function buildSource( required projectName, @@ -133,18 +139,11 @@ component { // Project Build Dir variables.projectBuildDir = variables.buildDir & "/#projectName#"; - directoryCreate( - variables.projectBuildDir, - true, - true - ); + directoryCreate( variables.projectBuildDir, true, true ); // Copy source print.blueLine( "Copying source to build folder..." ).toConsole(); - copy( - variables.cwd, - variables.projectBuildDir - ); + copy( variables.cwd, variables.projectBuildDir ); // Create build ID fileWrite( @@ -167,7 +166,7 @@ component { .params( path = "/#variables.projectBuildDir#/**", token = ( arguments.branch == "master" ? "@build.number@" : "+@build.number@" ), - replacement = ( arguments.branch == "master" ? arguments.buildID : "-snapshot" ) + replacement = ( arguments.branch == "master" ? arguments.buildID : "" ) ) .run(); @@ -183,10 +182,7 @@ component { ); // Copy box.json for convenience - fileCopy( - "#variables.projectBuildDir#/box.json", - variables.exportsDir - ); + fileCopy( "#variables.projectBuildDir#/box.json", variables.exportsDir ); } /** @@ -236,16 +232,16 @@ component { command( "checksum" ) .params( path = "#variables.exportsDir#/*.zip", - algorithm = "SHA-512", - extension = "sha512", + algorithm = "SHA-256", + extension = "sha256", write = true ) .run(); command( "checksum" ) .params( path = "#variables.exportsDir#/*.zip", - algorithm = "md5", - extension = "md5", + algorithm = "SHA-512", + extension = "sha512", write = true ) .run(); @@ -295,15 +291,13 @@ component { /** * Ensure the export directory exists at artifacts/NAME/VERSION/ */ - private function ensureExportDir( - required projectName, - version = "1.0.0" - ){ - if ( structKeyExists( variables, "exportsDir" ) && directoryExists( variables.exportsDir ) ){ + private function ensureExportDir( required projectName, version = "1.0.0" ){ + if ( structKeyExists( variables, "exportsDir" ) && directoryExists( variables.exportsDir ) ) { return; } // Prepare exports directory variables.exportsDir = variables.artifactsDir & "/#projectName#/#arguments.version#"; directoryCreate( variables.exportsDir, true, true ); } + } diff --git a/build/SetupTemplate.cfc b/build/SetupTemplate.cfc deleted file mode 100644 index ae607fa..0000000 --- a/build/SetupTemplate.cfc +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Setup the Module Template according to your needs - */ -component { - - /** - * Constructor - */ - function init(){ - // Setup Pathing - variables.cwd = getCWD().reReplace( "\.$", "" ); - return this; - } - - /** - * Setup the module template - */ - function run(){ - - // remove old .git - //directoryDelete( variables.cwd & ".git", true ); - - // Create new git repo - //command( "!git init" ).run(); - - var moduleName = ask( "What is the human readable name of your module?" ); - if( !len( moduleName ) ){ - error( "Module Name is required" ); - } - var moduleSlug = ask( "What is the slug for your module?" ); - if( !len( moduleSlug ) ){ - error( "Module Slug is required" ); - } - var moduleDescription = ask( "Short description of your module?" ); - if( !len( moduleDescription ) ){ - error( "Module Description is required" ); - } - - command( "tokenReplace" ) - .params( - path = "/#variables.cwd#/**", - token = "cbpayments", - replacement = moduleName - ) - .run(); - - command( "tokenReplace" ) - .params( - path = "/#variables.cwd#/**", - token = "cbpayments", - replacement = moduleSlug - ) - .run(); - - command( "tokenReplace" ) - .params( - path = "/#variables.cwd#/**", - token = "A module providing a common interface and API for processing payments and subscriptions'", - replacement = moduleDescription - ) - .run(); - - // Finalize Message - print - .line() - .boldMagentaLine( "Your module template is now ready for development! Just add the github origin, commit some code and Go rock it!" ) - .toConsole(); - } - -} diff --git a/build/package-provider.sh b/build/package-provider.sh new file mode 100755 index 0000000..34c4e99 --- /dev/null +++ b/build/package-provider.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash + +set -euo pipefail + +project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +provider_root="${project_root}/providers/cbpayments-monei" +version="$(jq -r '.version' "${provider_root}/box.json")" +artifact_dir="${project_root}/.artifacts/cbpayments-monei/${version}" +archive="${artifact_dir}/cbpayments-monei-${version}.zip" +stage_root="$(mktemp -d "${TMPDIR:-/tmp}/cbpayments-monei-package.XXXXXX")" + +cleanup() { + rm -rf "${stage_root}" +} +trap cleanup EXIT + +mkdir -p "${artifact_dir}" +cp "${provider_root}/ModuleConfig.cfc" "${stage_root}/" +cp "${provider_root}/box.json" "${stage_root}/" +cp "${provider_root}/README.md" "${stage_root}/" +cp "${project_root}/LICENSE" "${stage_root}/" +cp -R "${provider_root}/models" "${stage_root}/models" + +rm -f "${archive}" +( + cd "${stage_root}" + zip -qr "${archive}" . +) + +archive_listing="$(unzip -Z1 "${archive}")" +for required_entry in ModuleConfig.cfc box.json README.md LICENSE models/MoneiClient.cfc models/MoneiProvider.cfc; do + if ! grep -Fxq "${required_entry}" <<<"${archive_listing}"; then + echo "Required MONEI package entry is missing: ${required_entry}" >&2 + exit 1 + fi +done + +if grep -Eq '(^|/)(\.git|\.engine|test-harness|modules|build)(/|$)' <<<"${archive_listing}"; then + echo "MONEI package contains a forbidden development directory." >&2 + exit 1 +fi + +echo "${archive}" diff --git a/build/package-smoke-harness/Application.cfc b/build/package-smoke-harness/Application.cfc new file mode 100644 index 0000000..152b826 --- /dev/null +++ b/build/package-smoke-harness/Application.cfc @@ -0,0 +1,21 @@ +component { + + this.name = "cbpayments-package-smoke-#hash( getCurrentTemplatePath() )#"; + this.sessionManagement = false; + this.mappings[ "/root" ] = getDirectoryFromPath( getCurrentTemplatePath() ); + + function onApplicationStart(){ + application.cbBootstrap = new coldbox.system.Bootstrap( + "", + getDirectoryFromPath( getCurrentTemplatePath() ) + ); + application.cbBootstrap.loadColdbox(); + return true; + } + + function onRequestStart( targetPage ){ + application.cbBootstrap.onRequestStart( arguments.targetPage ); + return true; + } + +} diff --git a/build/package-smoke-harness/box.json b/build/package-smoke-harness/box.json new file mode 100644 index 0000000..721f437 --- /dev/null +++ b/build/package-smoke-harness/box.json @@ -0,0 +1,8 @@ +{ + "name": "cbpayments-package-smoke", + "version": "1.0.0", + "type": "project", + "dependencies": { + "coldbox": "^8.0.0" + } +} diff --git a/build/package-smoke-harness/config/Coldbox.cfc b/build/package-smoke-harness/config/Coldbox.cfc new file mode 100644 index 0000000..df67c7c --- /dev/null +++ b/build/package-smoke-harness/config/Coldbox.cfc @@ -0,0 +1,24 @@ +component { + + function configure(){ + coldbox = { + appName : "cbpayments package smoke", + reinitPassword : "", + handlerCaching : false, + eventCaching : false + }; + moduleSettings = { + cbpayments : { + defaultProcessor : "mock", + processors : { + mock : { provider : "MockProvider@cbpayments", properties : {} }, + monei : { + provider : "MoneiProvider@cbpayments-monei", + properties : { apiKey : "pk_test_cbpayments_package_smoke" } + } + } + } + }; + } + +} diff --git a/build/package-smoke-harness/config/Router.cfc b/build/package-smoke-harness/config/Router.cfc new file mode 100644 index 0000000..39057c5 --- /dev/null +++ b/build/package-smoke-harness/config/Router.cfc @@ -0,0 +1,7 @@ +component { + + function configure(){ + route( "/" ).to( "Main.index" ); + } + +} diff --git a/build/package-smoke-harness/handlers/Main.cfc b/build/package-smoke-harness/handlers/Main.cfc new file mode 100644 index 0000000..d2ce871 --- /dev/null +++ b/build/package-smoke-harness/handlers/Main.cfc @@ -0,0 +1,20 @@ +component { + + property name="paymentService" inject="PaymentService@cbpayments"; + + function index( event, rc, prc ){ + var processorName = rc.keyExists( "processor" ) ? rc.processor : "mock"; + var processor = paymentService.processor( processorName ); + + return event.renderData( + type = "json", + data = { + status : processorName == "monei" ? "cbpayments-monei-package-smoke-ok" : "cbpayments-package-smoke-ok", + processorName : processor.getProcessorName(), + providerType : processor.getProviderType(), + capabilities : paymentService.capabilities( processorName ) + } + ); + } + +} diff --git a/build/package-smoke-harness/index.cfm b/build/package-smoke-harness/index.cfm new file mode 100644 index 0000000..d9c35bf --- /dev/null +++ b/build/package-smoke-harness/index.cfm @@ -0,0 +1,3 @@ + +application.cbBootstrap.onRequestStart( "" ); + diff --git a/build/package-smoke-harness/server.json b/build/package-smoke-harness/server.json new file mode 100644 index 0000000..dff1808 --- /dev/null +++ b/build/package-smoke-harness/server.json @@ -0,0 +1,17 @@ +{ + "app": { + "cfengine": "lucee@6" + }, + "web": { + "http": { + "port": 60301 + }, + "rewrites": { + "enable": true + } + }, + "openBrowser": false, + "JVM": { + "javaVersion": "openjdk21_jre" + } +} diff --git a/build/package-smoke.sh b/build/package-smoke.sh new file mode 100755 index 0000000..a5981de --- /dev/null +++ b/build/package-smoke.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash + +set -euo pipefail + +project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +archive="${1:-}" +provider_archive="${2:-}" + +if [[ -z "${archive}" ]]; then + archive="$(find "${project_root}/.artifacts/cbpayments" -name 'cbpayments-*.zip' ! -name '*-docs-*' -type f | sort | tail -1)" +fi + +if [[ ! -f "${archive}" ]]; then + echo "Package archive not found: ${archive}" >&2 + exit 1 +fi + +archive="$(cd "$(dirname "${archive}")" && pwd)/$(basename "${archive}")" + +required_entries=( + "ModuleConfig.cfc" + "box.json" + "LICENSE" + "models/PaymentService.cfc" + "models/contracts/WebhookEventTypes.cfc" + "models/providers/MockProvider.cfc" + "models/providers/StripeProvider.cfc" + "docs/security.md" +) + +archive_listing="$(unzip -Z1 "${archive}")" +for required_entry in "${required_entries[@]}"; do + if ! grep -Fxq "${required_entry}" <<<"${archive_listing}"; then + echo "Required package entry is missing: ${required_entry}" >&2 + exit 1 + fi +done + +if grep -Eq '(^|/)(\.git|\.engine|test-harness|modules|build)(/|$)|^providers/' <<<"${archive_listing}"; then + echo "Package contains a forbidden development directory." >&2 + exit 1 +fi + +if [[ -n "${provider_archive}" ]]; then + if [[ ! -f "${provider_archive}" ]]; then + echo "Provider package archive not found: ${provider_archive}" >&2 + exit 1 + fi + provider_archive="$(cd "$(dirname "${provider_archive}")" && pwd)/$(basename "${provider_archive}")" +fi + +smoke_root="$(mktemp -d "${TMPDIR:-/tmp}/cbpayments-package-smoke.XXXXXX")" +smoke_log="${smoke_root}/server.log" +server_started=false + +cleanup() { + if [[ "${server_started}" == "true" ]]; then + (cd "${smoke_root}" && box server stop serverConfigFile=server.json >/dev/null 2>&1) || true + fi + rm -rf "${smoke_root}" +} +trap cleanup EXIT + +cp -R "${project_root}/build/package-smoke-harness/." "${smoke_root}/" + +( + cd "${smoke_root}" + box install --production + box install "${archive}" --saveExact + if [[ -n "${provider_archive}" ]]; then + # The provider correctly depends on the future cbpayments ^1.0.0 release. + # Unpack the candidate directly so a pre-release smoke run does not ask + # ForgeBox for that not-yet-published version and replace the core candidate. + mkdir -p modules/cbpayments-monei + unzip -q "${provider_archive}" -d modules/cbpayments-monei + fi + box server start serverConfigFile=server.json --noSaveSettings >"${smoke_log}" 2>&1 +) +server_started=true + +processor="mock" +expected_status="cbpayments-package-smoke-ok" +if [[ -n "${provider_archive}" ]]; then + processor="monei" + expected_status="cbpayments-monei-package-smoke-ok" +fi + +for _attempt in $(seq 1 60); do + if response="$(curl --fail --silent --show-error "http://127.0.0.1:60301/?fwreinit=1&processor=${processor}" 2>>"${smoke_log}")"; then + if grep -Fq "${expected_status}" <<<"${response}"; then + echo "Packaged cbpayments modules resolved and executed successfully." + exit 0 + fi + fi + sleep 1 +done + +echo "Packaged cbpayments did not become healthy." >&2 +(cd "${smoke_root}" && box server log serverConfigFile=server.json) || true +exit 1 diff --git a/build/release-dry-run.sh b/build/release-dry-run.sh new file mode 100755 index 0000000..ffc4046 --- /dev/null +++ b/build/release-dry-run.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash + +set -euo pipefail + +archive="${1:-}" + +if [[ ! -f "${archive}" ]]; then + echo "Usage: build/release-dry-run.sh " >&2 + exit 1 +fi + +project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +candidate="$(cd "$(dirname "${archive}")" && pwd)/$(basename "${archive}")" +dry_root="$(mktemp -d "${TMPDIR:-/tmp}/cbpayments-release-dry-run.XXXXXX")" +trap 'rm -rf "${dry_root}"' EXIT + +mkdir -p "${dry_root}/download-host" "${dry_root}/forgebox" "${dry_root}/github-release" +cp "${candidate}" "${dry_root}/download-host/" +cp "${candidate}" "${dry_root}/forgebox/" +cp "${candidate}" "${dry_root}/github-release/" + +expected="$(shasum -a 256 "${candidate}" | cut -d' ' -f1)" +for published in \ + "${dry_root}/download-host/$(basename "${candidate}")" \ + "${dry_root}/forgebox/$(basename "${candidate}")" \ + "${dry_root}/github-release/$(basename "${candidate}")"; do + test "$(shasum -a 256 "${published}" | cut -d' ' -f1)" = "${expected}" +done + +test -f "${project_root}/.tmp/apidocs/index.html" +"${project_root}/build/secret-scan.sh" "${candidate}" +"${project_root}/build/package-smoke.sh" "${dry_root}/download-host/$(basename "${candidate}")" + +echo "Release dry run preserved one SHA-256 across download, ForgeBox, and GitHub simulations: ${expected}" diff --git a/build/secret-scan.sh b/build/secret-scan.sh new file mode 100755 index 0000000..0a2bf20 --- /dev/null +++ b/build/secret-scan.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash + +set -euo pipefail + +archive="${1:-}" +patterns='(sk_live_[A-Za-z0-9]{16,}|sk_test_[A-Za-z0-9]{24,}|rk_(live|test)_[A-Za-z0-9]{16,}|pk_(live|test)_[A-Za-z0-9]{24,}|whsec_[A-Za-z0-9]{24,})' + +found_secret=0 +while IFS= read -r -d '' source_file; do + if [[ ! -f "${source_file}" ]]; then + continue + fi + if grep -I -n -E "${patterns}" "${source_file}"; then + found_secret=1 + else + grep_status=$? + if [[ ${grep_status} -gt 1 ]]; then + echo "Could not scan source file: ${source_file}" >&2 + exit "${grep_status}" + fi + fi +done < <(git ls-files --cached --others --exclude-standard -z) + +if [[ ${found_secret} -eq 1 ]]; then + echo "A payment Provider-shaped secret was found in source selected for version control." >&2 + exit 1 +fi + +if [[ -n "${archive}" ]]; then + if [[ ! -f "${archive}" ]]; then + echo "Archive not found: ${archive}" >&2 + exit 1 + fi + if unzip -p "${archive}" | LC_ALL=C grep -a -E "${patterns}"; then + echo "A payment Provider-shaped secret was found in the release archive." >&2 + exit 1 + fi +fi + +echo "No payment Provider-shaped secrets found." diff --git a/build/validate-package.sh b/build/validate-package.sh new file mode 100755 index 0000000..a16c290 --- /dev/null +++ b/build/validate-package.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +set -euo pipefail + +jq -e ' + .name == "cbpayments" and + .slug == "cbpayments" and + .type == "modules" and + .version == "1.0.0" and + .dependencies.stripecfml == "4.1.0" and + .devDependencies["commandbox-boxlang"] == "1.22.0" and + .devDependencies["commandbox-cfformat"] == "0.21.0" and + .devDependencies["commandbox-docbox"] == "2.5.0+5" and + (.location | startswith("https://")) and + (.license | any(.type == "Apache2")) +' box.json >/dev/null + +jq -e ' + .dependencies.coldbox == "8.1.0+34" and + .dependencies.stripecfml == "4.1.0" and + .devDependencies.testbox == "7.0.0+19" +' test-harness/box.json >/dev/null + +jq -e ' + .slug == "cbpayments-monei" and + .type == "modules" and + .version == "1.0.0" and + .dependencies.cbpayments == "^1.0.0" and + (.location | startswith("https://")) and + (.license | any(.type == "Apache2")) +' providers/cbpayments-monei/box.json >/dev/null + +test "$(box package show slug)" = "cbpayments" +test "$(box package show type)" = "modules" + +echo "Core and Provider package metadata is valid and dependency pins are exact." diff --git a/changelog.md b/changelog.md index 082aa3e..db586cc 100644 --- a/changelog.md +++ b/changelog.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [1.0.0] => 2024-JUL-23 +### Added -* First iteration of this module +- Named default and custom payment Processors with lazy lifecycle, module contributions, full Provider WireBox IDs, and DSL access. +- Normalized Money, request, result, failure, client-action, and webhook-event contracts. +- Mock, Null, and isolated Stripe Providers plus an independently packageable MONEI Provider. +- Provider-neutral webhook event types and typed ColdBox interception points shared across Providers. +- Recursive payment-data redaction, metadata/URL/idempotency validation, safe interception points, and provider contract kit. +- ColdBox 8 engine matrix, packaging smoke test, live Stripe workflow, and comprehensive Provider/API documentation. diff --git a/docs/compatibility.md b/docs/compatibility.md new file mode 100644 index 0000000..a6223e2 --- /dev/null +++ b/docs/compatibility.md @@ -0,0 +1,18 @@ +# Compatibility and migration + +## 1.0 support matrix + +| Runtime | ColdBox | Status | +| --- | --- | --- | +| BoxLang 1 native | 8.x | Required | +| BoxLang 1 CFML compatibility | 8.x | Required | +| Lucee 6 and 7 | 8.x | Required | +| Adobe ColdFusion 2023 and 2025 | 8.x | Required | + +Java 21 is used by CI. The locked lane currently uses ColdBox 8.1.0+34 and TestBox 7.0.0+19. Stripe support is tested with stripe-cfml 4.1.0 and API `2026-02-25.clover`; the separate freshness lane exercises available upgrades without weakening the release lock. + +## Legacy development branch + +The unreleased 2024 implementation is not a compatibility contract. Replace singleton `StripeProcessor@cbpayments`, the broad processor interface, Charges calls, Sources/Tokens, and raw `{ error, content }` responses with named Processors, full Provider WireBox IDs, capability-specific requests, `PaymentResult`, Checkout/Payment/Setup Intents, and verified normalized webhooks. + +Move application customer, invoice, subscription, ledger, retry, and authorization behavior out of the module. Replace floating major-unit amounts with integer `Money.amountMinor`, allocate durable idempotency keys, and store provider external IDs in application-owned records. Use `getClient()` only as a temporary migration escape hatch with targeted tests. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..a35b2fe --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,99 @@ +# Configuration + +## Providers and Processors + +A Provider is an implementation class. A Processor is a named configuration that points at a Provider's full WireBox ID and supplies its properties. + +This example configures three different Providers: + +```boxlang +moduleSettings.cbpayments = { + defaultProcessor : "receivables", + processors : { + receivables : { + provider : "StripeProvider@cbpayments", + properties : { + apiKey : getSystemSetting( "STRIPE_API_KEY" ), + webhookSecrets : [ getSystemSetting( "STRIPE_WEBHOOK_SECRET" ) ], + apiVersion : "2026-02-25.clover", + defaultCurrency : "usd" + } + }, + europe : { + provider : "MoneiProvider@cbpayments-monei", + properties : { + apiKey : getSystemSetting( "MONEI_API_KEY" ), + accountId : getSystemSetting( "MONEI_ACCOUNT_ID", "" ) + } + }, + preview : { + provider : "MockProvider@cbpayments", + properties : {} + } + }, + webhooks : { toleranceSeconds : 300 }, + logging : { includeProviderRequestIds : true } +}; +``` + +`defaultProcessor` must name a configured Processor. Processor names are case-insensitive at lookup and retain their configured spelling. Each definition accepts only `provider` and optional `properties`. `provider` must be a full WireBox ID such as `StripeProvider@cbpayments`; short aliases are not supported. + +The built-in Providers are: + +| WireBox ID | Purpose | +| --- | --- | +| `StripeProvider@cbpayments` | Stripe Checkout, Payment Intents, refunds, customers, and webhooks | +| `MockProvider@cbpayments` | Deterministic consumer and contract testing | +| `NullProvider@cbpayments` | Explicitly disabled/no-op hosted payments | + +`MoneiProvider@cbpayments-monei` is supplied by the independently installable `cbpayments-monei` Provider module. + +## Multiple accounts using one Provider + +Configure another Processor with the same full WireBox ID and separate properties: + +```boxlang +processors : { + primaryStripe : { + provider : "StripeProvider@cbpayments", + properties : { apiKey : getSystemSetting( "PRIMARY_STRIPE_API_KEY" ) } + }, + secondaryStripe : { + provider : "StripeProvider@cbpayments", + properties : { apiKey : getSystemSetting( "SECONDARY_STRIPE_API_KEY" ) } + } +} +``` + +Each Processor is constructed lazily and owns an isolated Provider instance/client. + +## Provider module contributions + +A dependent ColdBox module can contribute Processors without registering a Provider type: + +```boxlang +settings.cbpayments = { + processors : { + merchant : { + provider : "AcmePayProvider@cbpayments-acmepay", + properties : {} + } + } +}; +``` + +The resulting name is `merchant@ModuleName`. Use `globalProcessors` only when an intentionally global name is required. Duplicate names fail rather than silently replacing an existing Processor. + +## Runtime registration + +Dynamic registration is available for tests and genuinely runtime-selected accounts: + +```boxlang +paymentService.registerProcessor( + name = "tenant-42", + provider = "StripeProvider@cbpayments", + properties = tenantStripeProperties +); +``` + +Prefer static module settings when the Processor set is known at application startup. diff --git a/docs/contracts.md b/docs/contracts.md new file mode 100644 index 0000000..589707c --- /dev/null +++ b/docs/contracts.md @@ -0,0 +1,127 @@ +# Contracts and capabilities + +Provider authors implement the required base contract and only the optional capability contracts their Provider supports. Consumers normally call these APIs through `PaymentService@cbpayments`. + +## Required Provider contract + +`IPaymentProvider` requires: + +```boxlang +startup( required string processorName, struct properties = {} ) +shutdown() +getProcessorName() +getProviderType() +capabilities() +supports( required string capability ) +getClient() +``` + +`processorName` identifies the configured Processor. `providerType` identifies the implementation family, such as `Stripe`, `MONEI`, or `Mock`. `capabilities()` returns the capability names below; `supports()` must agree with that list. `getClient()` is a Provider-specific escape hatch and should not leak into ordinary application payment code. + +Extending `AbstractPaymentProvider` supplies this contract plus safe lifecycle announcements, normalized success/failure factories, redaction, idempotency validation, and shared webhook announcements. + +## Optional capability contracts + +### `IHostedCheckoutProvider` / `hostedCheckout` + +```boxlang +createCheckout( required HostedCheckoutRequest paymentRequest ) +retrieveCheckout( required string externalId ) +expireCheckout( required string externalId, required string idempotencyKey ) +``` + +Use `result.getNextAction()` to obtain a validated redirect URL. + +### `IPaymentIntentsProvider` / `paymentIntents` + +```boxlang +createPaymentIntent( required PaymentIntentRequest paymentRequest ) +retrievePaymentIntent( required string externalId ) +confirmPaymentIntent( required string externalId, required string idempotencyKey, struct options = {} ) +cancelPaymentIntent( required string externalId, required string idempotencyKey ) +``` + +### `ICaptureProvider` / `capture` + +```boxlang +capturePayment( required CaptureRequest paymentRequest ) +``` + +### `IRefundsProvider` / `refunds` + +```boxlang +createRefund( required RefundRequest paymentRequest ) +retrieveRefund( required string externalId ) +``` + +### `ISetupIntentsProvider` / `setupIntents` + +```boxlang +createSetupIntent( required SetupIntentRequest paymentRequest ) +retrieveSetupIntent( required string externalId ) +cancelSetupIntent( required string externalId, required string idempotencyKey ) +``` + +### `ICustomersProvider` / `customers` + +```boxlang +createCustomer( required CustomerRequest paymentRequest ) +retrieveCustomer( required string externalId ) +updateCustomer( required string externalId, required CustomerRequest paymentRequest ) +deleteCustomer( required string externalId, required string idempotencyKey ) +``` + +### `IWebhookProvider` / `webhooks` + +```boxlang +verifyWebhook( + required string rawBody, + required string signature, + string accountId = "" +) +``` + +Verification must happen against the exact raw body before parsing. A successful call returns `PaymentEvent` and announces the generic and normalized interception points described in the [webhook guide](webhooks.md). + +## Request objects + +| Request | Required purpose-specific fields | +| --- | --- | +| `HostedCheckoutRequest@cbpayments` | `money`, `idempotencyKey`, `returnUrl`, `cancelUrl` | +| `PaymentIntentRequest@cbpayments` | `money`, `idempotencyKey`; optional `captureMethod` | +| `CaptureRequest@cbpayments` | `externalId`, `idempotencyKey`; optional `money` | +| `RefundRequest@cbpayments` | `externalId`, `idempotencyKey`; optional `money` and `reason` | +| `SetupIntentRequest@cbpayments` | `idempotencyKey`; optional customer and usage data | +| `CustomerRequest@cbpayments` | `idempotencyKey`; optional safe customer attributes | + +All request types support bounded scalar `metadata` and namespaced `providerOptions`. Construct them through WireBox: + +```boxlang +var intentRequest = wirebox.getInstance( + name = "PaymentIntentRequest@cbpayments", + initArguments = { + money : orderMoney, + idempotencyKey : "order-42-intent-v1", + providerOptions : { + Stripe : { offSession : true } + } + } +); +``` + +## Contract verification + +Provider modules can inject `ProviderContract@cbpayments` in their test suite: + +```boxlang +property name="providerContract" inject="ProviderContract@cbpayments"; +property name="provider" inject="AcmePayProvider@cbpayments-acmepay"; + +provider.startup( "contract-test", testProperties ); +providerContract.assertValid( + provider, + [ "hostedCheckout", "refunds", "webhooks" ] +); +``` + +The verifier checks the base API, capability spelling/uniqueness, and the required methods for every advertised capability. It complements operation, error, redaction, lifecycle, webhook, packaging, and engine tests; it does not replace them. diff --git a/docs/custom-providers.md b/docs/custom-providers.md new file mode 100644 index 0000000..8cd43b7 --- /dev/null +++ b/docs/custom-providers.md @@ -0,0 +1,156 @@ +# Custom Provider author guide + +A Provider is an installable ColdBox module that implements vendor behavior. A Processor is created only when an application points a named configuration at that Provider's full WireBox ID. + +## No Provider type registry is needed + +WireBox already gives every module model a unique ID. If your module declares `this.modelNamespace = "cbpayments-acmepay"`, its `models/AcmePayProvider.cfc` is available as `AcmePayProvider@cbpayments-acmepay`. A second alias registry duplicates WireBox, introduces ownership/collision rules, and obscures the actual implementation being configured, so cbpayments does not use `providerTypes`. + +Installing a Provider module makes its WireBox ID available. It does not create a Processor, credentials, or an account configuration. + +## Module skeleton + +```boxlang +component { + + this.title = "cbpayments AcmePay Provider"; + this.modelNamespace = "cbpayments-acmepay"; + this.cfmapping = "cbpaymentsacmepay"; + this.dependencies = [ "cbpayments" ]; + + function configure(){ + settings = {}; + } + +} +``` + +The application configures a Processor explicitly: + +```boxlang +moduleSettings.cbpayments.processors.acme = { + provider : "AcmePayProvider@cbpayments-acmepay", + properties : { + apiKey : getSystemSetting( "ACMEPAY_API_KEY" ) + } +}; +``` + +If the Provider module itself supplies a known Processor, it may contribute a module-scoped definition: + +```boxlang +settings.cbpayments = { + processors : { + sandbox : { + provider : "AcmePayProvider@cbpayments-acmepay", + properties : {} + } + } +}; +``` + +Consumers address that Processor as `sandbox@cbpayments-acmepay`. Most Provider modules should leave credentials to application configuration instead. + +## Required implementation + +Implement `cbpayments.models.contracts.IPaymentProvider`, or extend `cbpayments.models.providers.AbstractPaymentProvider`: + +```boxlang +component + extends="cbpayments.models.providers.AbstractPaymentProvider" + implements="cbpayments.models.contracts.capabilities.IHostedCheckoutProvider" +{ + + function init(){ + super.init(); + variables.providerType = "AcmePay"; + variables.supportedCapabilities = [ "hostedCheckout" ]; + return this; + } + + function startup( required string processorName, struct properties = {} ){ + validateProperties( arguments.properties ); + variables.client = wirebox.getInstance( + name = "AcmePayClient@cbpayments-acmepay", + initArguments = { apiKey : arguments.properties.apiKey } + ); + return super.startup( arguments.processorName, arguments.properties ); + } + + function createCheckout( required any paymentRequest ){ + return runOperation( + "hostedCheckout.create", + function(){ + var response = variables.client.createCheckout( { + amount : paymentRequest.getMoney().getAmountMinor(), + currency : paymentRequest.getMoney().getCurrency() + } ); + return successResult( + operation = "hostedCheckout.create", + externalId = response.id, + idempotencyKey = paymentRequest.getIdempotencyKey(), + nextAction = { type : "redirect", redirectUrl : response.redirectUrl } + ); + }, + paymentRequest.getIdempotencyKey() + ); + } + + function retrieveCheckout( required string externalId ){ + // Retrieve, allow-list, and normalize the Provider response. + } + + function expireCheckout( required string externalId, required string idempotencyKey ){ + // Require idempotency and normalize the Provider response. + } + +} +``` + +`IPaymentProvider` requires `startup`, `shutdown`, `getProcessorName`, `getProviderType`, `capabilities`, `supports`, and `getClient`. `AbstractPaymentProvider` implements these and provides `successResult`, `failureResult`, `runOperation`, lifecycle announcements, redaction, and `announceVerifiedWebhook`. + +## Optional contracts + +Implement and advertise only supported capabilities: + +| Interface | Capability | Required methods | +| --- | --- | --- | +| `IHostedCheckoutProvider` | `hostedCheckout` | `createCheckout`, `retrieveCheckout`, `expireCheckout` | +| `IPaymentIntentsProvider` | `paymentIntents` | `createPaymentIntent`, `retrievePaymentIntent`, `confirmPaymentIntent`, `cancelPaymentIntent` | +| `ICaptureProvider` | `capture` | `capturePayment` | +| `IRefundsProvider` | `refunds` | `createRefund`, `retrieveRefund` | +| `ISetupIntentsProvider` | `setupIntents` | `createSetupIntent`, `retrieveSetupIntent`, `cancelSetupIntent` | +| `ICustomersProvider` | `customers` | `createCustomer`, `retrieveCustomer`, `updateCustomer`, `deleteCustomer` | +| `IWebhookProvider` | `webhooks` | `verifyWebhook` | + +The exact signatures and request APIs are documented in [Contracts and capabilities](contracts.md). + +## Normalization rules + +- Keep the vendor library and HTTP transport inside the Provider module. +- Create one client per Processor so accounts and mutable state cannot bleed together. +- Require idempotency keys for every mutation and forward them when the vendor supports an idempotency mechanism. +- Send integer minor-unit amounts and explicit currencies. +- Allow-list response fields; never return raw SDK/HTTP objects. +- Return expected remote failures as `PaymentResult(ok=false)` and reserve typed exceptions for configuration/programmer errors. +- Namespace and allow-list Provider options, for example `providerOptions.AcmePay`. +- Never retain or announce raw webhook bodies, signatures, credentials, authorization headers, payment tokens, PAN, CVV, or unfiltered error bodies. +- Map webhooks into `WebhookEventTypes@cbpayments` and call `announceVerifiedWebhook` only after signature, timestamp, and account validation. + +## Test contract + +Use WireBox in integration tests so module mappings and injections are proven: + +```boxlang +var provider = getInstance( "AcmePayProvider@cbpayments-acmepay" ); +provider.startup( "acme-contract", { client : fakeClient } ); + +getInstance( "ProviderContract@cbpayments" ).assertValid( + provider, + [ "hostedCheckout", "webhooks" ] +); +``` + +Also test every operation, request translation, all HTTP error families, malformed responses, retryability, idempotency, redaction, Processor isolation, lifecycle, valid/invalid/stale/malformed webhooks, shared interception points, module load/unload, clean package installation, and the supported engine matrix. + +The `providers/cbpayments-monei` source in this repository is a complete example of an independently packageable Provider module with a dedicated transport and contract suite. diff --git a/docs/monei.md b/docs/monei.md new file mode 100644 index 0000000..f190800 --- /dev/null +++ b/docs/monei.md @@ -0,0 +1,97 @@ +# MONEI Provider guide + +The independently installable `cbpayments-monei` module supplies `MoneiProvider@cbpayments-monei`. It talks to the MONEI REST Payments API and normalizes its lifecycle into cbpayments contracts. + +## Installation and configuration + +Until the Provider has its own ForgeBox release, install the artifact built by this repository's PR/release pipeline. After publication, use `box install cbpayments-monei`. + +```boxlang +moduleSettings.cbpayments = { + defaultProcessor : "monei", + processors : { + monei : { + provider : "MoneiProvider@cbpayments-monei", + properties : { + apiKey : getSystemSetting( "MONEI_API_KEY" ), + accountId : getSystemSetting( "MONEI_ACCOUNT_ID", "" ), + toleranceSeconds : 300 + } + } + } +}; +``` + +MONEI's API key is sent in the server-side `Authorization` header and is also used to verify webhook signatures. `accountId` is required only for a connected merchant under MONEI Connect. Optional transport settings are `baseUrl` and `timeout`; `client` exists only for test doubles. + +## Capabilities + +MONEI supports `hostedCheckout`, `paymentIntents`, `capture`, `refunds`, and `webhooks`. It does not advertise cbpayments customer or setup-intent capabilities. + +## Hosted checkout + +Creating a `HostedCheckoutRequest` creates a deferred MONEI Payment. The normalized redirect URL in `result.getNextAction()` sends the customer to MONEI's hosted page. + +```boxlang +var checkoutRequest = wirebox.getInstance( + name = "HostedCheckoutRequest@cbpayments", + initArguments = { + money : orderMoney, + idempotencyKey : "order-42-checkout-v1", + returnUrl : "https://shop.example.com/orders/42/complete", + cancelUrl : "https://shop.example.com/orders/42/cancel", + description : "Order 42", + providerOptions : { + MONEI : { + callbackUrl : "https://shop.example.com/webhooks/monei", + allowedPaymentMethods : [ "card", "bizum" ] + } + } + } +); +var checkout = paymentService.createCheckout( checkoutRequest, "monei" ); +return relocate( url = checkout.getNextAction().redirectUrl ); +``` + +Hosted options are `callbackUrl`, `failUrl`, `allowedPaymentMethods`, and `expireAt`. Retrieve or expire the payment with `retrieveCheckout` and `expireCheckout`. + +## Authorization, confirmation, and capture + +`captureMethod="manual"` maps to MONEI's `AUTH` transaction type; automatic capture maps to `SALE`. + +```boxlang +var intentRequest = wirebox.getInstance( + name = "PaymentIntentRequest@cbpayments", + initArguments = { + money : orderMoney, + idempotencyKey : "order-42-authorize-v1", + captureMethod : "manual" + } +); +var authorization = paymentService.createPaymentIntent( intentRequest, "monei" ); + +var confirmed = paymentService.confirmPaymentIntent( + authorization.getExternalId(), + "order-42-confirm-v1", + { paymentToken : tokenFromMoneiJs }, + "monei" +); + +var capture = paymentService.capturePayment( captureRequest, "monei" ); +``` + +Confirmation options are `paymentToken`, `sessionId`, and `generatePaymentToken`. Payment tokens are sensitive references: do not log them or put them in metadata. + +## Refunds + +`createRefund` maps optional minor-unit `money` and `reason` to MONEI's refund endpoint. Supported reasons are determined by MONEI; use values such as `requested_by_customer`, `duplicated`, or `fraudulent`. Omit `money` for a full refund. + +MONEI returns the updated Payment for refund operations, so `retrieveRefund(externalId)` retrieves that Payment by ID. + +## Webhooks and callbacks + +The Provider accepts both MONEI account webhook envelopes (`charge.*` and `refund.*`) and the direct Payment object sent to a payment's `callbackUrl`. Pass the exact raw body and `MONEI-Signature` header. The signature is checked with HMAC-SHA256, timestamp tolerance, and constant-time comparison before parsing or announcing the event. + +MONEI event types are mapped into the shared cbpayments taxonomy. For example, `charge.authorized` becomes `payment.authorized` and announces `cbpaymentsOnPaymentAuthorized`; `charge.succeeded` becomes `payment.succeeded` and announces `cbpaymentsOnPaymentSucceeded`. + +See [Webhook processing](webhooks.md) for the route and application processing boundary. diff --git a/docs/providers.md b/docs/providers.md new file mode 100644 index 0000000..0f9048c --- /dev/null +++ b/docs/providers.md @@ -0,0 +1,128 @@ +# Providers, Processors, and normalized operations + +A **Provider** implements payment behavior. A **Processor** is one named, configured Provider instance. `PaymentService@cbpayments` is the application-facing registry and operation facade. + +## Access through WireBox + +```boxlang +component { + + property name="paymentService" inject="PaymentService@cbpayments"; + property name="defaultProcessor" inject="cbpayments"; + property name="receivablesProcessor" inject="cbpayments:receivables"; + +} +``` + +The service facade is preferred for application operations because it performs capability checks consistently: + +```boxlang +var result = paymentService.createCheckout( checkoutRequest ); +var namedResult = paymentService.createCheckout( checkoutRequest, "receivables" ); +``` + +## Registry API + +| Method | Result | +| --- | --- | +| `processor(name)` | Lazily construct and return a configured Processor | +| `defaultProcessor()` | Return the Processor named by `defaultProcessor` | +| `registerProcessor(name, provider, properties={}, override=false)` | Register a Processor using a full WireBox Provider ID | +| `unregisterProcessor(name)` | Shut down and remove a Processor | +| `hasProcessor(name)` / `missingProcessor(name)` | Case-insensitive existence checks | +| `processorNames()` | Sorted configured display names | +| `processorCount()` | Number of configured Processors | +| `capabilities(name)` | Capabilities advertised by a Processor | +| `supports(name, capability)` | Whether a Processor supports one capability | +| `shutdown()` / `reset()` | Shut down instances; `reset` also clears construction locks | + +## Operation API + +The final `processorName` argument is optional for every operation. When omitted, the default Processor is used. + +| Capability | PaymentService methods | +| --- | --- | +| `hostedCheckout` | `createCheckout(request, processorName="")`, `retrieveCheckout(externalId, processorName="")`, `expireCheckout(externalId, idempotencyKey, processorName="")` | +| `paymentIntents` | `createPaymentIntent(request, processorName="")`, `retrievePaymentIntent(externalId, processorName="")`, `confirmPaymentIntent(externalId, idempotencyKey, options={}, processorName="")`, `cancelPaymentIntent(externalId, idempotencyKey, processorName="")` | +| `capture` | `capturePayment(request, processorName="")` | +| `refunds` | `createRefund(request, processorName="")`, `retrieveRefund(externalId, processorName="")` | +| `setupIntents` | `createSetupIntent(request, processorName="")`, `retrieveSetupIntent(externalId, processorName="")`, `cancelSetupIntent(externalId, idempotencyKey, processorName="")` | +| `customers` | `createCustomer(request, processorName="")`, `retrieveCustomer(externalId, processorName="")`, `updateCustomer(externalId, request, processorName="")`, `deleteCustomer(externalId, idempotencyKey, processorName="")` | +| `webhooks` | `verifyWebhook(rawBody, signature, accountId="", processorName="")` | + +Unsupported capabilities throw `cbpayments.UnsupportedCapability` before a Provider is called. + +## Hosted checkout example + +```boxlang +var checkoutRequest = wirebox.getInstance( + name = "HostedCheckoutRequest@cbpayments", + initArguments = { + money : wirebox.getInstance( + name = "Money@cbpayments", + initArguments = { amountMinor : 4999, currency : "usd" } + ), + idempotencyKey : "order-100-checkout-v1", + returnUrl : "https://shop.example.com/orders/100/complete", + cancelUrl : "https://shop.example.com/orders/100/cancel" + } +); +var result = paymentService.createCheckout( checkoutRequest, "receivables" ); + +if ( result.getOk() && result.getNextAction().type == "redirect" ) { + return relocate( url = result.getNextAction().redirectUrl ); +} +``` + +## Manual authorization and capture example + +```boxlang +var intentRequest = wirebox.getInstance( + name = "PaymentIntentRequest@cbpayments", + initArguments = { + money : orderMoney, + idempotencyKey : "order-100-authorize-v1", + captureMethod : "manual" + } +); +var authorization = paymentService.createPaymentIntent( intentRequest, "receivables" ); + +if ( authorization.getStatus() == "authorized" ) { + var captureRequest = wirebox.getInstance( + name = "CaptureRequest@cbpayments", + initArguments = { + externalId : authorization.getExternalId(), + idempotencyKey : "order-100-capture-v1", + money : orderMoney + } + ); + var capture = paymentService.capturePayment( captureRequest, "receivables" ); +} +``` + +## Refund example + +```boxlang +if ( paymentService.supports( "receivables", "refunds" ) ) { + var refundRequest = wirebox.getInstance( + name = "RefundRequest@cbpayments", + initArguments = { + externalId : payment.providerPaymentId, + idempotencyKey : "payment-#payment.id#-refund-v1", + money : refundMoney, + reason : "requested_by_customer" + } + ); + var refund = paymentService.createRefund( refundRequest, "receivables" ); +} +``` + +## Results and failures + +`PaymentResult` provides getters and a safe `getMemento()` envelope containing `ok`, `operation`, `processorName`, `providerType`, normalized `status`, external/request IDs, idempotency key, timestamp, amount, next action, failure, and allow-listed Provider details. + +Expected remote failures return `ok=false`. `PaymentFailure` exposes `category`, `code`, safe `message`, `retryable`, and `declineCategory`. Categories are `declined`, `validation`, `authentication`, `rate_limited`, `network`, `provider`, and `unknown`. Programmer/configuration errors throw typed `cbpayments.*` exceptions. + +Unknown vendor statuses normalize to `unknown`. Sensitive client secrets are available only through `result.getClientAction()` when a flow requires them and are excluded from `getMemento()`. + +Every mutating request requires an idempotency key. `Money` accepts only integer minor units and validated ISO 4217 currencies. Provider-specific options must be nested by Provider type and are allow-listed by each adapter. diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..debce44 --- /dev/null +++ b/docs/security.md @@ -0,0 +1,15 @@ +# Security and go-live checklist + +- Keep card entry on Stripe-hosted Checkout or Stripe UI components. Never pass PAN, CVV, or bank credentials to cbpayments. +- Load API keys and webhook secrets from environment or managed secret storage. Do not place them in source, fixtures, logs, exception text, or CI artifacts. +- Allocate and persist idempotency keys in application state before every mutating Processor call. +- Do not hold a database transaction across a Processor network call. Persist pending state, call outside the transaction, then reconcile idempotently. +- Pass exact raw webhook bytes, verify before parsing, acknowledge promptly, and process from a durable tenant-scoped inbox. +- Authorize every operation and every read of a sensitive `ClientAction`; return a client secret only to its intended browser/client. +- Keep timeouts finite and retry only operations proven safe/idempotent. Honor rate-limit guidance. +- Restrict Provider-specific options to documented allow-lists. Treat `getClient()` as privileged, non-portable access. +- Treat every upstream response field as untrusted. cbpayments scrubs secret-like values and Luhn-valid PANs from normalized diagnostics, rejects malformed signed webhook envelopes, and drops non-HTTPS provider redirect URLs; applications must apply the same discipline when using `getClient()`. +- Verify test/live mode, Processor-to-business mapping, connected-account ownership, refunds/disputes policy, receipts, reconciliation, alerts, secret rotation, and incident contacts before launch. +- Run source and built-archive secret scans and inspect logs after adversarial failure tests. + +Report vulnerabilities through the repository security policy rather than a public issue. diff --git a/docs/stripe.md b/docs/stripe.md new file mode 100644 index 0000000..05743c6 --- /dev/null +++ b/docs/stripe.md @@ -0,0 +1,178 @@ +# Stripe Provider guide + +`StripeProvider@cbpayments` wraps stripe-cfml 4.1.0 and uses Stripe API version `2026-02-25.clover` by default. Each configured Processor creates an isolated Stripe client. + +## Configuration + +```boxlang +moduleSettings.cbpayments = { + defaultProcessor : "stripe", + processors : { + stripe : { + provider : "StripeProvider@cbpayments", + properties : { + apiKey : getSystemSetting( "STRIPE_API_KEY" ), + webhookSecrets : [ getSystemSetting( "STRIPE_WEBHOOK_SECRET" ) ], + apiVersion : "2026-02-25.clover", + defaultCurrency : "usd", + connectAccount : "", + toleranceSeconds : 300 + } + } + } +}; +``` + +`apiKey` is the secret server-side key. A publishable key is not needed by cbpayments. If your browser uses Stripe.js, give the publishable key directly to that browser integration through your application's public configuration. + +`webhookSecrets` is an ordered array so an old and new endpoint secret can overlap during rotation. `connectAccount` sends all calls for this Processor to one connected account. `client` is accepted only to inject a test double. + +## Capabilities + +Stripe supports `hostedCheckout`, `paymentIntents`, `capture`, `refunds`, `setupIntents`, `customers`, and `webhooks`. + +## Hosted Checkout + +Hosted Checkout is the default for an ordinary on-session payment: + +```boxlang +var checkoutRequest = wirebox.getInstance( + name = "HostedCheckoutRequest@cbpayments", + initArguments = { + money : orderMoney, + idempotencyKey : "order-42-checkout-v1", + returnUrl : "https://shop.example.com/orders/42/complete", + cancelUrl : "https://shop.example.com/orders/42/cancel", + description : "Order 42", + metadata : { orderId : "42" } + } +); +var result = paymentService.createCheckout( checkoutRequest, "stripe" ); + +if ( result.getOk() ) { + return relocate( url = result.getNextAction().redirectUrl ); +} +``` + +Retrieve or expire the Session using its normalized external ID: + +```boxlang +var current = paymentService.retrieveCheckout( checkout.externalId, "stripe" ); +var expired = paymentService.expireCheckout( + checkout.externalId, + "order-42-expire-v1", + "stripe" +); +``` + +Checkout-specific options are allow-listed beneath `providerOptions.Stripe`: `expiresAt`, `applicationFeeAmount`, `transferDestination`, and `onBehalfOf`. + +## Payment Intents + +Use a Payment Intent when the application independently models payment state, confirms later, processes off-session, or authorizes before capture: + +```boxlang +var intentRequest = wirebox.getInstance( + name = "PaymentIntentRequest@cbpayments", + initArguments = { + money : orderMoney, + idempotencyKey : "order-42-intent-v1", + captureMethod : "manual", + customerId : customer.stripeId, + paymentMethodId : savedPaymentMethodId, + providerOptions : { Stripe : { offSession : true } } + } +); +var intent = paymentService.createPaymentIntent( intentRequest, "stripe" ); +``` + +Confirm, retrieve, cancel, and capture through the normalized service: + +```boxlang +var confirmed = paymentService.confirmPaymentIntent( + intent.getExternalId(), + "order-42-confirm-v1", + { returnUrl : "https://shop.example.com/orders/42/complete" }, + "stripe" +); + +var latest = paymentService.retrievePaymentIntent( intent.getExternalId(), "stripe" ); + +var captureRequest = wirebox.getInstance( + name = "CaptureRequest@cbpayments", + initArguments = { + externalId : intent.getExternalId(), + idempotencyKey : "order-42-capture-v1", + money : orderMoney + } +); +var captured = paymentService.capturePayment( captureRequest, "stripe" ); +``` + +When Stripe requires browser action, `result.getClientAction()` carries the client secret. It is deliberately excluded from `getMemento()`, logs, and interception data. Never persist or log it. + +## Refunds + +```boxlang +var refundRequest = wirebox.getInstance( + name = "RefundRequest@cbpayments", + initArguments = { + externalId : payment.stripePaymentIntentId, + idempotencyKey : "payment-42-refund-v1", + money : refundMoney, + reason : "requested_by_customer", + metadata : { refundId : "RF-42" } + } +); +var refund = paymentService.createRefund( refundRequest, "stripe" ); +var current = paymentService.retrieveRefund( refund.getExternalId(), "stripe" ); +``` + +Omit `money` for a full refund. Stripe accepts only its supported reason values. + +## Customers and Setup Intents + +Create a Customer before attaching reusable payment methods: + +```boxlang +var customerRequest = wirebox.getInstance( + name = "CustomerRequest@cbpayments", + initArguments = { + idempotencyKey : "customer-42-create-v1", + email : user.email, + customerName : user.name, + metadata : { userId : user.id } + } +); +var customer = paymentService.createCustomer( customerRequest, "stripe" ); + +var setupRequest = wirebox.getInstance( + name = "SetupIntentRequest@cbpayments", + initArguments = { + idempotencyKey : "customer-42-setup-v1", + customerId : customer.getExternalId(), + usage : "off_session" + } +); +var setup = paymentService.createSetupIntent( setupRequest, "stripe" ); +``` + +Retrieve/cancel Setup Intents and retrieve/update/delete Customers through the corresponding `PaymentService` methods. + +## Connect + +Configure one Processor per connected account when a stable account boundary exists. For destination charges, the allow-listed Checkout/Intent options are `applicationFeeAmount`, `transferDestination`, and `onBehalfOf`. Choose the charge model in application policy; cbpayments does not retry a failed operation against a different account or Processor. + +## Webhooks + +Pass the exact raw body and `Stripe-Signature` header to `verifyWebhook`. The Provider verifies the timestamp and HMAC through stripe-cfml, accepts ordered secret rotation, validates the optional Connect account, maps Stripe event names into shared cbpayments event types, and announces interception points only after verification. + +See [Webhook processing](webhooks.md) for a complete route and interceptor example. + +## Errors and the raw client + +Stripe HTTP errors become normalized failures; transport timeouts become retryable `network` failures. Malformed success bodies become `provider/malformed_response`. Raw response bodies, headers, payment method IDs, and client secrets are not serialized. + +The underlying stripe-cfml instance is available from `paymentService.processor("stripe").getClient()` for unsupported vendor-specific work. Keep that escape hatch behind a small application adapter with dedicated tests so ordinary payment code remains portable. + +cbpayments does not expose Charges, Sources, Tokens, raw card collection, Card Element, or an application-level subscription scheduler. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..e125cda --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,9 @@ +# Testing + +Use `MockProvider@cbpayments` as the default consumer-test Provider. It implements the full 1.0 capability set without network access, records recursively sanitized requests, supports `enqueueResult( operation, outcome )`, and exposes `reset()` and `getRecordedRequests()`. + +Use `NullProvider@cbpayments` only where payments are intentionally disabled. It advertises hosted checkout alone and returns deterministic no-op results; it never pretends to be Stripe. + +The repository suite covers Processor validation, case-insensitive lookup, override/unregister, isolated construction locks, concurrent first access, lifecycle, DSL injection, module contributions, contracts, currency metadata, URL/metadata validation, redaction, safe interception data, every Stripe adapter operation, MONEI transport mapping, error classes, request IDs, client-secret isolation, signature rotation/tolerance, malformed bodies, account mismatch, shared webhook events, duplicates/out-of-order events, and real HMAC verification without network calls. + +Required CI runs ColdBox 8 on BoxLang 1 native and CFML compatibility, Lucee 6/7, and Adobe ColdFusion 2023/2025. Pull requests from branches in this repository also call the secret-bearing workflow with `STRIPE_API_KEY`, run the live test-mode contract, and scrub artifacts. Fork and Dependabot pull requests cannot receive the secret and skip only the live job; their mocked matrix remains required. The workflow also supports scheduled and manual runs. Webhook verification uses a per-run in-memory signing secret and makes no webhook network call. diff --git a/docs/webhooks.md b/docs/webhooks.md new file mode 100644 index 0000000..e62a16b --- /dev/null +++ b/docs/webhooks.md @@ -0,0 +1,127 @@ +# Webhook processing + +cbpayments verifies and normalizes webhooks, then announces provider-neutral ColdBox interception points. Your application owns the HTTP route, Processor selection, durable inbox, idempotent business work, and response. + +## Request flow + +1. Read the exact raw request body and vendor signature header. +2. Select the configured Processor for that endpoint/account. +3. Call `PaymentService.verifyWebhook`. +4. The Provider verifies signature, timestamp, and account before parsing/trusting the payload. +5. cbpayments returns a safe `PaymentEvent`, announces `cbpaymentsOnWebhookVerified`, then announces one shared typed interception point when the vendor event is mapped. +6. Persist an inbox record keyed by Processor plus external event ID, enqueue idempotent work, and promptly acknowledge the request. + +## ColdBox handler example + +Route Stripe and MONEI to separate actions so Processor selection does not depend on untrusted request data: + +```boxlang +post( "/webhooks/stripe", "PaymentWebhooks.stripe" ); +post( "/webhooks/monei", "PaymentWebhooks.monei" ); +``` + +```boxlang +component { + + property name="paymentService" inject="PaymentService@cbpayments"; + + function stripe( event, rc, prc ){ + return verify( + event, + "Stripe-Signature", + "receivables" + ); + } + + function monei( event, rc, prc ){ + return verify( + event, + "MONEI-Signature", + "europe" + ); + } + + private function verify( event, signatureHeader, processorName ){ + try { + var paymentEvent = paymentService.verifyWebhook( + rawBody = event.getHTTPContent(), + signature = event.getHTTPHeader( signatureHeader, "" ), + processorName = processorName + ); + return event.renderData( + type = "json", + data = { received : true, eventId : paymentEvent.getEventId() }, + statusCode = 200 + ); + } catch ( cbpayments.InvalidWebhookSignature exception ) { + return event.renderData( type = "json", data = { received : false }, statusCode = 401 ); + } catch ( cbpayments.StaleWebhook exception ) { + return event.renderData( type = "json", data = { received : false }, statusCode = 401 ); + } catch ( cbpayments.MalformedWebhook exception ) { + return event.renderData( type = "json", data = { received : false }, statusCode = 400 ); + } + } + +} +``` + +Never log the raw body or signature from a rejected webhook. + +## Processing a typed event + +Typed interception data contains a safe `paymentEvent` memento, not the raw Provider payload: + +```boxlang +component { + + property name="paymentInbox" inject="PaymentInbox"; + + function cbpaymentsOnPaymentSucceeded( event, interceptData ){ + var paymentEvent = interceptData.paymentEvent; + paymentInbox.recordOnce( + processorName = paymentEvent.processorName, + eventId = paymentEvent.eventId, + eventType = paymentEvent.eventType, + objectId = paymentEvent.objectId, + checksum = paymentEvent.payloadChecksum + ); + } + +} +``` + +Return success for an already-recorded event. Webhook delivery is duplicate-prone and may be out of order; retrieve current payment state when event order alone cannot decide the transition. + +## Shared event list + +Every Provider maps its vendor-specific names into this fixed list: + +| Normalized event | Interception point | +| --- | --- | +| `payment.authorized` | `cbpaymentsOnPaymentAuthorized` | +| `payment.processing` | `cbpaymentsOnPaymentProcessing` | +| `payment.requires_action` | `cbpaymentsOnPaymentRequiresAction` | +| `payment.succeeded` | `cbpaymentsOnPaymentSucceeded` | +| `payment.failed` | `cbpaymentsOnPaymentFailed` | +| `payment.canceled` | `cbpaymentsOnPaymentCanceled` | +| `payment.refunded` | `cbpaymentsOnPaymentRefunded` | +| `payment.partially_refunded` | `cbpaymentsOnPaymentPartiallyRefunded` | +| `checkout.completed` | `cbpaymentsOnCheckoutCompleted` | +| `checkout.expired` | `cbpaymentsOnCheckoutExpired` | +| `refund.succeeded` | `cbpaymentsOnRefundSucceeded` | +| `refund.processing` | `cbpaymentsOnRefundProcessing` | +| `refund.failed` | `cbpaymentsOnRefundFailed` | +| `setup.succeeded` | `cbpaymentsOnSetupSucceeded` | +| `setup.failed` | `cbpaymentsOnSetupFailed` | +| `setup.canceled` | `cbpaymentsOnSetupCanceled` | +| `customer.created` | `cbpaymentsOnCustomerCreated` | +| `customer.updated` | `cbpaymentsOnCustomerUpdated` | +| `customer.deleted` | `cbpaymentsOnCustomerDeleted` | + +Unknown vendor events return a verified `PaymentEvent` with `eventType="unknown"` and trigger only `cbpaymentsOnWebhookVerified`. The original allow-listed vendor name remains available as `providerEventType`. + +## PaymentEvent API + +`PaymentEvent` getters and `getMemento()` expose `eventId`, normalized `eventType`, `providerEventType`, `occurredAt`, `livemode`, `processorName`, `providerType`, safe Provider account/object identifiers, normalized status/amount, allow-listed Provider details, `payloadChecksum`, and `matchedSecretIndex`. The raw payload and secret are never retained. + +Distinct failures cover invalid signatures, malformed signed bodies, stale/future timestamps, missing webhook configuration, and Provider-account mismatches. diff --git a/dsl/cbpaymentsDSL.cfc b/dsl/cbpaymentsDSL.cfc new file mode 100644 index 0000000..5ad7b27 --- /dev/null +++ b/dsl/cbpaymentsDSL.cfc @@ -0,0 +1,34 @@ +/** + * WireBox DSL for named cbpayments processors. + * + * cbpayments => default Processor + * cbpayments:default => default Processor + * cbpayments:name => named Processor + */ +component accessors="true" { + + property name="injector"; + + function init( required any injector ){ + variables.injector = arguments.injector; + return this; + } + + function process( required definition, targetObject, targetID ){ + var segments = listToArray( arguments.definition.dsl, ":" ); + var service = variables.injector.getInstance( "PaymentService@cbpayments" ); + + if ( arrayLen( segments ) == 1 || ( arrayLen( segments ) == 2 && segments[ 2 ] == "default" ) ) { + return service.defaultProcessor(); + } + if ( arrayLen( segments ) == 2 && len( segments[ 2 ] ) ) { + return service.processor( segments[ 2 ] ); + } + + throw( + type = "cbpayments.IncorrectDsl", + message = "No cbpayments DSL is available for [#arguments.definition.dsl#]." + ); + } + +} diff --git a/helpers/Mixins.cfm b/helpers/Mixins.cfm new file mode 100644 index 0000000..01c5b36 --- /dev/null +++ b/helpers/Mixins.cfm @@ -0,0 +1,15 @@ + +/** + * Return the configured payment processor by name. + */ +function getPaymentProcessor( required string name ){ + return wirebox.getInstance( "PaymentService@cbpayments" ).processor( arguments.name ); +} + +/** + * Return the application's default payment processor. + */ +function getDefaultPaymentProcessor(){ + return wirebox.getInstance( "PaymentService@cbpayments" ).defaultProcessor(); +} + diff --git a/models/PaymentService.cfc b/models/PaymentService.cfc new file mode 100644 index 0000000..e89d012 --- /dev/null +++ b/models/PaymentService.cfc @@ -0,0 +1,540 @@ +/** + * Thread-safe registry and facade for configured payment processors. + */ +component accessors="true" singleton threadsafe { + + property name="appModules" inject="coldbox:setting:modules"; + property name="moduleSettings" inject="coldbox:moduleSettings:cbpayments"; + property name="wirebox" inject="wirebox"; + property name="log" inject="logbox:logger:{this}"; + + function init(){ + variables.processors = {}; + variables.processorConstructionLocks = {}; + variables.processorConstructionLocksLock = createObject( + "java", + "java.util.concurrent.locks.ReentrantLock" + ).init(); + return this; + } + + any function registerAppProcessors(){ + registerProcessorMap( variables.moduleSettings.processors ); + return this; + } + + any function registerModuleContributions(){ + if ( isNull( variables.appModules ) ) { + return this; + } + for ( var moduleName in variables.appModules ) { + var moduleConfig = variables.appModules[ moduleName ]; + if ( !moduleConfig.settings.keyExists( "cbpayments" ) ) { + continue; + } + var contribution = moduleConfig.settings.cbpayments; + if ( contribution.keyExists( "processors" ) ) { + registerProcessorMap( contribution.processors, "@#moduleName#" ); + } + if ( contribution.keyExists( "globalProcessors" ) ) { + registerProcessorMap( contribution.globalProcessors ); + } + } + return this; + } + + any function registerProcessor( + required string name, + required string provider, + struct properties = {}, + boolean override = false + ){ + var key = canonical( arguments.name ); + var processorLock = getProcessorConstructionLock( key ); + processorLock.lock(); + try { + if ( !len( key ) ) { + throw( type = "cbpayments.InvalidConfiguration", message = "Processor names cannot be empty." ); + } + if ( variables.processors.keyExists( key ) && !arguments.override ) { + throw( + type = "cbpayments.DuplicateProcessor", + message = "A payment processor named [#arguments.name#] is already registered." + ); + } + if ( variables.processors.keyExists( key ) && arguments.override ) { + unregisterProcessor( arguments.name ); + } + variables.processors[ key ] = { + "name" : arguments.name, + "provider" : arguments.provider, + "properties" : arguments.properties, + "registeredOn" : now(), + "createdOn" : "" + }; + } finally { + processorLock.unlock(); + } + return this; + } + + any function unregisterProcessor( required string name ){ + var key = canonical( arguments.name ); + var processorLock = getProcessorConstructionLock( key ); + processorLock.lock(); + try { + var record = getProcessorRecord( arguments.name ); + if ( record.keyExists( "instance" ) ) { + record.instance.shutdown(); + } + variables.processors.delete( key ); + } finally { + processorLock.unlock(); + } + return this; + } + + any function processor( required string name ){ + var key = canonical( arguments.name ); + var processorLock = getProcessorConstructionLock( key ); + processorLock.lock(); + try { + var record = getProcessorRecord( arguments.name ); + if ( !record.keyExists( "instance" ) ) { + var instance = buildProvider( record.provider ); + validateProviderContract( instance, record.provider ); + instance.startup( record.name, record.properties ); + variables.processors[ key ].instance = instance; + variables.processors[ key ].createdOn = now(); + } + return variables.processors[ key ].instance; + } finally { + processorLock.unlock(); + } + } + + any function defaultProcessor(){ + validateDefaultProcessor(); + return processor( variables.moduleSettings.defaultProcessor ); + } + + any function validateDefaultProcessor(){ + if ( + !variables.moduleSettings.keyExists( "defaultProcessor" ) + || !hasProcessor( variables.moduleSettings.defaultProcessor ) + ) { + throw( + type = "cbpayments.InvalidConfiguration", + message = "defaultProcessor must name a registered cbpayments processor definition." + ); + } + return this; + } + + any function validateSettings(){ + var allowed = [ + "defaultProcessor", + "processors", + "webhooks", + "logging" + ]; + for ( var key in variables.moduleSettings ) { + if ( !arrayFindNoCase( allowed, key ) ) { + throw( type = "cbpayments.InvalidConfiguration", message = "Unknown cbpayments setting [#key#]." ); + } + } + if ( !isStruct( variables.moduleSettings.processors ) ) { + throw( + type = "cbpayments.InvalidConfiguration", + message = "cbpayments processors setting must be a struct." + ); + } + if ( + !isStruct( variables.moduleSettings.webhooks ) + || !variables.moduleSettings.webhooks.keyExists( "toleranceSeconds" ) + || !isNumeric( variables.moduleSettings.webhooks.toleranceSeconds ) + || variables.moduleSettings.webhooks.toleranceSeconds < 0 + ) { + throw( + type = "cbpayments.InvalidConfiguration", + message = "cbpayments webhooks.toleranceSeconds must be a non-negative number." + ); + } + if ( + !isStruct( variables.moduleSettings.logging ) + || !variables.moduleSettings.logging.keyExists( "includeProviderRequestIds" ) + || !isBoolean( variables.moduleSettings.logging.includeProviderRequestIds ) + ) { + throw( + type = "cbpayments.InvalidConfiguration", + message = "cbpayments logging.includeProviderRequestIds must be boolean." + ); + } + return this; + } + + boolean function hasProcessor( required string name ){ + return variables.processors.keyExists( canonical( arguments.name ) ); + } + + boolean function missingProcessor( required string name ){ + return !hasProcessor( arguments.name ); + } + + array function processorNames(){ + var result = variables.processors + .keyArray() + .map( function( key ){ + return variables.processors[ key ].name; + } ); + result.sort( "textNoCase" ); + return result; + } + + numeric function processorCount(){ + return variables.processors.count(); + } + + array function capabilities( required string name ){ + return processor( arguments.name ).capabilities(); + } + + boolean function supports( required string name, required string capability ){ + return processor( arguments.name ).supports( arguments.capability ); + } + + any function shutdown(){ + var registeredNames = processorNames(); + registeredNames.each( function( name ){ + unregisterProcessor( name ); + } ); + return this; + } + + /** + * Shut down every instance and clear construction metadata during module unload/reinit. + */ + any function reset(){ + shutdown(); + variables.processorConstructionLocks = {}; + return this; + } + + any function createCheckout( required any request, string processorName = "" ){ + return dispatch( + arguments.processorName, + "hostedCheckout", + "createCheckout", + [ arguments.request ] + ); + } + + any function retrieveCheckout( required string externalId, string processorName = "" ){ + return dispatch( + arguments.processorName, + "hostedCheckout", + "retrieveCheckout", + [ arguments.externalId ] + ); + } + + any function expireCheckout( + required string externalId, + required string idempotencyKey, + string processorName = "" + ){ + return dispatch( + arguments.processorName, + "hostedCheckout", + "expireCheckout", + [ arguments.externalId, arguments.idempotencyKey ] + ); + } + + any function createPaymentIntent( required any request, string processorName = "" ){ + return dispatch( + arguments.processorName, + "paymentIntents", + "createPaymentIntent", + [ arguments.request ] + ); + } + + any function retrievePaymentIntent( required string externalId, string processorName = "" ){ + return dispatch( + arguments.processorName, + "paymentIntents", + "retrievePaymentIntent", + [ arguments.externalId ] + ); + } + + any function confirmPaymentIntent( + required string externalId, + required string idempotencyKey, + struct options = {}, + string processorName = "" + ){ + return dispatch( + arguments.processorName, + "paymentIntents", + "confirmPaymentIntent", + [ + arguments.externalId, + arguments.idempotencyKey, + arguments.options + ] + ); + } + + any function cancelPaymentIntent( + required string externalId, + required string idempotencyKey, + string processorName = "" + ){ + return dispatch( + arguments.processorName, + "paymentIntents", + "cancelPaymentIntent", + [ arguments.externalId, arguments.idempotencyKey ] + ); + } + + any function capturePayment( required any request, string processorName = "" ){ + return dispatch( + arguments.processorName, + "capture", + "capturePayment", + [ arguments.request ] + ); + } + + any function createRefund( required any request, string processorName = "" ){ + return dispatch( + arguments.processorName, + "refunds", + "createRefund", + [ arguments.request ] + ); + } + + any function retrieveRefund( required string externalId, string processorName = "" ){ + return dispatch( + arguments.processorName, + "refunds", + "retrieveRefund", + [ arguments.externalId ] + ); + } + + any function createSetupIntent( required any request, string processorName = "" ){ + return dispatch( + arguments.processorName, + "setupIntents", + "createSetupIntent", + [ arguments.request ] + ); + } + + any function retrieveSetupIntent( required string externalId, string processorName = "" ){ + return dispatch( + arguments.processorName, + "setupIntents", + "retrieveSetupIntent", + [ arguments.externalId ] + ); + } + + any function cancelSetupIntent( + required string externalId, + required string idempotencyKey, + string processorName = "" + ){ + return dispatch( + arguments.processorName, + "setupIntents", + "cancelSetupIntent", + [ arguments.externalId, arguments.idempotencyKey ] + ); + } + + any function createCustomer( required any request, string processorName = "" ){ + return dispatch( + arguments.processorName, + "customers", + "createCustomer", + [ arguments.request ] + ); + } + + any function retrieveCustomer( required string externalId, string processorName = "" ){ + return dispatch( + arguments.processorName, + "customers", + "retrieveCustomer", + [ arguments.externalId ] + ); + } + + any function updateCustomer( + required string externalId, + required any request, + string processorName = "" + ){ + return dispatch( + arguments.processorName, + "customers", + "updateCustomer", + [ arguments.externalId, arguments.request ] + ); + } + + any function deleteCustomer( + required string externalId, + required string idempotencyKey, + string processorName = "" + ){ + return dispatch( + arguments.processorName, + "customers", + "deleteCustomer", + [ arguments.externalId, arguments.idempotencyKey ] + ); + } + + any function verifyWebhook( + required string rawBody, + required string signature, + string accountId = "", + string processorName = "" + ){ + return dispatch( + arguments.processorName, + "webhooks", + "verifyWebhook", + [ + arguments.rawBody, + arguments.signature, + arguments.accountId + ] + ); + } + + private any function dispatch( + required string processorName, + required string capability, + required string method, + required array positionalArguments + ){ + var target = len( arguments.processorName ) ? processor( arguments.processorName ) : defaultProcessor(); + if ( !target.supports( arguments.capability ) ) { + throw( + type = "cbpayments.UnsupportedCapability", + message = "Processor [#target.getProcessorName()#] does not support [#arguments.capability#]." + ); + } + return invoke( + target, + arguments.method, + arguments.positionalArguments + ); + } + + private any function registerProcessorMap( required struct processorMap, string namespace = "" ){ + for ( var name in arguments.processorMap ) { + var definition = arguments.processorMap[ name ]; + validateProcessorDefinition( name, definition ); + registerProcessor( + name = name & namespace, + provider = definition.provider, + properties = definition.keyExists( "properties" ) ? definition.properties : {} + ); + } + return this; + } + + private void function validateProcessorDefinition( required string name, required any definition ){ + if ( !isStruct( arguments.definition ) || !arguments.definition.keyExists( "provider" ) ) { + throw( + type = "cbpayments.InvalidConfiguration", + message = "Processor definition [#arguments.name#] requires a provider key." + ); + } + if ( + !isSimpleValue( arguments.definition.provider ) + || !len( trim( arguments.definition.provider ) ) + || !find( "@", arguments.definition.provider ) + || ( arguments.definition.keyExists( "properties" ) && !isStruct( arguments.definition.properties ) ) + ) { + throw( + type = "cbpayments.InvalidConfiguration", + message = "Processor definition [#arguments.name#] requires a full WireBox provider ID and optional struct properties." + ); + } + for ( var key in arguments.definition ) { + if ( !arrayFindNoCase( [ "provider", "properties" ], key ) ) { + throw( + type = "cbpayments.InvalidConfiguration", + message = "Processor definition [#arguments.name#] has unknown key [#key#]." + ); + } + } + } + + private struct function getProcessorRecord( required string name ){ + var key = canonical( arguments.name ); + if ( !variables.processors.keyExists( key ) ) { + throw( + type = "cbpayments.UnknownProcessor", + message = "Payment processor [#arguments.name#] is not registered. Registered processors: #processorNames().toList()#." + ); + } + return variables.processors[ key ]; + } + + private any function buildProvider( required string provider ){ + return variables.wirebox.getInstance( arguments.provider ); + } + + private void function validateProviderContract( required any instance, required string provider ){ + var requiredMethods = [ + "startup", + "shutdown", + "getProcessorName", + "getProviderType", + "capabilities", + "supports", + "getClient" + ]; + var candidate = arguments.instance; + var providerID = arguments.provider; + requiredMethods.each( function( method ){ + if ( !structKeyExists( candidate, method ) ) { + throw( + type = "cbpayments.InvalidProviderContract", + message = "Provider [#providerID#] does not implement [#method#]." + ); + } + } ); + } + + private any function getProcessorConstructionLock( required string key ){ + variables.processorConstructionLocksLock.lock(); + try { + if ( !variables.processorConstructionLocks.keyExists( arguments.key ) ) { + variables.processorConstructionLocks[ arguments.key ] = createObject( + "java", + "java.util.concurrent.locks.ReentrantLock" + ).init(); + } + return variables.processorConstructionLocks[ arguments.key ]; + } finally { + variables.processorConstructionLocksLock.unlock(); + } + } + + private string function canonical( required string name ){ + return lCase( trim( arguments.name ) ); + } + +} diff --git a/models/contracts/IPaymentProvider.cfc b/models/contracts/IPaymentProvider.cfc new file mode 100644 index 0000000..d8010d9 --- /dev/null +++ b/models/contracts/IPaymentProvider.cfc @@ -0,0 +1,11 @@ +interface displayname="IPaymentProvider" { + + public any function startup( required string processorName, struct properties ); + public any function shutdown(); + public string function getProcessorName(); + public string function getProviderType(); + public array function capabilities(); + public boolean function supports( required string capability ); + public any function getClient(); + +} diff --git a/models/contracts/Money.cfc b/models/contracts/Money.cfc new file mode 100644 index 0000000..83711a4 --- /dev/null +++ b/models/contracts/Money.cfc @@ -0,0 +1,40 @@ +component accessors="true" { + + property name="amountMinor" type="numeric"; + property name="currency" type="string"; + property name="currencyMetadata" inject="CurrencyMetadata@cbpayments"; + + function init( + required numeric amountMinor, + required string currency, + any currencyMetadata + ){ + variables.currencyMetadata = isNull( arguments.currencyMetadata ) + ? new cbpayments.models.util.CurrencyMetadata() + : arguments.currencyMetadata; + if ( arguments.amountMinor != fix( arguments.amountMinor ) ) { + throw( + type = "cbpayments.InvalidMoney", + message = "Money must use an integer amountMinor; floating-point major units are not accepted." + ); + } + variables.amountMinor = arguments.amountMinor; + variables.currency = lCase( trim( arguments.currency ) ); + if ( !reFind( "^[a-z]{3}$", variables.currency ) ) { + throw( + type = "cbpayments.InvalidCurrency", + message = "Currency must be a three-letter ISO 4217 code." + ); + } + variables.currencyMetadata.exponent( variables.currency ); + return this; + } + + struct function getMemento(){ + return { + "amountMinor" : variables.amountMinor, + "currency" : variables.currency + }; + } + +} diff --git a/models/contracts/WebhookEventTypes.cfc b/models/contracts/WebhookEventTypes.cfc new file mode 100644 index 0000000..e0010ef --- /dev/null +++ b/models/contracts/WebhookEventTypes.cfc @@ -0,0 +1,58 @@ +/** + * Provider-neutral webhook event types and their shared interception points. + */ +component singleton { + + function init(){ + variables.states = { + "payment.authorized" : "cbpaymentsOnPaymentAuthorized", + "payment.processing" : "cbpaymentsOnPaymentProcessing", + "payment.requires_action" : "cbpaymentsOnPaymentRequiresAction", + "payment.succeeded" : "cbpaymentsOnPaymentSucceeded", + "payment.failed" : "cbpaymentsOnPaymentFailed", + "payment.canceled" : "cbpaymentsOnPaymentCanceled", + "payment.refunded" : "cbpaymentsOnPaymentRefunded", + "payment.partially_refunded" : "cbpaymentsOnPaymentPartiallyRefunded", + "checkout.completed" : "cbpaymentsOnCheckoutCompleted", + "checkout.expired" : "cbpaymentsOnCheckoutExpired", + "refund.succeeded" : "cbpaymentsOnRefundSucceeded", + "refund.processing" : "cbpaymentsOnRefundProcessing", + "refund.failed" : "cbpaymentsOnRefundFailed", + "setup.succeeded" : "cbpaymentsOnSetupSucceeded", + "setup.failed" : "cbpaymentsOnSetupFailed", + "setup.canceled" : "cbpaymentsOnSetupCanceled", + "customer.created" : "cbpaymentsOnCustomerCreated", + "customer.updated" : "cbpaymentsOnCustomerUpdated", + "customer.deleted" : "cbpaymentsOnCustomerDeleted" + }; + return this; + } + + array function eventTypes(){ + var result = []; + for ( var eventType in variables.states ) { + arrayAppend( result, eventType ); + } + result.sort( "textNoCase" ); + return result; + } + + array function interceptionPoints(){ + var result = []; + for ( var eventType in variables.states ) { + arrayAppend( result, variables.states[ eventType ] ); + } + result.sort( "textNoCase" ); + return result; + } + + boolean function supports( required string eventType ){ + return variables.states.keyExists( lCase( trim( arguments.eventType ) ) ); + } + + string function interceptionPoint( required string eventType ){ + var key = lCase( trim( arguments.eventType ) ); + return variables.states.keyExists( key ) ? variables.states[ key ] : ""; + } + +} diff --git a/models/contracts/capabilities/ICaptureProvider.cfc b/models/contracts/capabilities/ICaptureProvider.cfc new file mode 100644 index 0000000..1640dc2 --- /dev/null +++ b/models/contracts/capabilities/ICaptureProvider.cfc @@ -0,0 +1,5 @@ +interface displayname="ICaptureProvider" { + + public any function capturePayment( required any paymentRequest ); + +} diff --git a/models/contracts/capabilities/ICustomersProvider.cfc b/models/contracts/capabilities/ICustomersProvider.cfc new file mode 100644 index 0000000..12baed9 --- /dev/null +++ b/models/contracts/capabilities/ICustomersProvider.cfc @@ -0,0 +1,8 @@ +interface displayname="ICustomersProvider" { + + public any function createCustomer( required any paymentRequest ); + public any function retrieveCustomer( required string externalId ); + public any function updateCustomer( required string externalId, required any paymentRequest ); + public any function deleteCustomer( required string externalId, required string idempotencyKey ); + +} diff --git a/models/contracts/capabilities/IHostedCheckoutProvider.cfc b/models/contracts/capabilities/IHostedCheckoutProvider.cfc new file mode 100644 index 0000000..426b2a4 --- /dev/null +++ b/models/contracts/capabilities/IHostedCheckoutProvider.cfc @@ -0,0 +1,7 @@ +interface displayname="IHostedCheckoutProvider" { + + public any function createCheckout( required any paymentRequest ); + public any function retrieveCheckout( required string externalId ); + public any function expireCheckout( required string externalId, required string idempotencyKey ); + +} diff --git a/models/contracts/capabilities/IPaymentIntentsProvider.cfc b/models/contracts/capabilities/IPaymentIntentsProvider.cfc new file mode 100644 index 0000000..f2977bd --- /dev/null +++ b/models/contracts/capabilities/IPaymentIntentsProvider.cfc @@ -0,0 +1,12 @@ +interface displayname="IPaymentIntentsProvider" { + + public any function createPaymentIntent( required any paymentRequest ); + public any function retrievePaymentIntent( required string externalId ); + public any function confirmPaymentIntent( + required string externalId, + required string idempotencyKey, + struct options + ); + public any function cancelPaymentIntent( required string externalId, required string idempotencyKey ); + +} diff --git a/models/contracts/capabilities/IRefundsProvider.cfc b/models/contracts/capabilities/IRefundsProvider.cfc new file mode 100644 index 0000000..37b15b9 --- /dev/null +++ b/models/contracts/capabilities/IRefundsProvider.cfc @@ -0,0 +1,6 @@ +interface displayname="IRefundsProvider" { + + public any function createRefund( required any paymentRequest ); + public any function retrieveRefund( required string externalId ); + +} diff --git a/models/contracts/capabilities/ISetupIntentsProvider.cfc b/models/contracts/capabilities/ISetupIntentsProvider.cfc new file mode 100644 index 0000000..5023137 --- /dev/null +++ b/models/contracts/capabilities/ISetupIntentsProvider.cfc @@ -0,0 +1,7 @@ +interface displayname="ISetupIntentsProvider" { + + public any function createSetupIntent( required any paymentRequest ); + public any function retrieveSetupIntent( required string externalId ); + public any function cancelSetupIntent( required string externalId, required string idempotencyKey ); + +} diff --git a/models/contracts/capabilities/IWebhookProvider.cfc b/models/contracts/capabilities/IWebhookProvider.cfc new file mode 100644 index 0000000..f7fa5b3 --- /dev/null +++ b/models/contracts/capabilities/IWebhookProvider.cfc @@ -0,0 +1,9 @@ +interface displayname="IWebhookProvider" { + + public any function verifyWebhook( + required string rawBody, + required string signature, + string accountId + ); + +} diff --git a/models/contracts/requests/AbstractPaymentRequest.cfc b/models/contracts/requests/AbstractPaymentRequest.cfc new file mode 100644 index 0000000..6b71d3a --- /dev/null +++ b/models/contracts/requests/AbstractPaymentRequest.cfc @@ -0,0 +1,35 @@ +component accessors="true" { + + property name="idempotencyKey" type="string"; + property name="description" type="string"; + property name="metadata" type="struct"; + property name="providerOptions" type="struct"; + property name="securityValidator" inject="SecurityValidator@cbpayments"; + + function initBase( + required string idempotencyKey, + string description = "", + struct metadata = {}, + struct providerOptions = {}, + any securityValidator + ){ + variables.securityValidator = isNull( arguments.securityValidator ) + ? new cbpayments.models.util.SecurityValidator() + : arguments.securityValidator; + variables.idempotencyKey = variables.securityValidator.requireIdempotencyKey( arguments.idempotencyKey ); + variables.description = variables.securityValidator.validateDescription( arguments.description ); + variables.metadata = variables.securityValidator.validateMetadata( arguments.metadata ); + variables.providerOptions = arguments.providerOptions; + return this; + } + + struct function getBaseMemento(){ + return { + "idempotencyKey" : variables.idempotencyKey, + "description" : variables.description, + "metadata" : variables.metadata, + "providerOptions" : variables.providerOptions + }; + } + +} diff --git a/models/contracts/requests/CaptureRequest.cfc b/models/contracts/requests/CaptureRequest.cfc new file mode 100644 index 0000000..e6d701b --- /dev/null +++ b/models/contracts/requests/CaptureRequest.cfc @@ -0,0 +1,31 @@ +component accessors="true" extends="AbstractPaymentRequest" { + + property name="externalId" type="string"; + property name="money"; + + function init( + required string externalId, + required string idempotencyKey, + any money, + struct metadata = {}, + struct providerOptions = {}, + any securityValidator + ){ + initBase( argumentCollection = arguments ); + variables.externalId = trim( arguments.externalId ); + variables.money = isNull( arguments.money ) ? javacast( "null", "" ) : arguments.money; + if ( !len( variables.externalId ) ) { + throw( type = "cbpayments.InvalidRequest", message = "Capture requires an externalId." ); + } + return this; + } + + struct function getMemento(){ + var result = getBaseMemento().append( { "externalId" : variables.externalId, "money" : {} } ); + if ( !isNull( variables.money ) ) { + result.money = variables.money.getMemento(); + } + return result; + } + +} diff --git a/models/contracts/requests/CustomerRequest.cfc b/models/contracts/requests/CustomerRequest.cfc new file mode 100644 index 0000000..da7cb93 --- /dev/null +++ b/models/contracts/requests/CustomerRequest.cfc @@ -0,0 +1,31 @@ +component accessors="true" extends="AbstractPaymentRequest" { + + property name="email" type="string"; + property name="customerName" type="string"; + + function init( + required string idempotencyKey, + string email = "", + string customerName = "", + string description = "", + struct metadata = {}, + struct providerOptions = {}, + any securityValidator + ){ + initBase( argumentCollection = arguments ); + if ( len( arguments.email ) && !isValid( "email", arguments.email ) ) { + throw( type = "cbpayments.InvalidRequest", message = "Customer email is invalid." ); + } + variables.email = arguments.email; + variables.customerName = arguments.customerName; + return this; + } + + struct function getMemento(){ + return getBaseMemento().append( { + "email" : variables.email, + "name" : variables.customerName + } ); + } + +} diff --git a/models/contracts/requests/HostedCheckoutRequest.cfc b/models/contracts/requests/HostedCheckoutRequest.cfc new file mode 100644 index 0000000..921c11d --- /dev/null +++ b/models/contracts/requests/HostedCheckoutRequest.cfc @@ -0,0 +1,43 @@ +component accessors="true" extends="AbstractPaymentRequest" { + + property name="money"; + property name="returnUrl" type="string"; + property name="cancelUrl" type="string"; + property name="customerId" type="string"; + + function init( + required any money, + required string idempotencyKey, + required string returnUrl, + required string cancelUrl, + string description = "", + string customerId = "", + struct metadata = {}, + struct providerOptions = {}, + boolean allowLocalHttp = false, + any securityValidator + ){ + initBase( argumentCollection = arguments ); + variables.money = arguments.money; + variables.returnUrl = variables.securityValidator.validateUrl( + arguments.returnUrl, + arguments.allowLocalHttp + ); + variables.cancelUrl = variables.securityValidator.validateUrl( + arguments.cancelUrl, + arguments.allowLocalHttp + ); + variables.customerId = arguments.customerId; + return this; + } + + struct function getMemento(){ + return getBaseMemento().append( { + "money" : variables.money.getMemento(), + "returnUrl" : variables.returnUrl, + "cancelUrl" : variables.cancelUrl, + "customerId" : variables.customerId + } ); + } + +} diff --git a/models/contracts/requests/PaymentIntentRequest.cfc b/models/contracts/requests/PaymentIntentRequest.cfc new file mode 100644 index 0000000..43bc7cd --- /dev/null +++ b/models/contracts/requests/PaymentIntentRequest.cfc @@ -0,0 +1,39 @@ +component accessors="true" extends="AbstractPaymentRequest" { + + property name="money"; + property name="customerId" type="string"; + property name="paymentMethodId" type="string"; + property name="captureMethod" type="string"; + + function init( + required any money, + required string idempotencyKey, + string customerId = "", + string paymentMethodId = "", + string captureMethod = "automatic", + string description = "", + struct metadata = {}, + struct providerOptions = {}, + any securityValidator + ){ + initBase( argumentCollection = arguments ); + if ( !arrayFindNoCase( [ "automatic", "manual" ], arguments.captureMethod ) ) { + throw( type = "cbpayments.InvalidRequest", message = "captureMethod must be automatic or manual." ); + } + variables.money = arguments.money; + variables.customerId = arguments.customerId; + variables.paymentMethodId = arguments.paymentMethodId; + variables.captureMethod = lCase( arguments.captureMethod ); + return this; + } + + struct function getMemento(){ + return getBaseMemento().append( { + "money" : variables.money.getMemento(), + "customerId" : variables.customerId, + "paymentMethodId" : variables.paymentMethodId, + "captureMethod" : variables.captureMethod + } ); + } + +} diff --git a/models/contracts/requests/RefundRequest.cfc b/models/contracts/requests/RefundRequest.cfc new file mode 100644 index 0000000..f0b27fb --- /dev/null +++ b/models/contracts/requests/RefundRequest.cfc @@ -0,0 +1,38 @@ +component accessors="true" extends="AbstractPaymentRequest" { + + property name="externalId" type="string"; + property name="money"; + property name="reason" type="string"; + + function init( + required string externalId, + required string idempotencyKey, + any money, + string reason = "", + struct metadata = {}, + struct providerOptions = {}, + any securityValidator + ){ + initBase( argumentCollection = arguments ); + variables.externalId = trim( arguments.externalId ); + variables.money = isNull( arguments.money ) ? javacast( "null", "" ) : arguments.money; + variables.reason = arguments.reason; + if ( !len( variables.externalId ) ) { + throw( type = "cbpayments.InvalidRequest", message = "Refund requires an externalId." ); + } + return this; + } + + struct function getMemento(){ + var result = getBaseMemento().append( { + "externalId" : variables.externalId, + "money" : {}, + "reason" : variables.reason + } ); + if ( !isNull( variables.money ) ) { + result.money = variables.money.getMemento(); + } + return result; + } + +} diff --git a/models/contracts/requests/SetupIntentRequest.cfc b/models/contracts/requests/SetupIntentRequest.cfc new file mode 100644 index 0000000..20790b2 --- /dev/null +++ b/models/contracts/requests/SetupIntentRequest.cfc @@ -0,0 +1,31 @@ +component accessors="true" extends="AbstractPaymentRequest" { + + property name="customerId" type="string"; + property name="usage" type="string"; + + function init( + required string idempotencyKey, + string customerId = "", + string usage = "off_session", + string description = "", + struct metadata = {}, + struct providerOptions = {}, + any securityValidator + ){ + initBase( argumentCollection = arguments ); + if ( !arrayFindNoCase( [ "off_session", "on_session" ], arguments.usage ) ) { + throw( type = "cbpayments.InvalidRequest", message = "Setup Intent usage is invalid." ); + } + variables.customerId = arguments.customerId; + variables.usage = lCase( arguments.usage ); + return this; + } + + struct function getMemento(){ + return getBaseMemento().append( { + "customerId" : variables.customerId, + "usage" : variables.usage + } ); + } + +} diff --git a/models/contracts/results/ClientAction.cfc b/models/contracts/results/ClientAction.cfc new file mode 100644 index 0000000..a4893fc --- /dev/null +++ b/models/contracts/results/ClientAction.cfc @@ -0,0 +1,23 @@ +/** + * Sensitive client-side action data. It is intentionally excluded from default result mementos. + */ +component accessors="true" { + + property name="type" type="string"; + property name="clientSecret" type="string"; + + function init( required string type, string clientSecret = "" ){ + variables.type = arguments.type; + variables.clientSecret = arguments.clientSecret; + return this; + } + + struct function getMemento( boolean includeSensitive = false ){ + var value = { "type" : variables.type }; + if ( arguments.includeSensitive ) { + value.clientSecret = variables.clientSecret; + } + return value; + } + +} diff --git a/models/contracts/results/PaymentEvent.cfc b/models/contracts/results/PaymentEvent.cfc new file mode 100644 index 0000000..e0fadfd --- /dev/null +++ b/models/contracts/results/PaymentEvent.cfc @@ -0,0 +1,81 @@ +/** + * Verified, normalized webhook event. Raw payloads are never retained. + */ +component accessors="true" { + + property name="eventId" type="string"; + property name="eventType" type="string"; + property name="providerEventType" type="string"; + property name="occurredAt"; + property name="livemode" type="boolean"; + property name="processorName" type="string"; + property name="providerType" type="string"; + property name="providerAccountId" type="string"; + property name="objectType" type="string"; + property name="objectId" type="string"; + property name="status" type="string"; + property name="amount"; + property name="providerDetails" type="struct"; + property name="payloadChecksum" type="string"; + property name="matchedSecretIndex" type="numeric"; + + function init( + required string eventId, + required string eventType, + required string providerEventType, + required any occurredAt, + required boolean livemode, + required string processorName, + required string providerType, + string providerAccountId = "", + string objectType = "", + string objectId = "", + string status = "unknown", + any amount, + struct providerDetails = {}, + required string payloadChecksum, + numeric matchedSecretIndex = 0 + ){ + variables.eventId = arguments.eventId; + variables.eventType = arguments.eventType; + variables.providerEventType = arguments.providerEventType; + variables.occurredAt = arguments.occurredAt; + variables.livemode = arguments.livemode; + variables.processorName = arguments.processorName; + variables.providerType = arguments.providerType; + variables.providerAccountId = arguments.providerAccountId; + variables.objectType = arguments.objectType; + variables.objectId = arguments.objectId; + variables.status = arguments.status; + variables.amount = isNull( arguments.amount ) ? javacast( "null", "" ) : arguments.amount; + variables.providerDetails = arguments.providerDetails; + variables.payloadChecksum = arguments.payloadChecksum; + variables.matchedSecretIndex = arguments.matchedSecretIndex; + return this; + } + + struct function getMemento(){ + var result = { + "eventId" : variables.eventId, + "eventType" : variables.eventType, + "providerEventType" : variables.providerEventType, + "occurredAt" : variables.occurredAt, + "livemode" : variables.livemode, + "processorName" : variables.processorName, + "providerType" : variables.providerType, + "providerAccountId" : variables.providerAccountId, + "objectType" : variables.objectType, + "objectId" : variables.objectId, + "status" : variables.status, + "amount" : {}, + "providerDetails" : variables.providerDetails, + "payloadChecksum" : variables.payloadChecksum, + "matchedSecretIndex" : variables.matchedSecretIndex + }; + if ( !isNull( variables.amount ) ) { + result.amount = isObject( variables.amount ) ? variables.amount.getMemento() : variables.amount; + } + return result; + } + +} diff --git a/models/contracts/results/PaymentFailure.cfc b/models/contracts/results/PaymentFailure.cfc new file mode 100644 index 0000000..e362b98 --- /dev/null +++ b/models/contracts/results/PaymentFailure.cfc @@ -0,0 +1,49 @@ +component accessors="true" { + + property name="category" type="string"; + property name="code" type="string"; + property name="message" type="string"; + property name="retryable" type="boolean"; + property name="declineCategory" type="string"; + + function init( + required string category, + string code = "provider_error", + string message = "The payment provider could not complete the request.", + boolean retryable = false, + string declineCategory = "" + ){ + var allowed = [ + "declined", + "validation", + "authentication", + "rate_limited", + "network", + "provider", + "unknown" + ]; + if ( !allowed.findNoCase( arguments.category ) ) { + throw( + type = "cbpayments.InvalidFailure", + message = "Unknown failure category [#arguments.category#]." + ); + } + variables.category = lCase( arguments.category ); + variables.code = arguments.code; + variables.message = arguments.message; + variables.retryable = arguments.retryable; + variables.declineCategory = arguments.declineCategory; + return this; + } + + struct function getMemento(){ + return { + "category" : variables.category, + "code" : variables.code, + "message" : variables.message, + "retryable" : variables.retryable, + "declineCategory" : variables.declineCategory + }; + } + +} diff --git a/models/contracts/results/PaymentResult.cfc b/models/contracts/results/PaymentResult.cfc new file mode 100644 index 0000000..29cd936 --- /dev/null +++ b/models/contracts/results/PaymentResult.cfc @@ -0,0 +1,93 @@ +/** + * Stable, provider-neutral operation result. + */ +component accessors="true" { + + property name="ok" type="boolean"; + property name="operation" type="string"; + property name="processorName" type="string"; + property name="providerType" type="string"; + property name="status" type="string"; + property name="externalId" type="string"; + property name="requestId" type="string"; + property name="idempotencyKey" type="string"; + property name="createdAt" type="string"; + property name="amount"; + property name="nextAction" type="struct"; + property name="failure"; + property name="providerDetails" type="struct"; + property name="clientAction"; + + function init( + required boolean ok, + required string operation, + required string processorName, + required string providerType, + string status = "unknown", + string externalId = "", + string requestId = "", + string idempotencyKey = "", + any amount, + struct nextAction = {}, + any failure, + struct providerDetails = {}, + any clientAction + ){ + variables.ok = arguments.ok; + variables.operation = arguments.operation; + variables.processorName = arguments.processorName; + variables.providerType = arguments.providerType; + variables.status = normalizeStatus( arguments.status ); + variables.externalId = arguments.externalId; + variables.requestId = arguments.requestId; + variables.idempotencyKey = arguments.idempotencyKey; + variables.createdAt = dateTimeFormat( dateConvert( "local2Utc", now() ), "yyyy-mm-dd'T'HH:nn:ss'Z'" ); + variables.amount = isNull( arguments.amount ) ? javacast( "null", "" ) : arguments.amount; + variables.nextAction = arguments.nextAction; + variables.failure = isNull( arguments.failure ) ? javacast( "null", "" ) : arguments.failure; + variables.providerDetails = arguments.providerDetails; + variables.clientAction = isNull( arguments.clientAction ) ? javacast( "null", "" ) : arguments.clientAction; + return this; + } + + struct function getMemento(){ + var result = { + "ok" : variables.ok, + "operation" : variables.operation, + "processorName" : variables.processorName, + "providerType" : variables.providerType, + "status" : variables.status, + "externalId" : variables.externalId, + "requestId" : variables.requestId, + "idempotencyKey" : variables.idempotencyKey, + "createdAt" : variables.createdAt, + "amount" : {}, + "nextAction" : variables.nextAction, + "failure" : {}, + "providerDetails" : variables.providerDetails + }; + if ( !isNull( variables.amount ) ) { + result.amount = isObject( variables.amount ) ? variables.amount.getMemento() : variables.amount; + } + if ( !isNull( variables.failure ) ) { + result.failure = isObject( variables.failure ) ? variables.failure.getMemento() : variables.failure; + } + return result; + } + + private string function normalizeStatus( required string status ){ + var allowed = [ + "requires_action", + "pending", + "authorized", + "succeeded", + "failed", + "cancelled", + "partially_refunded", + "refunded", + "unknown" + ]; + return allowed.findNoCase( arguments.status ) ? lCase( arguments.status ) : "unknown"; + } + +} diff --git a/models/processor/AuthorizeNETProcessor.cfc b/models/processor/AuthorizeNETProcessor.cfc deleted file mode 100755 index e779f76..0000000 --- a/models/processor/AuthorizeNETProcessor.cfc +++ /dev/null @@ -1,8 +0,0 @@ -/** - * A cool processor/AuthorizeNET entity - */ -component extends="BaseProcessor" { - - -} - diff --git a/models/processor/BaseProcessor.cfc b/models/processor/BaseProcessor.cfc deleted file mode 100755 index 5449b62..0000000 --- a/models/processor/BaseProcessor.cfc +++ /dev/null @@ -1,278 +0,0 @@ -/** - * A Base processor utility - */ -component accessors="true" { - - // Global DI - property name="wirebox" inject="wirebox"; - property name="log" inject="logbox:logger:{this}"; - - /** - * Get a new reponse object - */ - function newResponse() provider="ProcessorResponse@cbpayments"{ - } - - /** - * Retrieve a human readable name for the processor - */ - function getName(){ - throw( "This method must be implemented in the child processor" ); - } - - /** - * If there is a version attached to the processor then return it here. - */ - function getVersion(){ - throw( "This method must be implemented in the child processor" ); - } - - /** - * Get the payment processor SDK library implementation. This will be getting the raw processor. - */ - any function getProcessor(){ - throw( "This method must be implemented in the child processor" ); - } - - /** - * Pre-authorizes a transaction on the processor without capture - * - * @amount The amount in cents to charge, example: $20 = 2000, $20.5 = 2050, it is required - * @source A payment source to be charged, usually this is a card token, a customer token, etc. It is required - * @currency Usually the three-letter ISO Currency code (Optional) - * @customerId A customer identifier to attach to the charge (Optional) - * @description The description of the charge (Optional) - * @headers A struct of headers to send with the processor (Optional) - * @metadata A struct of metadata to send to the processor (Optional) - */ - ProcessorResponse function preAuthorize( - required numeric amount, - required source, - currency = "usd", - customerId, - description = "", - struct metadata = {} - ){ - throw( "This method must be implemented in the child processor" ); - } - - /** - * Make a charge on the processor. Please note that any EXTRA arguments added to a processor - * The processor implementation must take care of them. - * - * @amount The amount in cents to charge, example: $20 = 2000, $20.5 = 2050, it is required - * @source A payment source to be charged, usually this is a card token, a customer token, etc. It is required - * @currency Usually the three-letter ISO Currency code (Optional) - * @customerId A customer identifier to attach to the charge (Optional) - * @description The description of the charge (Optional) - * @metadata A struct of metadata to send to the processor (Optional) - */ - ProcessorResponse function charge( - required numeric amount, - required source, - currency, - customerId, - description, - boolean capture = true, - struct metadata = {} - ){ - throw( "This method must be implemented in the child processor" ); - } - - /** - * Make a refund on the processor. Please note that any EXTRA arguments added to a processor - * The processor implementation must take care of them. - * - * @charge The identifier of the charge to refund. - * @amount The amount in cents to refund, if not sent then the entire charge is refunded (Optional) - * @reason A reason of why the refund (Optional) - * @headers A struct of headers to send with the processor (Optional) - * @metadata A struct of metadata to send to the processor (Optional) - */ - ProcessorResponse function refund( - required charge, - numeric amount, - reason, - struct metadata = {} - ){ - throw( "This method must be implemented in the child processor" ); - } - - /** - * Create a payment intent which can be used for recurring billing - * - * @amount The amount in cents to charge, example: $20 = 2000, $20.5 = 2050, it is required - * @currency Usually the three-letter ISO Currency code (Optional) - * @customerId A customer identifier to attach to the charge (Optional) - * @description The description of the charge (Optional) - */ - ProcessorResponse function createPaymentIntent( - required numeric amount, - required string customer, - required string payment_method, - string description = "", - string currency = "usd", - struct metadata = {} - ){ - throw( "This method must be implemented in the child processor" ); - } - - /** - * Create a subscription in the provider - * The processor implementation must take care of them. - * - * @providerCustomerId The provider customer Id - * @planId Plan Id in the provider - * @metadata A struct of metadata to send to the processor (Optional) - */ - ProcessorResponse function createSubscription( - required providerCustomerId, - required planId, - numeric quantity, - struct metadata = {} - ){ - throw( "This method must be implemented in the child processor" ); - } - - /** - * Get the subscription from the provider - * The processor implementation must take care of them. - * - * @subscriptionId The Subscription Id - * @metadata A struct of metadata to send to the processor (Optional) - */ - ProcessorResponse function getSubscription( required subscriptionId, struct metadata = {} ){ - throw( "This method must be implemented in the child processor" ); - } - - /** - * Cancel a subscription in the provider. - * Cancelling a subscription retains access through the end of the billing period. - * - * @subscriptionId Subscription Id - * @metadata A struct of metadata to send to the processor (Optional) - */ - ProcessorResponse function cancelSubscription( required subscriptionId, struct metadata = {} ){ - throw( "This method must be implemented in the child processor" ); - } - - /** - * Resume a subscription in the provider. - * - * @subscriptionId Subscription Id - * @metadata A struct of metadata to send to the processor (Optional) - */ - ProcessorResponse function resumeSubscription( required subscriptionId, struct metadata = {} ){ - throw( "This method must be implemented in the child processor" ); - } - - /** - * Update the subscription quantity in the provider. - * Cancelling a subscription retains access through the end of the billing period. - * - * @subscriptionId Subscription Id - * @quantity The new quantity, the subscription cost will be calculated based on this number - * @metadata A struct of metadata to send to the processor (Optional) - */ - ProcessorResponse function updateSubscriptionQuantity( - required subscriptionId, - required numeric quantity, - struct metadata = {} - ){ - throw( "This method must be implemented in the child processor" ); - } - - /** - * Delete a subscription in the provider - * The processor implementation must take care of them. - * - * @subscriptionId Subscription Id - * @metadata A struct of metadata to send to the processor (Optional) - */ - ProcessorResponse function deleteSubscription( required subscriptionId, struct metadata = {} ){ - throw( "This method must be implemented in the child processor" ); - } - - /** - * Convenience method to retrieve the customer associated with a subscription - * - * @subscriptionId The subscription Id - * @metadata A struct of metadata to send to the processor (Optional) - */ - ProcessorResponse function getSubscriptionCustomer( required subscriptionId, struct metadata ){ - throw( "This method must be implemented in the child processor" ); - } - - /** - * Create a customer in the provider - * The processor implementation must take care of them. - * - * @email Email of the new customer - * @paymentMethodId Payment Method Id of the provider - * @description Customer description, very handy info that could be found in the provider - * @metadata A struct of metadata to send to the processor (Optional) - */ - ProcessorResponse function createCustomer( - required email, - required paymentMethodId, - description, - struct metadata = {} - ){ - throw( "This method must be implemented in the child processor" ); - } - - /** - * Get the customer struct from the provider - * The processor implementation must take care of them. - * - * @providerCustomerId The provider customer Id - * @metadata A struct of metadata to send to the processor (Optional) - */ - ProcessorResponse function getCustomer( required providerCustomerId, struct metadata = {} ){ - throw( "This method must be implemented in the child processor" ); - } - - /** - * Get the customer payment method - * The processor implementation must take care of them. - * - * @providerCustomerId The provider customer Id - * @metadata A struct of metadata to send to the processor (Optional) - */ - ProcessorResponse function getPaymentMethod( required providerCustomerId, struct metadata = {} ){ - throw( "This method must be implemented in the child processor" ); - } - - /** - * Update the payment method associated to a customer - * The processor implementation must take care of them. - * - * @customerId The customer Id in the provider - * @paymentMethodId The Payment Method Id generated by the provider - * @metadata A struct of metadata to send to the processor (Optional) - */ - ProcessorResponse function updatePaymentMethod( - required providerCustomerId, - required paymentMethodId, - struct metadata = {} - ){ - throw( "This method must be implemented in the child processor" ); - } - - /** - * Change from one subscription plan to another one - * - * @planId The provider plan Id to change to - * @subscriptionId The subscription Id in the provider - * @metadata A struct of metadata to send to the processor (Optional) - */ - ProcessorResponse function changeSubscriptionPlan( - required planId, - required subscriptionId, - struct metadata = {} - ){ - throw( "This method must be implemented in the child processor" ); - } - -} - diff --git a/models/processor/IPaymentProcessor.cfc b/models/processor/IPaymentProcessor.cfc deleted file mode 100644 index f6e56ff..0000000 --- a/models/processor/IPaymentProcessor.cfc +++ /dev/null @@ -1,229 +0,0 @@ -/** - * This is the interface that every processor must implement in order to work with forgebox - */ -interface { - - /** - * Retrieve a human readable name for the processor - */ - function getName(); - - /** - * If there is a version attached to the processor then return it here. - */ - function getVersion(); - - /** - * Get the payment processor SDK library implementation. This will be getting the raw processor. - */ - any function getProcessor(); - - /** - * Pre-authorizes a transaction on the processor without capture - * - * @amount The amount in cents to charge, example: $20 = 2000, $20.5 = 2050, it is required - * @source A payment source to be charged, usually this is a card token, a customer token, etc. It is required - * @currency Usually the three-letter ISO Currency code (Optional) - * @customerId A customer identifier to attach to the charge (Optional) - * @description The description of the charge (Optional) - * @headers A struct of headers to send with the processor (Optional) - * @metadata A struct of metadata to send to the processor (Optional) - */ - ProcessorResponse function preAuthorize( - required numeric amount, - required source, - currency = "usd", - customerId, - description = "", - struct metadata = {} - ); - - /** - * Make a charge on the processor. Please note that any EXTRA arguments added to a processor - * The processor implementation must take care of them. - * - * @amount The amount in cents to charge, example: $20 = 2000, $20.5 = 2050, it is required - * @source A payment source to be charged, usually this is a card token, a customer token, etc. It is required - * @currency Usually the three-letter ISO Currency code (Optional) - * @customerId A customer identifier to attach to the charge (Optional) - * @description The description of the charge (Optional) - * @metadata A struct of metadata to send to the processor (Optional) - */ - ProcessorResponse function charge( - required numeric amount, - required source, - currency, - customerId, - description, - boolean capture = true, - struct metadata = {} - ); - - /** - * Make a refund on the processor. Please note that any EXTRA arguments added to a processor - * The processor implementation must take care of them. - * - * @charge The identifier of the charge to refund. - * @amount The amount in cents to refund, if not sent then the entire charge is refunded (Optional) - * @reason A reason of why the refund (Optional) - * @headers A struct of headers to send with the processor (Optional) - * @metadata A struct of metadata to send to the processor (Optional) - */ - ProcessorResponse function refund( - required charge, - numeric amount, - reason, - struct metadata = {} - ); - - /** - * Create a payment intent which can be used for recurring billing - * - * @amount The amount in cents to charge, example: $20 = 2000, $20.5 = 2050, it is required - * @currency Usually the three-letter ISO Currency code (Optional) - * @customerId A customer identifier to attach to the charge (Optional) - * @description The description of the charge (Optional) - */ - ProcessorResponse function createPaymentIntent( - required numeric amount, - required string customer, - required string payment_method, - string description = "", - string currency = "usd", - struct metadata = {} - ); - - /** - * Create a subscription in the provider - * The processor implementation must take care of them. - * - * @providerCustomerId The provider customer Id - * @planId Plan Id in the provider - * @metadata A struct of metadata to send to the processor (Optional) - */ - ProcessorResponse function createSubscription( - required providerCustomerId, - required planId, - numeric quantity, - struct metadata = {} - ); - - /** - * Get the subscription from the provider - * The processor implementation must take care of them. - * - * @subscriptionId The Subscription Id - * @metadata A struct of metadata to send to the processor (Optional) - */ - ProcessorResponse function getSubscription( required subscriptionId, struct metadata = {} ); - - /** - * Cancel a subscription in the provider. - * Cancelling a subscription retains access through the end of the billing period. - * - * @subscriptionId Subscription Id - * @metadata A struct of metadata to send to the processor (Optional) - */ - ProcessorResponse function cancelSubscription( required subscriptionId, struct metadata = {} ); - - /** - * Resume a subscription in the provider. - * - * @subscriptionId Subscription Id - * @metadata A struct of metadata to send to the processor (Optional) - */ - ProcessorResponse function resumeSubscription( required subscriptionId, struct metadata = {} ); - - /** - * Update the subscription quantity in the provider. - * Cancelling a subscription retains access through the end of the billing period. - * - * @subscriptionId Subscription Id - * @quantity The new quantity, the subscription cost will be calculated based on this number - * @metadata A struct of metadata to send to the processor (Optional) - */ - ProcessorResponse function updateSubscriptionQuantity( - required subscriptionId, - required numeric quantity, - struct metadata = {} - ); - - /** - * Delete a subscription in the provider - * The processor implementation must take care of them. - * - * @subscriptionId Subscription Id - * @metadata A struct of metadata to send to the processor (Optional) - */ - ProcessorResponse function deleteSubscription( required subscriptionId, struct metadata = {} ); - - /** - * Convenience method to retrieve the customer associated with a subscription - * - * @subscriptionId The subscription Id - * @metadata A struct of metadata to send to the processor (Optional) - */ - ProcessorResponse function getSubscriptionCustomer( required subscriptionId, struct metadata ); - - /** - * Create a customer in the provider - * The processor implementation must take care of them. - * - * @email Email of the new customer - * @paymentMethodId Payment Method Id of the provider - * @description Customer description, very handy info that could be found in the provider - * @metadata A struct of metadata to send to the processor (Optional) - */ - ProcessorResponse function createCustomer( - required email, - required paymentMethodId, - description, - struct metadata = {} - ); - - /** - * Get the customer struct from the provider - * The processor implementation must take care of them. - * - * @providerCustomerId The provider customer Id - * @metadata A struct of metadata to send to the processor (Optional) - */ - ProcessorResponse function getCustomer( required providerCustomerId, struct metadata = {} ); - - /** - * Get the customer payment method - * The processor implementation must take care of them. - * - * @providerCustomerId The provider customer Id - * @metadata A struct of metadata to send to the processor (Optional) - */ - ProcessorResponse function getPaymentMethod( required providerCustomerId, struct metadata = {} ); - - /** - * Update the payment method associated to a customer - * The processor implementation must take care of them. - * - * @customerId The customer Id in the provider - * @paymentMethodId The Payment Method Id generated by the provider - * @metadata A struct of metadata to send to the processor (Optional) - */ - ProcessorResponse function updatePaymentMethod( - required providerCustomerId, - required paymentMethodId, - struct metadata = {} - ); - - /** - * Change from one subscription plan to another one - * - * @planId The provider plan Id to change to - * @subscriptionId The subscription Id in the provider - * @metadata A struct of metadata to send to the processor (Optional) - */ - ProcessorResponse function changeSubscriptionPlan( - required planId, - required subscriptionId, - struct metadata = {} - ); - -} diff --git a/models/processor/PayPalProcessor.cfc b/models/processor/PayPalProcessor.cfc deleted file mode 100755 index c247328..0000000 --- a/models/processor/PayPalProcessor.cfc +++ /dev/null @@ -1,8 +0,0 @@ -/** - * A cool processor/PayPal entity - */ -component extends="BaseProcessor" { - - -} - diff --git a/models/processor/ProcessorResponse.cfc b/models/processor/ProcessorResponse.cfc deleted file mode 100644 index 1b21767..0000000 --- a/models/processor/ProcessorResponse.cfc +++ /dev/null @@ -1,35 +0,0 @@ -/** - * This response object is a standardized response for all payment processor gateways - */ -component accessors="true" { - - /** - * The error flag if there was an exception in the call - */ - property - name ="error" - type ="boolean" - default="false"; - - /** - * The raw content returned from the gateway, this can be any format - */ - property - name ="content" - type ="any" - default=""; - - /** - * Constructor - */ - function init(){ - // Init properties - variables.content = ""; - variables.error = false; - - return this; - } - - this.memento.defaultIncludes = [ "content", "error" ]; - -} diff --git a/models/processor/StripeProcessor.cfc b/models/processor/StripeProcessor.cfc deleted file mode 100755 index 88b976d..0000000 --- a/models/processor/StripeProcessor.cfc +++ /dev/null @@ -1,825 +0,0 @@ -/** - * A cool processor/Stripe entity - */ -component - extends ="BaseProcessor" - delegates ="DateTime@coreDelegates" - implements="IPaymentProcessor" - singleton -{ - - // DI - property name="stripe" inject="stripe@stripecfml"; - - /** - * Constructor - */ - function init(){ - return this; - } - - /** - * Retrieve a human readable name for the processor - */ - function getName(){ - return "Stripe CFML"; - } - - /** - * If there is a version attached to the processor then return it here. - */ - function getVersion(){ - return "1.x.x"; - } - - /** - * Get the payment processor SDK library implementation. This will be getting the raw processor. - */ - any function getProcessor(){ - return variables.stripe; - } - - /** - * Pre-authorizes a transaction on the processor without capture - * - * @amount The amount in cents to charge, example: $20 = 2000, $20.5 = 2050, it is required - * @source A payment source to be charged, usually this is a card token, a customer token, etc. It is required - * @currency Usually the three-letter ISO Currency code (Optional) - * @customerId A customer identifier to attach to the charge (Optional) - * @description The description of the charge (Optional) - * @headers A struct of headers to send with the processor (Optional) - * @metadata A struct of metadata to send to the processor (Optional) - * - * @return a struct containing the error and, if no error, the content of the [charge object](https://stripe.com/docs/api/charges/object) - */ - ProcessorResponse function preAuthorize( - required numeric amount, - required source, - currency = "usd", - customerId, - description = "", - struct metadata = {} - ){ - arguments.capture = false; - return charge( argumentCollection = arguments ); - } - - /** - * Captures a charge created via pre-authorization - * - * @chargeId - * - * @return a struct containing the error and, if no error, the content of the [charge object](https://stripe.com/docs/api/charges/object) - */ - ProcessorResponse function capture( required chargeId ){ - var oResponse = newResponse(); - - if ( log.canDebug() ) { - log.debug( "Stripe capture starting: #serializeJSON( arguments )#" ); - } - - var processorResponse = variables.stripe.charges.capture( charge_id = arguments.chargeId ); - - // Capture it baby! - oResponse.setContent( formatChargeResponse( processorResponse.content ) ); - - // Check for errors - if ( processorResponse.status >= 300 ) { - oResponse.setError( true ); - } - - if ( log.canDebug() ) { - log.debug( "Stripe capture response: #serializeJSON( oResponse.getContent() )#" ); - } - - return oResponse; - } - - /** - * Make a charge on the processor - * - * @amount The amount in cents to charge, example: $20 = 2000, $20.5 = 2050, it is required - * @source A payment source to be charged, usually this is a card token, a customer token, etc. It is required - * @currency Usually the three-letter ISO Currency code (Optional) - * @customerId A customer identifier to attach to the charge (Optional) - * @description The description of the charge (Optional) - * @headers A struct of headers to send with the processor (Optional) - * @metadata A struct of metadata to send to the processor (Optional) - * - * @return a struct containing the error and, if no error, the content of the [charge object](https://stripe.com/docs/api/charges/object) - */ - ProcessorResponse function charge( - required numeric amount, - required source, - currency = "usd", - customerId, - description = "", - boolean capture = true, - struct metadata = {} - ){ - var oResponse = newResponse(); - - if ( log.canDebug() ) { - log.debug( "Stripe charge starting: #serializeJSON( arguments )#" ); - } - - var processorResponse = variables.stripe.charges.create( argumentCollection = arguments ); - // Charge it baby! - oResponse.setContent( formatChargeResponse( processorResponse.content ) ); - - // Check for errors - if ( processorResponse.status >= 300 ) { - oResponse.setError( true ); - } - - if ( log.canDebug() ) { - log.debug( "Stripe charge response: #serializeJSON( oResponse.getContent() )#" ); - } - - return oResponse; - } - - /** - * Make a refund on the processor - * - * @charge The identifier of the charge to refund. - * @amount The amount in cents to refund, if not sent then the entire charge is refunded (Optional) - * @reason A reason of why the refund (Optional) - * @headers A struct of headers to send with the processor (Optional) - * @metadata A struct of metadata to send to the processor (Optional) - * - * @return a struct containing the error and, if no error, the content of the [refund object](https://stripe.com/docs/api/refunds/object) - */ - ProcessorResponse function refund( - required charge, - numeric amount, - reason = "", - struct metadata = {} - ){ - var oResponse = newResponse(); - - if ( log.canDebug() ) { - log.debug( "Stripe refund starting: #serializeJSON( arguments )#" ); - } - - var processorResponse = variables.stripe.refunds.create( argumentCollection = arguments ); - - // Charge it baby! - oResponse.setContent( processorResponse.content ); - - // Check for errors - if ( processorResponse.status >= 300 ) { - oResponse.setError( true ); - } - - if ( log.canDebug() ) { - log.debug( "Stripe refund response: #serializeJSON( oResponse.getContent() )#" ); - } - - return oResponse; - } - - /** - * Create a customer on Stripe so we can associate to a plan subscription - * - * @email email of the customer we are creating - * @paymentMethod Token provided by the stripe form with the payment method - * @description Customer description, very handy info that could be found in the provider - * @metadata A struct of metadata to send to the processor (Optional) - * - * @return a struct containing the error and, if no error, the content of the [customer object](https://docs.stripe.com/api/customers/object) - */ - ProcessorResponse function createCustomer( - required email, - required paymentMethodId, - description = "", - struct metadata = {} - ){ - var invoiceSettings = { "default_payment_method" : arguments.paymentMethodId }; - - var oResponse = newResponse(); - - if ( log.canDebug() ) { - log.debug( "Stripe customer creation starting: #serializeJSON( arguments )#" ); - } - - var processorResponse = variables.stripe.customers.create( - email = arguments.email, - payment_method = arguments.paymentMethodId, - description = arguments.description, - invoice_settings = invoiceSettings - ); - - // Create customer - oResponse.setContent( processorResponse.content ); - - // Check for errors - if ( processorResponse.status >= 300 ) { - oResponse.setError( true ); - } - - if ( log.canDebug() ) { - log.debug( "Stripe customer creation response: #serializeJSON( oResponse.getContent() )#" ); - } - - return oResponse; - } - - /** - * Retrieves a list of customers in the stripe system - * - * @limit The max rows to return - * @offset The offset to start the list - * @metadata - * - * @return a struct containing the error and, if no error, the content of the [customers list](https://docs.stripe.com/api/customers/list) - */ - ProcessorResponse function listCustomers( - numeric limit = 10, - numeric offset = 0, - struct metadata = {} - ){ - var oResponse = newResponse(); - - if ( log.canDebug() ) { - log.debug( "Stripe list customers starting: #serializeJSON( arguments )#" ); - } - - var processorResponse = variables.stripe.customers.list( - limit = arguments.limit, - offset = arguments.offset - ); - - // List customers - oResponse.setContent( processorResponse.content ); - - // Check for errors - if ( processorResponse.status >= 300 ) { - oResponse.setError( true ); - } - - if ( log.canDebug() ) { - log.debug( "Stripe list customers response: #serializeJSON( oResponse.getContent() )#" ); - } - - return oResponse; - } - - /** - * Get the customer struct from the provider - * - * @providerCustomerId The provider customer Id - * @metadata A struct of metadata to send to the processor (Optional)* @return a struct containing the error and, if no error, the content of the [customer object](https://docs.stripe.com/api/customers/object) - */ - ProcessorResponse function getCustomer( required providerCustomerId, struct metadata ){ - var customer = {}; - var oResponse = newResponse(); - - if ( log.canDebug() ) { - log.debug( "Stripe get customer starting: #serializeJSON( arguments )#" ); - } - - oResponse.setContent( {} ); - - // Find customer - var customer = variables.stripe.customers.retrieve( arguments.providerCustomerId ); - if ( structKeyExists( customer, "content" ) && !structKeyExists( customer.content, "error" ) ) { - oResponse.setContent( customer.content ); - } else { - oResponse.setError( true ); - return oResponse; - } - - // Check for additional errors - if ( customer.status >= 300 ) { - oResponse.setError( true ); - } - - if ( log.canDebug() ) { - log.debug( "Stripe get customer response: #serializeJSON( oResponse.getContent() )#" ); - } - - return oResponse; - } - - - /** - * Make a charge on the processor - * TODO: Add this to the interface - * - * @amount The amount in cents to charge, example: $20 = 2000, $20.5 = 2050, it is required - * @currency Usually the three-letter ISO Currency code (Optional) - * @customerId A customer identifier to attach to the charge (Optional) - * @description The description of the charge (Optional) - */ - ProcessorResponse function createSetupIntent( - required string customer, - string description = "", - string currency = "usd", - string usage = "off_session", - boolean attach_to_self = false, - string flow_directions = "inbound", - struct metadata = {} - ){ - var oResponse = newResponse(); - - if ( log.canDebug() ) { - log.debug( "Stripe setup intent creation request: #serializeJSON( arguments )#" ); - } - - var processorResponse = variables.stripe.setupIntents.create( argumentCollection = arguments ); - - oResponse.setContent( processorResponse.content ); - - // Check for errors - if ( processorResponse.status >= 300 ) { - oResponse.setError( true ); - } - - if ( log.canDebug() ) { - log.debug( "Stripe setup intent creation response: #serializeJSON( oResponse.getContent() )#" ); - } - - return oResponse; - } - - ProcessorResponse function getSetupIntent( required intentId ){ - } - - /** - * Make a charge on the processor - * TODO: Add this to the interface - * - * @amount The amount in cents to charge, example: $20 = 2000, $20.5 = 2050, it is required - * @currency Usually the three-letter ISO Currency code (Optional) - * @customerId A customer identifier to attach to the charge (Optional) - * @description The description of the charge (Optional) - */ - ProcessorResponse function createPaymentIntent( - required numeric amount, - required string customer, - required string payment_method, - string description = "", - string currency = "usd", - struct metadata = {} - ){ - var oResponse = newResponse(); - - if ( log.canDebug() ) { - log.debug( "Stripe payment intent creation request: #serializeJSON( arguments )#" ); - } - - var processorResponse = variables.stripe.paymentIntents.create( argumentCollection = arguments ); - - oResponse.setContent( processorResponse.content ); - - // Check for errors - if ( processorResponse.status >= 300 ) { - oResponse.setError( true ); - } - - if ( log.canDebug() ) { - log.debug( "Stripe payment intent creation response: #serializeJSON( oResponse.getContent() )#" ); - } - - return oResponse; - } - - - - /** - * Retrieve the payment intent status - * - * @providerCustomerId - * @planId - * @quantity - * @metadata - */ - public string function fetchPaymentIntentStatus( required string paymentIntentId ){ - var oResponse = newResponse(); - - if ( log.canDebug() ) { - log.debug( "Stripe payment intent status request: #serializeJSON( arguments )#" ); - } - - var processorResponse = variables.stripe.paymentIntents.retrieve( arguments.paymentIntentId ); - - oResponse.setContent( processorResponse.content ); - - // Check for errors - if ( processorResponse.status >= 300 ) { - oResponse.setError( true ); - } - - if ( log.canDebug() ) { - log.debug( "Stripe payment intent status response: #serializeJSON( oResponse.getContent() )#" ); - } - - return oResponse; - } - - - /** - * Create a subscription, combine a plan with a customer - * - * @plan Plan to associate to the subscription - * @customer Customer to associate the subscription plan with - * @metadata A struct of metadata to send to the processor (Optional) - * - * @return a struct containing the error and, if no error, the content of the [subscription object](https://stripe.com/docs/api/subscriptions/object) - */ - ProcessorResponse function createSubscription( - required providerCustomerId, - required planId, - numeric quantity = 1, - struct metadata = {} - ){ - var oResponse = newResponse(); - - if ( log.canDebug() ) { - log.debug( "Stripe subscription creation starting: #serializeJSON( arguments )#" ); - } - - var processorResponse = variables.stripe.subscriptions.create( - customer = arguments.providerCustomerId, - items = [ - { - "plan" : arguments.planId, - "quantity" : arguments.quantity - } - ], - expand = [ "latest_invoice.payment_intent" ] - ); - // Create customer - oResponse.setContent( processorResponse.content ); - - // Check for errors - if ( processorResponse.status >= 300 ) { - oResponse.setError( true ); - } - - if ( log.canDebug() ) { - log.debug( "Stripe subscription creation response: #serializeJSON( oResponse.getContent() )#" ); - } - - return oResponse; - } - - /** - * Cancel a subscription in the provider. - * Cancelling a subscription retains access through the end of the billing period. - * - * @subscriptionId Subscription Id - * @metadata A struct of metadata to send to the processor (Optional) - * - * @return a struct containing the error and, if no error, the content of the cancelled [subscription object](https://stripe.com/docs/api/subscriptions/object) - */ - ProcessorResponse function cancelSubscription( required subscriptionId, struct metadata = {} ){ - var oResponse = newResponse(); - - var processorResponse = variables.stripe.subscriptions.update( - arguments.subscriptionId, - { cancel_at_period_end : true } - ); - // Associate payment method as default - oResponse.setContent( processorResponse.content ); - - // Check for errors - if ( processorResponse.status >= 300 ) { - oResponse.setError( true ); - } - - return oResponse; - } - - /** - * Resume a subscription in the provider. - * - * @subscriptionId Subscription Id - * @metadata A struct of metadata to send to the processor (Optional) - * - * @return a struct containing the error and, if no error, the content of the resumed [subscription object](https://stripe.com/docs/api/subscriptions/object) - */ - ProcessorResponse function resumeSubscription( required subscriptionId, struct metadata = {} ){ - var oResponse = newResponse(); - - var processorResponse = variables.stripe.subscriptions.update( - arguments.subscriptionId, - { cancel_at_period_end : false } - ); - - // Associate payment method as default - oResponse.setContent( processorResponse.content ); - - // Check for errors - if ( processorResponse.status >= 300 ) { - oResponse.setError( true ); - } - - return oResponse; - } - - /** - * Update the subscription quantity in the provider. - * Cancelling a subscription retains access through the end of the billing period. - * - * @subscriptionId Subscription Id - * @quantity The new quantity, the subscription cost will be calculated based on this number - * @metadata A struct of metadata to send to the processor (Optional) - * - * @return a struct containing the error and, if no error, the content of the updated [subscription object](https://stripe.com/docs/api/subscriptions/object) - */ - ProcessorResponse function updateSubscriptionQuantity( - required subscriptionId, - required numeric quantity, - struct metadata = {} - ){ - var oResponse = newResponse(); - - var processorResponse = variables.stripe.subscriptions.update( - arguments.subscriptionId, - { quantity : arguments.quantity } - ); - - // Associate payment method as default - oResponse.setContent( processorResponse.content ); - - // Check for errors - if ( processorResponse.status >= 300 ) { - oResponse.setError( true ); - } - - return oResponse; - } - - /** - * Get the payment method struct from the provider - * - * @subscriptionId The subscription Id - * @metadata A struct of metadata to send to the processor (Optional) - * - * @return returns a struct containing error information and, if no error, the content of the [subscription object](https://stripe.com/docs/api/subscriptions/object) - */ - ProcessorResponse function getSubscription( required subscriptionId, struct metadata ){ - var subscription = {}; - var oResponse = newResponse(); - - if ( log.canDebug() ) { - log.debug( "Stripe get provider subscription method starting: #serializeJSON( arguments )#" ); - } - - oResponse.setContent( {} ); - - // Find subscription in stripe - var subscription = variables.stripe.subscriptions.retrieve( arguments.subscriptionId ) - if ( structKeyExists( subscription, "content" ) && !structKeyExists( subscription.content, "error" ) ) { - oResponse.setContent( subscription.content ); - } else { - oResponse.setError( true ); - return oResponse; - } - - if ( log.canDebug() ) { - log.debug( "Stripe get provider subscription response: #serializeJSON( oResponse.getContent() )#" ); - } - - return oResponse; - } - - /** - * Get the customer object from the provider - * - * @subscriptionId The subscription Id - * @metadata A struct of metadata to send to the processor (Optional) - * - * @return struct of: https://stripe.com/docs/api/customers - */ - ProcessorResponse function getSubscriptionCustomer( required subscriptionId, struct metadata ){ - var subscription = {}; - var oResponse = newResponse(); - - if ( log.canDebug() ) { - log.debug( - "Stripe get provider customer by subscription id method starting: #serializeJSON( arguments )#" - ); - } - - oResponse.setContent( {} ); - - // Find subscription - var subscription = getSubscription( arguments.subscriptionId ); - if ( subscription.getError() ) { - oResponse.setError( true ); - return oResponse; - } - - var customer = getCustomer( subscription.getContent().content?.customer ?: "" ); - - if ( customer.getError() ) { - oResponse.setError( true ); - return oResponse; - } - - // Set content - oResponse.setContent( customer.getContent().content ); - - if ( log.canDebug() ) { - log.debug( - "Stripe get provider customer by subscription id method response: #serializeJSON( oResponse.getContent() )#" - ); - } - - return oResponse; - } - - /** - * Get the payment method struct from the provider - * - * @providerCustomerId The provider customer Id to get the payment method from - * @metadata A struct of metadata to send to the processor (Optional) - * - * @return struct of: https://stripe.com/docs/api/payment_methods - */ - ProcessorResponse function getPaymentMethod( required providerCustomerId, struct metadata ){ - var customer = {}; - var oResponse = newResponse(); - - if ( log.canDebug() ) { - log.debug( "Stripe get payment method starting: #serializeJSON( arguments )#" ); - } - - oResponse.setContent( {} ); - - // Get customer struct payment method - var customer = getCustomer( providerCustomerId ); - if ( customer.getError() ) { - oResponse.setError( true ); - return oResponse; - } - var customerHasPaymentMethod = customer.getContent().content?.invoice_settings?.default_payment_method neq "" ? true : false; - - // Validate and retrieve the customer payment method - if ( customerHasPaymentMethod ) { - oResponse.setContent( - variables.stripe.paymentMethods.retrieve( - customer.getContent().content.invoice_settings.default_payment_method - ).content - ); - } else { - oResponse.setError( true ); - } - - if ( log.canDebug() ) { - log.debug( "Stripe get payment method response: #serializeJSON( oResponse.getContent() )#" ); - } - - return oResponse; - } - - /** - * Update payment method - * - * @customerId Customer provider ID - * @paymentMethodId Payment Method - */ - ProcessorResponse function updatePaymentMethod( - required providerCustomerId, - required paymentMethodId, - struct metadata = {} - ){ - var oResponse = newResponse(); - - if ( log.canDebug() ) { - log.debug( "Stripe Update Payment Method starting: #serializeJSON( arguments )#" ); - } - - // Create payment Method - var paymentMethod = variables.stripe.paymentMethods.attach( - arguments.paymentMethodId, - { customer : arguments.providerCustomerId } - ).content; - - var processorResponse = variables.stripe.customers.update( - arguments.providerCustomerId, - { invoice_settings : { default_payment_method : paymentMethod.id } } - ); - // Associate payment method as default - - oResponse.setContent( processorResponse.content ); - - // Check for errors - if ( processorResponse.status >= 300 ) { - oResponse.setError( true ); - } - - if ( log.canDebug() ) { - log.debug( "Stripe Update Payment Method response: #serializeJSON( oResponse.getContent() )#" ); - } - - return oResponse; - } - - /** - * Change from one subscription plan to another one - * - * @planId The provider plan Id to change to - * @subscriptionId The subscription Id - * @metadata A struct of metadata to send to the processor (Optional) - */ - ProcessorResponse function changeSubscriptionPlan( - required planId, - required subscriptionId, - struct metadata = {} - ){ - var oResponse = newResponse(); - - if ( log.canDebug() ) { - log.debug( "Stripe Change Subscription Plan starting: #serializeJSON( arguments )#" ); - } - - // Retrieve subscription - var subscription = variables.stripe.subscriptions.retrieve( arguments.subscriptionId ).content; - - var processorResponse = variables.stripe.subscriptions.update( - arguments.subscriptionId, - { - cancel_at_period_end : false, - proration_behavior : "create_prorations", - items : [ - { - id : subscription.items.data[ 1 ].id, - plan : arguments.planId - } - ] - } - ); - - // Associate payment method as default - oResponse.setContent( processorResponse.content ); - - // Check for errors - if ( processorResponse.status >= 300 ) { - oResponse.setError( true ); - } - - if ( log.canDebug() ) { - log.debug( "Stripe Change Subscription Plan response: #serializeJSON( oResponse.getContent() )#" ); - } - - return oResponse; - } - - /** - * Validate a promotion code - * - * @code The promotion code to validate - */ - ProcessorResponse function validatePromotionCode( required code ){ - var oResponse = newResponse(); - - var promotionCodes = variables.stripe.promotionCodes.list( { code : code, active : true, limit : 1 } ).content; - - if ( promotionCodes.data.len() <= 0 ) { - oResponse.setError( true ); - oResponse.setContent( "Not promotion code found for [#code#]." ); - return oResponse; - } - - oResponse.setContent( promotionCodes.data[ 1 ] ); - return oResponse; - } - - /** - * Returns an ISO formatted date from unixSeconds - * - * @epochSeconds - */ - string function fromUnixSeconds( required numeric epochSeconds ){ - return getISOTime( - dateAdd( - "s", - arguments.epochSeconds, - "1970-01-01T00:00:00Z" - ) - ); - } - - /** - * Returns a struct with the formatted content of the charge response - * - * @content - */ - struct function formatChargeResponse( required struct content ){ - var remove = [ "calculated_statement_descriptor" ]; - content[ "processor" ] = content.calculated_statement_descriptor ?: "Stripe"; - content.created = content.keyExists( "created" ) ? fromUnixSeconds( content.created ) : javacast( - "null", - "" - ); - remove.each( ( key ) => { - structDelete( content, key ); - } ); - return content; - } - -} diff --git a/models/providers/AbstractPaymentProvider.cfc b/models/providers/AbstractPaymentProvider.cfc new file mode 100644 index 0000000..b0a85f3 --- /dev/null +++ b/models/providers/AbstractPaymentProvider.cfc @@ -0,0 +1,225 @@ +/** + * Shared provider lifecycle, capability, result, and safe observability behavior. + */ +component accessors="true" implements="cbpayments.models.contracts.IPaymentProvider" { + + property name="processorName" type="string"; + property name="identifier" type="string"; + property name="providerType" type="string"; + property name="properties" type="struct"; + property name="started" type="boolean"; + property name="client"; + property name="supportedCapabilities" type="array"; + property name="interceptorService" inject="coldbox:InterceptorService"; + property name="moduleSettings" inject="coldbox:moduleSettings:cbpayments"; + property name="redactor" inject="Redactor@cbpayments"; + property name="webhookEventTypes" inject="WebhookEventTypes@cbpayments"; + property name="wirebox" inject="wirebox"; + + function init(){ + variables.identifier = createUUID(); + variables.processorName = ""; + variables.providerType = "Abstract"; + variables.properties = {}; + variables.started = false; + variables.client = javacast( "null", "" ); + variables.supportedCapabilities = []; + variables.redactor = new cbpayments.models.util.Redactor(); + variables.webhookEventTypes = new cbpayments.models.contracts.WebhookEventTypes(); + variables.securityValidator = new cbpayments.models.util.SecurityValidator( variables.redactor ); + return this; + } + + string function requireExternalId( required string externalId ){ + var value = trim( arguments.externalId ); + if ( !len( value ) ) { + throw( type = "cbpayments.InvalidRequest", message = "A non-empty externalId is required." ); + } + return value; + } + + string function requireIdempotencyKey( required string idempotencyKey ){ + return variables.securityValidator.requireIdempotencyKey( arguments.idempotencyKey ); + } + + any function startup( required string processorName, struct properties = {} ){ + variables.processorName = arguments.processorName; + variables.properties = arguments.properties; + variables.started = true; + announce( "cbpaymentsOnProcessorStart", safeContext() ); + return this; + } + + any function shutdown(){ + if ( variables.started ) { + variables.started = false; + announce( "cbpaymentsOnProcessorShutdown", safeContext() ); + } + variables.client = javacast( "null", "" ); + return this; + } + + boolean function hasStarted(){ + return variables.started; + } + + string function getIdentifier(){ + return variables.identifier; + } + + string function getProviderType(){ + return variables.providerType; + } + + array function capabilities(){ + return duplicate( variables.supportedCapabilities ); + } + + boolean function supports( required string capability ){ + return variables.supportedCapabilities.findNoCase( arguments.capability ) > 0; + } + + any function getClient(){ + return variables.client; + } + + struct function getProperties(){ + return variables.redactor.redact( variables.properties ); + } + + any function runOperation( + required string operation, + required any callback, + string idempotencyKey = "" + ){ + var startedAt = getTickCount(); + var context = safeContext().append( { + "operation" : arguments.operation, + "idempotencyKey" : arguments.idempotencyKey + } ); + announce( "cbpaymentsPreOperation", context ); + try { + var result = arguments.callback(); + context.append( { + "duration" : getTickCount() - startedAt, + "status" : result.getStatus() + } ); + if ( includeProviderRequestIds() ) { + context.requestId = result.getRequestId(); + } + announce( result.getOk() ? "cbpaymentsPostOperation" : "cbpaymentsOnOperationFailure", context ); + return result; + } catch ( any exception ) { + announce( + "cbpaymentsOnOperationFailure", + context.append( { + "duration" : getTickCount() - startedAt, + "failureCategory" : "provider" + } ) + ); + if ( left( exception.type, 11 ) == "cbpayments." ) { + rethrow; + } + throw( + type = "cbpayments.ProviderException", + message = "The [#variables.processorName#] payment provider failed during [#arguments.operation#].", + detail = variables.redactor.redactString( exception.message ) + ); + } + } + + any function successResult( + required string operation, + string status = "succeeded", + string externalId = "", + string requestId = "", + string idempotencyKey = "", + any amount, + struct nextAction = {}, + struct providerDetails = {}, + any clientAction + ){ + return new cbpayments.models.contracts.results.PaymentResult( + ok = true, + operation = arguments.operation, + processorName = variables.processorName, + providerType = variables.providerType, + status = arguments.status, + externalId = arguments.externalId, + requestId = arguments.requestId, + idempotencyKey = arguments.idempotencyKey, + amount = isNull( arguments.amount ) ? javacast( "null", "" ) : arguments.amount, + nextAction = arguments.nextAction, + providerDetails = arguments.providerDetails, + clientAction = isNull( arguments.clientAction ) ? javacast( "null", "" ) : arguments.clientAction + ); + } + + any function failureResult( + required string operation, + required string category, + string code = "provider_error", + string message = "The payment provider could not complete the request.", + boolean retryable = false, + string status = "failed", + string externalId = "", + string requestId = "", + string idempotencyKey = "", + string declineCategory = "" + ){ + var failure = new cbpayments.models.contracts.results.PaymentFailure( + category = arguments.category, + code = arguments.code, + message = arguments.message, + retryable = arguments.retryable, + declineCategory = arguments.declineCategory + ); + return new cbpayments.models.contracts.results.PaymentResult( + ok = false, + operation = arguments.operation, + processorName = variables.processorName, + providerType = variables.providerType, + status = arguments.status, + externalId = arguments.externalId, + requestId = arguments.requestId, + idempotencyKey = arguments.idempotencyKey, + failure = failure + ); + } + + struct function safeContext(){ + return { + "processorName" : variables.processorName, + "providerType" : variables.providerType + }; + } + + void function announce( required string state, required struct data ){ + if ( !isNull( variables.interceptorService ) ) { + variables.interceptorService.announce( arguments.state, variables.redactor.redact( arguments.data ) ); + } + } + + any function announceVerifiedWebhook( required any paymentEvent ){ + var context = safeContext().append( { + "eventId" : arguments.paymentEvent.getEventId(), + "eventType" : arguments.paymentEvent.getEventType(), + "providerEventType" : arguments.paymentEvent.getProviderEventType(), + "matchedSecretIndex" : arguments.paymentEvent.getMatchedSecretIndex() + } ); + announce( "cbpaymentsOnWebhookVerified", context ); + var typedState = variables.webhookEventTypes.interceptionPoint( arguments.paymentEvent.getEventType() ); + if ( len( typedState ) ) { + announce( typedState, { "paymentEvent" : arguments.paymentEvent.getMemento() } ); + } + return arguments.paymentEvent; + } + + private boolean function includeProviderRequestIds(){ + return isNull( variables.moduleSettings ) + || !variables.moduleSettings.keyExists( "logging" ) + || !variables.moduleSettings.logging.keyExists( "includeProviderRequestIds" ) + || variables.moduleSettings.logging.includeProviderRequestIds; + } + +} diff --git a/models/providers/MockProvider.cfc b/models/providers/MockProvider.cfc new file mode 100644 index 0000000..7eee437 --- /dev/null +++ b/models/providers/MockProvider.cfc @@ -0,0 +1,400 @@ +/** + * Deterministic provider for consumer and contract tests. + */ +component + extends ="cbpayments.models.providers.AbstractPaymentProvider" + implements="cbpayments.models.contracts.capabilities.IHostedCheckoutProvider,cbpayments.models.contracts.capabilities.IPaymentIntentsProvider,cbpayments.models.contracts.capabilities.ICaptureProvider,cbpayments.models.contracts.capabilities.IRefundsProvider,cbpayments.models.contracts.capabilities.ISetupIntentsProvider,cbpayments.models.contracts.capabilities.ICustomersProvider,cbpayments.models.contracts.capabilities.IWebhookProvider" +{ + + function init(){ + super.init(); + variables.providerType = "Mock"; + variables.supportedCapabilities = [ + "hostedCheckout", + "paymentIntents", + "capture", + "refunds", + "setupIntents", + "customers", + "webhooks" + ]; + reset(); + return this; + } + + any function startup( required string processorName, struct properties = {} ){ + super.startup( argumentCollection = arguments ); + reset(); + return this; + } + + any function shutdown(){ + reset(); + return super.shutdown(); + } + + any function reset(){ + variables.counter = 0; + variables.recorded = []; + variables.queued = {}; + return this; + } + + any function enqueueResult( required string operation, required struct outcome ){ + if ( !variables.queued.keyExists( arguments.operation ) ) { + variables.queued[ arguments.operation ] = []; + } + variables.queued[ arguments.operation ].append( arguments.outcome ); + return this; + } + + array function getRecordedRequests(){ + return duplicate( variables.recorded ); + } + + any function createCheckout( required any paymentRequest ){ + return performCreate( + "hostedCheckout.create", + arguments.paymentRequest, + "pending", + { + "type" : "redirect", + "redirectUrl" : "https://payments.invalid/checkout/#nextId( "checkout" )#" + } + ); + } + + any function retrieveCheckout( required string externalId ){ + return performLookup( + "hostedCheckout.retrieve", + arguments.externalId, + "pending" + ); + } + + any function expireCheckout( required string externalId, required string idempotencyKey ){ + return performMutation( + "hostedCheckout.expire", + arguments.externalId, + arguments.idempotencyKey, + "cancelled" + ); + } + + any function createPaymentIntent( required any paymentRequest ){ + return performCreate( + "paymentIntents.create", + arguments.paymentRequest, + arguments.paymentRequest.getCaptureMethod() == "manual" ? "authorized" : "pending" + ); + } + + any function retrievePaymentIntent( required string externalId ){ + return performLookup( + "paymentIntents.retrieve", + arguments.externalId, + "pending" + ); + } + + any function confirmPaymentIntent( + required string externalId, + required string idempotencyKey, + struct options = {} + ){ + return performMutation( + "paymentIntents.confirm", + arguments.externalId, + arguments.idempotencyKey, + "succeeded" + ); + } + + any function cancelPaymentIntent( required string externalId, required string idempotencyKey ){ + return performMutation( + "paymentIntents.cancel", + arguments.externalId, + arguments.idempotencyKey, + "cancelled" + ); + } + + any function capturePayment( required any paymentRequest ){ + return performCreate( + "capture.create", + arguments.paymentRequest, + "succeeded", + {}, + arguments.paymentRequest.getExternalId() + ); + } + + any function createRefund( required any paymentRequest ){ + return performCreate( + "refunds.create", + arguments.paymentRequest, + isNull( arguments.paymentRequest.getMoney() ) ? "refunded" : "partially_refunded" + ); + } + + any function retrieveRefund( required string externalId ){ + return performLookup( + "refunds.retrieve", + arguments.externalId, + "refunded" + ); + } + + any function createSetupIntent( required any paymentRequest ){ + return performCreate( + "setupIntents.create", + arguments.paymentRequest, + "pending" + ); + } + + any function retrieveSetupIntent( required string externalId ){ + return performLookup( + "setupIntents.retrieve", + arguments.externalId, + "pending" + ); + } + + any function cancelSetupIntent( required string externalId, required string idempotencyKey ){ + return performMutation( + "setupIntents.cancel", + arguments.externalId, + arguments.idempotencyKey, + "cancelled" + ); + } + + any function createCustomer( required any paymentRequest ){ + return performCreate( + "customers.create", + arguments.paymentRequest, + "succeeded" + ); + } + + any function retrieveCustomer( required string externalId ){ + return performLookup( + "customers.retrieve", + arguments.externalId, + "succeeded" + ); + } + + any function updateCustomer( required string externalId, required any paymentRequest ){ + arguments.externalId = requireExternalId( arguments.externalId ); + return performCreate( + "customers.update", + arguments.paymentRequest, + "succeeded", + {}, + arguments.externalId + ); + } + + any function deleteCustomer( required string externalId, required string idempotencyKey ){ + return performMutation( + "customers.delete", + arguments.externalId, + arguments.idempotencyKey, + "cancelled" + ); + } + + any function verifyWebhook( + required string rawBody, + required string signature, + string accountId = "" + ){ + if ( arguments.signature != "mock-test-signature" ) { + announce( + "cbpaymentsOnWebhookRejected", + safeContext().append( { "failureType" : "cbpayments.InvalidWebhookSignature" } ) + ); + throw( type = "cbpayments.InvalidWebhookSignature", message = "The webhook signature is invalid." ); + } + if ( !isJSON( arguments.rawBody ) ) { + announce( + "cbpaymentsOnWebhookRejected", + safeContext().append( { "failureType" : "cbpayments.MalformedWebhook" } ) + ); + throw( type = "cbpayments.MalformedWebhook", message = "The webhook body is not valid JSON." ); + } + var payload = deserializeJSON( arguments.rawBody ); + if ( !payload.keyExists( "id" ) || !payload.keyExists( "type" ) ) { + announce( + "cbpaymentsOnWebhookRejected", + safeContext().append( { "failureType" : "cbpayments.MalformedWebhook" } ) + ); + throw( + type = "cbpayments.MalformedWebhook", + message = "The webhook event is missing required fields." + ); + } + var dataObject = payload.keyExists( "data" ) && payload.data.keyExists( "object" ) ? payload.data.object : {}; + var paymentEvent = new cbpayments.models.contracts.results.PaymentEvent( + eventId = payload.id, + eventType = payload.type, + providerEventType = payload.keyExists( "providerEventType" ) ? payload.providerEventType : "mock.#payload.type#", + occurredAt = payload.keyExists( "created" ) ? payload.created : 0, + livemode = payload.keyExists( "livemode" ) ? payload.livemode : false, + processorName = variables.processorName, + providerType = variables.providerType, + providerAccountId = arguments.accountId, + objectType = dataObject.keyExists( "object" ) ? dataObject.object : "", + objectId = dataObject.keyExists( "id" ) ? dataObject.id : "", + status = dataObject.keyExists( "status" ) ? normalizeStatus( dataObject.status ) : "unknown", + providerDetails = { "testEvent" : true }, + payloadChecksum = hash( arguments.rawBody, "SHA-256" ), + matchedSecretIndex = 1 + ); + return announceVerifiedWebhook( paymentEvent ); + } + + private any function performCreate( + required string operation, + required any paymentRequest, + required string status, + struct nextAction = {}, + string externalId = "" + ){ + var requestData = arguments.paymentRequest.getMemento(); + record( arguments.operation, requestData ); + var externalIdentifier = len( arguments.externalId ) + ? arguments.externalId + : nextId( listFirst( arguments.operation, "." ) ); + return runOperation( + arguments.operation, + function(){ + return outcomeOrSuccess( + operation = operation, + status = status, + externalId = externalIdentifier, + idempotencyKey = paymentRequest.getIdempotencyKey(), + amount = structKeyExists( requestData, "money" ) && requestData.money.count() + ? paymentRequest.getMoney() + : javacast( "null", "" ), + nextAction = nextAction + ); + }, + arguments.paymentRequest.getIdempotencyKey() + ); + } + + private any function performLookup( + required string operation, + required string externalId, + required string status + ){ + arguments.externalId = requireExternalId( arguments.externalId ); + record( arguments.operation, { "externalId" : arguments.externalId } ); + return runOperation( arguments.operation, function(){ + return outcomeOrSuccess( + operation = operation, + status = status, + externalId = externalId + ); + } ); + } + + private any function performMutation( + required string operation, + required string externalId, + required string idempotencyKey, + required string status + ){ + arguments.externalId = requireExternalId( arguments.externalId ); + arguments.idempotencyKey = requireIdempotencyKey( arguments.idempotencyKey ); + record( + arguments.operation, + { + "externalId" : arguments.externalId, + "idempotencyKey" : arguments.idempotencyKey + } + ); + return runOperation( + arguments.operation, + function(){ + return outcomeOrSuccess( + operation = operation, + status = status, + externalId = externalId, + idempotencyKey = idempotencyKey + ); + }, + arguments.idempotencyKey + ); + } + + private any function outcomeOrSuccess( + required string operation, + required string status, + string externalId = "", + string idempotencyKey = "", + any amount, + struct nextAction = {} + ){ + var outcome = popOutcome( arguments.operation ); + if ( !isNull( outcome ) && outcome.keyExists( "ok" ) && !outcome.ok ) { + return failureResult( + operation = arguments.operation, + category = outcome.keyExists( "category" ) ? outcome.category : "provider", + code = outcome.keyExists( "code" ) ? outcome.code : "queued_failure", + retryable = outcome.keyExists( "retryable" ) ? outcome.retryable : false, + externalId = arguments.externalId, + idempotencyKey = arguments.idempotencyKey + ); + } + return successResult( + operation = arguments.operation, + status = !isNull( outcome ) && outcome.keyExists( "status" ) ? outcome.status : arguments.status, + externalId = arguments.externalId, + idempotencyKey = arguments.idempotencyKey, + amount = isNull( arguments.amount ) ? javacast( "null", "" ) : arguments.amount, + nextAction = arguments.nextAction, + providerDetails = { "mock" : true } + ); + } + + private any function popOutcome( required string operation ){ + if ( variables.queued.keyExists( arguments.operation ) && variables.queued[ arguments.operation ].len() ) { + return variables.queued[ arguments.operation ].shift(); + } + return javacast( "null", "" ); + } + + private void function record( required string operation, required struct payload ){ + variables.recorded.append( { + "operation" : arguments.operation, + "payload" : variables.redactor.redact( arguments.payload ) + } ); + } + + private string function nextId( required string prefix ){ + variables.counter++; + return "mock_#arguments.prefix#_#variables.counter#"; + } + + private string function normalizeStatus( required string status ){ + var mapping = { + "requires_action" : "requires_action", + "requires_capture" : "authorized", + "processing" : "pending", + "open" : "pending", + "complete" : "succeeded", + "succeeded" : "succeeded", + "canceled" : "cancelled", + "cancelled" : "cancelled", + "failed" : "failed", + "refunded" : "refunded", + "partially_refunded" : "partially_refunded" + }; + return mapping.keyExists( arguments.status ) ? mapping[ arguments.status ] : "unknown"; + } + +} diff --git a/models/providers/NullProvider.cfc b/models/providers/NullProvider.cfc new file mode 100644 index 0000000..dfa3215 --- /dev/null +++ b/models/providers/NullProvider.cfc @@ -0,0 +1,52 @@ +/** + * Deterministic no-op provider for environments where hosted payments are disabled. + */ +component + extends ="cbpayments.models.providers.AbstractPaymentProvider" + implements="cbpayments.models.contracts.capabilities.IHostedCheckoutProvider" +{ + + function init(){ + super.init(); + variables.providerType = "Null"; + variables.supportedCapabilities = [ "hostedCheckout" ]; + return this; + } + + any function createCheckout( required any paymentRequest ){ + return runOperation( + "hostedCheckout.create", + function(){ + return successResult( + operation = "hostedCheckout.create", + status = "succeeded", + externalId = "null_#hash( paymentRequest.getIdempotencyKey() )#", + idempotencyKey = paymentRequest.getIdempotencyKey(), + amount = paymentRequest.getMoney(), + providerDetails = { "noop" : true } + ); + }, + paymentRequest.getIdempotencyKey() + ); + } + + any function retrieveCheckout( required string externalId ){ + return successResult( + operation = "hostedCheckout.retrieve", + status = "succeeded", + externalId = arguments.externalId, + providerDetails = { "noop" : true } + ); + } + + any function expireCheckout( required string externalId, required string idempotencyKey ){ + return successResult( + operation = "hostedCheckout.expire", + status = "cancelled", + externalId = arguments.externalId, + idempotencyKey = arguments.idempotencyKey, + providerDetails = { "noop" : true } + ); + } + +} diff --git a/models/providers/StripeProvider.cfc b/models/providers/StripeProvider.cfc new file mode 100644 index 0000000..7e262e5 --- /dev/null +++ b/models/providers/StripeProvider.cfc @@ -0,0 +1,998 @@ +/** + * Stripe adapter backed by stripe-cfml 4.1.0. + */ +component + extends ="cbpayments.models.providers.AbstractPaymentProvider" + implements="cbpayments.models.contracts.capabilities.IHostedCheckoutProvider,cbpayments.models.contracts.capabilities.IPaymentIntentsProvider,cbpayments.models.contracts.capabilities.ICaptureProvider,cbpayments.models.contracts.capabilities.IRefundsProvider,cbpayments.models.contracts.capabilities.ISetupIntentsProvider,cbpayments.models.contracts.capabilities.ICustomersProvider,cbpayments.models.contracts.capabilities.IWebhookProvider" +{ + + function init(){ + super.init(); + variables.providerType = "Stripe"; + variables.supportedCapabilities = [ + "hostedCheckout", + "paymentIntents", + "capture", + "refunds", + "setupIntents", + "customers", + "webhooks" + ]; + variables.apiVersion = "2026-02-25.clover"; + variables.defaultCurrency = "usd"; + variables.connectAccount = ""; + variables.webhookSecrets = []; + variables.toleranceSeconds = 300; + return this; + } + + any function startup( required string processorName, struct properties = {} ){ + validateProperties( arguments.properties ); + variables.processorName = arguments.processorName; + variables.properties = arguments.properties; + variables.apiVersion = propertyValue( "apiVersion", "2026-02-25.clover" ); + variables.defaultCurrency = lCase( propertyValue( "defaultCurrency", "usd" ) ); + variables.connectAccount = propertyValue( "connectAccount", "" ); + variables.webhookSecrets = propertyValue( "webhookSecrets", [] ); + variables.toleranceSeconds = propertyValue( "toleranceSeconds", moduleWebhookTolerance() ); + + if ( arguments.properties.keyExists( "client" ) ) { + variables.client = arguments.properties.client; + } else { + if ( !len( propertyValue( "apiKey", "" ) ) ) { + throw( + type = "cbpayments.InvalidConfiguration", + message = "Stripe provider [#arguments.processorName#] requires apiKey." + ); + } + variables.client = new stripecfml.stripe( + propertyValue( "apiKey", "" ), + { + "apiVersion" : variables.apiVersion, + "defaultCurrency" : variables.defaultCurrency, + "convertToCents" : false + } + ); + } + return super.startup( arguments.processorName, arguments.properties ); + } + + any function createCheckout( required any paymentRequest ){ + return stripeOperation( + "hostedCheckout.create", + paymentRequest.getIdempotencyKey(), + function(){ + var params = { + "mode" : "payment", + "success_url" : paymentRequest.getReturnUrl(), + "cancel_url" : paymentRequest.getCancelUrl(), + "line_items" : [ + { + "quantity" : 1, + "price_data" : { + "currency" : paymentRequest.getMoney().getCurrency(), + "unit_amount" : paymentRequest.getMoney().getAmountMinor(), + "product_data" : { + "name" : len( paymentRequest.getDescription() ) ? paymentRequest.getDescription() : "Payment" + } + } + } + ], + "metadata" : paymentRequest.getMetadata() + }; + if ( len( paymentRequest.getCustomerId() ) ) { + params.customer = paymentRequest.getCustomerId(); + } + applyCheckoutProviderOptions( params, paymentRequest.getProviderOptions() ); + var response = variables.client.checkout.sessions.create( + params, + requestHeaders( paymentRequest.getIdempotencyKey() ) + ); + return mapResponse( + "hostedCheckout.create", + response, + paymentRequest.getIdempotencyKey(), + paymentRequest.getMoney() + ); + } + ); + } + + any function retrieveCheckout( required string externalId ){ + arguments.externalId = requireExternalId( arguments.externalId ); + return stripeOperation( + "hostedCheckout.retrieve", + "", + function(){ + return mapResponse( + "hostedCheckout.retrieve", + variables.client.checkout.sessions.retrieve( externalId, requestHeaders() ) + ); + } + ); + } + + any function expireCheckout( required string externalId, required string idempotencyKey ){ + arguments.externalId = requireExternalId( arguments.externalId ); + arguments.idempotencyKey = requireIdempotencyKey( arguments.idempotencyKey ); + return stripeOperation( + "hostedCheckout.expire", + arguments.idempotencyKey, + function(){ + return mapResponse( + "hostedCheckout.expire", + variables.client.checkout.sessions.expire( externalId, requestHeaders( idempotencyKey ) ), + idempotencyKey + ); + } + ); + } + + any function createPaymentIntent( required any paymentRequest ){ + return stripeOperation( + "paymentIntents.create", + paymentRequest.getIdempotencyKey(), + function(){ + var params = { + "amount" : paymentRequest.getMoney().getAmountMinor(), + "currency" : paymentRequest.getMoney().getCurrency(), + "capture_method" : paymentRequest.getCaptureMethod(), + "automatic_payment_methods" : { "enabled" : true }, + "metadata" : paymentRequest.getMetadata() + }; + if ( len( paymentRequest.getCustomerId() ) ) { + params.customer = paymentRequest.getCustomerId(); + } + if ( len( paymentRequest.getPaymentMethodId() ) ) { + params.payment_method = paymentRequest.getPaymentMethodId(); + } + if ( len( paymentRequest.getDescription() ) ) { + params.description = paymentRequest.getDescription(); + } + applyIntentProviderOptions( params, paymentRequest.getProviderOptions() ); + return mapResponse( + "paymentIntents.create", + variables.client.paymentIntents.create( + params, + requestHeaders( paymentRequest.getIdempotencyKey() ) + ), + paymentRequest.getIdempotencyKey(), + paymentRequest.getMoney() + ); + } + ); + } + + any function retrievePaymentIntent( required string externalId ){ + arguments.externalId = requireExternalId( arguments.externalId ); + return stripeOperation( + "paymentIntents.retrieve", + "", + function(){ + return mapResponse( + "paymentIntents.retrieve", + variables.client.paymentIntents.retrieve( externalId, requestHeaders() ) + ); + } + ); + } + + any function confirmPaymentIntent( + required string externalId, + required string idempotencyKey, + struct options = {} + ){ + arguments.externalId = requireExternalId( arguments.externalId ); + arguments.idempotencyKey = requireIdempotencyKey( arguments.idempotencyKey ); + return stripeOperation( + "paymentIntents.confirm", + arguments.idempotencyKey, + function(){ + options + .keyArray() + .each( function( key ){ + if ( !arrayFindNoCase( [ "paymentMethodId", "returnUrl" ], key ) ) { + throw( + type = "cbpayments.InvalidProviderOptions", + message = "Stripe confirmation option [#key#] is not allowed." + ); + } + } ); + var params = {}; + if ( options.keyExists( "paymentMethodId" ) ) { + params.payment_method = options.paymentMethodId; + } + if ( options.keyExists( "returnUrl" ) ) { + params.return_url = new cbpayments.models.util.SecurityValidator().validateUrl( + options.returnUrl + ); + } + return mapResponse( + "paymentIntents.confirm", + variables.client.paymentIntents.confirm( + externalId, + params, + requestHeaders( idempotencyKey ) + ), + idempotencyKey + ); + } + ); + } + + any function cancelPaymentIntent( required string externalId, required string idempotencyKey ){ + arguments.externalId = requireExternalId( arguments.externalId ); + arguments.idempotencyKey = requireIdempotencyKey( arguments.idempotencyKey ); + return stripeOperation( + "paymentIntents.cancel", + arguments.idempotencyKey, + function(){ + return mapResponse( + "paymentIntents.cancel", + variables.client.paymentIntents.cancel( externalId, requestHeaders( idempotencyKey ) ), + idempotencyKey + ); + } + ); + } + + any function capturePayment( required any paymentRequest ){ + return stripeOperation( + "capture.create", + paymentRequest.getIdempotencyKey(), + function(){ + var params = { "metadata" : paymentRequest.getMetadata() }; + if ( !isNull( paymentRequest.getMoney() ) ) { + params.amount_to_capture = paymentRequest.getMoney().getAmountMinor(); + } + return mapResponse( + "capture.create", + variables.client.paymentIntents.capture( + paymentRequest.getExternalId(), + params, + requestHeaders( paymentRequest.getIdempotencyKey() ) + ), + paymentRequest.getIdempotencyKey(), + paymentRequest.getMoney() + ); + } + ); + } + + any function createRefund( required any paymentRequest ){ + return stripeOperation( + "refunds.create", + paymentRequest.getIdempotencyKey(), + function(){ + var params = { + "payment_intent" : paymentRequest.getExternalId(), + "metadata" : paymentRequest.getMetadata() + }; + if ( !isNull( paymentRequest.getMoney() ) ) { + params.amount = paymentRequest.getMoney().getAmountMinor(); + } + if ( len( paymentRequest.getReason() ) ) { + params.reason = paymentRequest.getReason(); + } + return mapResponse( + "refunds.create", + variables.client.refunds.create( params, requestHeaders( paymentRequest.getIdempotencyKey() ) ), + paymentRequest.getIdempotencyKey(), + paymentRequest.getMoney() + ); + } + ); + } + + any function retrieveRefund( required string externalId ){ + arguments.externalId = requireExternalId( arguments.externalId ); + return stripeOperation( + "refunds.retrieve", + "", + function(){ + return mapResponse( + "refunds.retrieve", + variables.client.refunds.retrieve( externalId, requestHeaders() ) + ); + } + ); + } + + any function createSetupIntent( required any paymentRequest ){ + return stripeOperation( + "setupIntents.create", + paymentRequest.getIdempotencyKey(), + function(){ + var params = { + "usage" : paymentRequest.getUsage(), + "automatic_payment_methods" : { "enabled" : true }, + "metadata" : paymentRequest.getMetadata() + }; + if ( len( paymentRequest.getCustomerId() ) ) { + params.customer = paymentRequest.getCustomerId(); + } + if ( len( paymentRequest.getDescription() ) ) { + params.description = paymentRequest.getDescription(); + } + return mapResponse( + "setupIntents.create", + variables.client.setupIntents.create( + params, + requestHeaders( paymentRequest.getIdempotencyKey() ) + ), + paymentRequest.getIdempotencyKey() + ); + } + ); + } + + any function retrieveSetupIntent( required string externalId ){ + arguments.externalId = requireExternalId( arguments.externalId ); + return stripeOperation( + "setupIntents.retrieve", + "", + function(){ + return mapResponse( + "setupIntents.retrieve", + variables.client.setupIntents.retrieve( externalId, requestHeaders() ) + ); + } + ); + } + + any function cancelSetupIntent( required string externalId, required string idempotencyKey ){ + arguments.externalId = requireExternalId( arguments.externalId ); + arguments.idempotencyKey = requireIdempotencyKey( arguments.idempotencyKey ); + return stripeOperation( + "setupIntents.cancel", + arguments.idempotencyKey, + function(){ + return mapResponse( + "setupIntents.cancel", + variables.client.setupIntents.cancel( externalId, requestHeaders( idempotencyKey ) ), + idempotencyKey + ); + } + ); + } + + any function createCustomer( required any paymentRequest ){ + return stripeOperation( + "customers.create", + paymentRequest.getIdempotencyKey(), + function(){ + return mapResponse( + "customers.create", + variables.client.customers.create( + customerParams( paymentRequest ), + requestHeaders( paymentRequest.getIdempotencyKey() ) + ), + paymentRequest.getIdempotencyKey() + ); + } + ); + } + + any function retrieveCustomer( required string externalId ){ + arguments.externalId = requireExternalId( arguments.externalId ); + return stripeOperation( + "customers.retrieve", + "", + function(){ + return mapResponse( + "customers.retrieve", + variables.client.customers.retrieve( externalId, requestHeaders() ) + ); + } + ); + } + + any function updateCustomer( required string externalId, required any paymentRequest ){ + arguments.externalId = requireExternalId( arguments.externalId ); + return stripeOperation( + "customers.update", + paymentRequest.getIdempotencyKey(), + function(){ + return mapResponse( + "customers.update", + variables.client.customers.update( + externalId, + customerParams( paymentRequest ), + requestHeaders( paymentRequest.getIdempotencyKey() ) + ), + paymentRequest.getIdempotencyKey() + ); + } + ); + } + + any function deleteCustomer( required string externalId, required string idempotencyKey ){ + arguments.externalId = requireExternalId( arguments.externalId ); + arguments.idempotencyKey = requireIdempotencyKey( arguments.idempotencyKey ); + return stripeOperation( + "customers.delete", + arguments.idempotencyKey, + function(){ + return mapResponse( + "customers.delete", + variables.client.customers.delete( externalId, requestHeaders( idempotencyKey ) ), + idempotencyKey + ); + } + ); + } + + any function verifyWebhook( + required string rawBody, + required string signature, + string accountId = "" + ){ + if ( !variables.webhookSecrets.len() ) { + throw( + type = "cbpayments.InvalidConfiguration", + message = "Stripe webhooks require at least one webhook secret." + ); + } + validateWebhookTimestamp( arguments.signature ); + var verifiedEvent = javacast( "null", "" ); + var matchedIndex = 0; + var lastException = javacast( "null", "" ); + for ( var index = 1; index <= variables.webhookSecrets.len(); index++ ) { + try { + verifiedEvent = variables.client.webhooks.constructEvent( + arguments.rawBody, + arguments.signature, + variables.webhookSecrets[ index ], + variables.toleranceSeconds + ); + matchedIndex = index; + break; + } catch ( any exception ) { + lastException = exception; + } + } + if ( isNull( verifiedEvent ) ) { + if ( + !isNull( lastException ) && !findNoCase( "signature", lastException.type ) && !findNoCase( + "signature", + lastException.message + ) + ) { + rejectWebhook( "cbpayments.MalformedWebhook", "The signed webhook body is malformed." ); + } + rejectWebhook( "cbpayments.InvalidWebhookSignature", "The webhook signature is invalid." ); + } + + var expectedAccount = len( arguments.accountId ) ? arguments.accountId : variables.connectAccount; + var actualAccount = verifiedEvent.keyExists( "account" ) ? verifiedEvent.account : ""; + if ( len( expectedAccount ) && actualAccount != expectedAccount ) { + rejectWebhook( + "cbpayments.WebhookAccountMismatch", + "The webhook account does not match this provider." + ); + } + var normalizedEvent = mapWebhookEvent( + verifiedEvent, + arguments.rawBody, + matchedIndex, + actualAccount + ); + return announceVerifiedWebhook( normalizedEvent ); + } + + private any function stripeOperation( + required string operation, + required string idempotencyKey, + required any callback + ){ + return runOperation( + arguments.operation, + function(){ + try { + return callback(); + } catch ( any exception ) { + if ( findNoCase( "Connection", exception.type ) || findNoCase( "timeout", exception.message ) ) { + return failureResult( + operation = operation, + category = "network", + code = "connection_failure", + retryable = true, + idempotencyKey = idempotencyKey + ); + } + rethrow; + } + }, + arguments.idempotencyKey + ); + } + + private any function mapResponse( + required string operation, + required any response, + string idempotencyKey = "", + any amount + ){ + if ( !isStruct( arguments.response ) ) { + return malformedResponse( + arguments.operation, + "", + arguments.idempotencyKey + ); + } + var httpStatus = response.keyExists( "status" ) && isNumeric( response.status ) ? int( response.status ) : 0; + var requestId = response.keyExists( "requestId" ) ? safeProviderString( response.requestId ) : ""; + if ( !response.keyExists( "content" ) || !isStruct( response.content ) ) { + return malformedResponse( + arguments.operation, + requestId, + arguments.idempotencyKey + ); + } + var content = response.content; + if ( httpStatus < 200 || httpStatus >= 300 ) { + var category = httpStatus == 401 || httpStatus == 403 + ? "authentication" + : httpStatus == 429 + ? "rate_limited" + : httpStatus >= 500 || !httpStatus ? "provider" : "validation"; + if ( + content.keyExists( "error" ) && isStruct( content.error ) && content.error.keyExists( "type" ) && content.error.type == "card_error" + ) { + category = "declined"; + } + return failureResult( + operation = arguments.operation, + category = category, + code = safeErrorCode( content ), + retryable = httpStatus == 429 || httpStatus >= 500 || !httpStatus, + requestId = requestId, + idempotencyKey = arguments.idempotencyKey, + externalId = content.keyExists( "id" ) ? safeProviderString( content.id ) : "", + declineCategory = safeDeclineCategory( content ) + ); + } + if ( !content.keyExists( "id" ) ) { + return malformedResponse( + arguments.operation, + requestId, + arguments.idempotencyKey + ); + } + + try { + var resultArguments = { + "operation" : arguments.operation, + "status" : stripeStatus( content ), + "externalId" : safeProviderString( content.id ), + "requestId" : requestId, + "idempotencyKey" : arguments.idempotencyKey, + "amount" : isNull( arguments.amount ) ? moneyFromContent( content ) : arguments.amount, + "nextAction" : nextActionFromContent( content ), + "providerDetails" : safeProviderDetails( content ) + }; + if ( content.keyExists( "client_secret" ) ) { + resultArguments.clientAction = new cbpayments.models.contracts.results.ClientAction( + type = "stripe_client_secret", + clientSecret = content.client_secret + ); + } + return successResult( argumentCollection = resultArguments ); + } catch ( any malformed ) { + return malformedResponse( + arguments.operation, + requestId, + arguments.idempotencyKey + ); + } + } + + private any function malformedResponse( + required string operation, + string requestId = "", + string idempotencyKey = "" + ){ + return failureResult( + operation = arguments.operation, + category = "provider", + code = "malformed_response", + retryable = false, + requestId = arguments.requestId, + idempotencyKey = arguments.idempotencyKey + ); + } + + private struct function requestHeaders( string idempotencyKey = "" ){ + var headers = {}; + if ( len( arguments.idempotencyKey ) ) { + headers.idempotencyKey = arguments.idempotencyKey; + } + if ( len( variables.connectAccount ) ) { + headers.stripeAccount = variables.connectAccount; + } + return headers; + } + + private struct function customerParams( required any paymentRequest ){ + var params = { "metadata" : arguments.paymentRequest.getMetadata() }; + if ( len( arguments.paymentRequest.getEmail() ) ) { + params.email = arguments.paymentRequest.getEmail(); + } + if ( len( arguments.paymentRequest.getCustomerName() ) ) { + params.name = arguments.paymentRequest.getCustomerName(); + } + if ( len( arguments.paymentRequest.getDescription() ) ) { + params.description = arguments.paymentRequest.getDescription(); + } + return params; + } + + private void function applyCheckoutProviderOptions( required struct params, required struct providerOptions ){ + var options = stripeOptions( + arguments.providerOptions, + [ + "expiresAt", + "applicationFeeAmount", + "transferDestination", + "onBehalfOf" + ] + ); + if ( options.keyExists( "expiresAt" ) ) { + arguments.params.expires_at = options.expiresAt; + } + if ( options.keyExists( "applicationFeeAmount" ) ) { + arguments.params.payment_intent_data = { "application_fee_amount" : options.applicationFeeAmount }; + } + if ( options.keyExists( "transferDestination" ) ) { + if ( !arguments.params.keyExists( "payment_intent_data" ) ) { + arguments.params.payment_intent_data = {}; + } + arguments.params.payment_intent_data.transfer_data = { "destination" : options.transferDestination }; + } + if ( options.keyExists( "onBehalfOf" ) ) { + if ( !arguments.params.keyExists( "payment_intent_data" ) ) { + arguments.params.payment_intent_data = {}; + } + arguments.params.payment_intent_data.on_behalf_of = options.onBehalfOf; + } + } + + private void function applyIntentProviderOptions( required struct params, required struct providerOptions ){ + var options = stripeOptions( + arguments.providerOptions, + [ + "applicationFeeAmount", + "transferDestination", + "onBehalfOf", + "offSession" + ] + ); + if ( options.keyExists( "applicationFeeAmount" ) ) { + arguments.params.application_fee_amount = options.applicationFeeAmount; + } + if ( options.keyExists( "transferDestination" ) ) { + arguments.params.transfer_data = { "destination" : options.transferDestination }; + } + if ( options.keyExists( "onBehalfOf" ) ) { + arguments.params.on_behalf_of = options.onBehalfOf; + } + if ( options.keyExists( "offSession" ) ) { + arguments.params.off_session = options.offSession; + } + } + + private struct function stripeOptions( required struct providerOptions, required array allowed ){ + if ( !arguments.providerOptions.count() ) { + return {}; + } + if ( !arguments.providerOptions.keyExists( "Stripe" ) || !isStruct( arguments.providerOptions.Stripe ) ) { + throw( + type = "cbpayments.InvalidProviderOptions", + message = "Stripe options must be namespaced under providerOptions.Stripe." + ); + } + arguments.providerOptions.Stripe + .keyArray() + .each( function( key ){ + if ( !allowed.findNoCase( key ) ) { + throw( + type = "cbpayments.InvalidProviderOptions", + message = "Stripe provider option [#key#] is not allowed." + ); + } + } ); + return arguments.providerOptions.Stripe; + } + + private any function moneyFromContent( required struct content ){ + var amountKey = content.keyExists( "amount_total" ) ? "amount_total" : content.keyExists( "amount" ) ? "amount" : ""; + if ( !len( amountKey ) || !content.keyExists( "currency" ) ) { + return javacast( "null", "" ); + } + return new cbpayments.models.contracts.Money( content[ amountKey ], content.currency ); + } + + private struct function nextActionFromContent( required struct content ){ + if ( + arguments.content.keyExists( "url" ) + && isSimpleValue( arguments.content.url ) + && reFindNoCase( "^https://[^[:space:]]+$", arguments.content.url ) + ) { + return { + "type" : "redirect", + "redirectUrl" : arguments.content.url + }; + } + if ( arguments.content.keyExists( "next_action" ) && isStruct( arguments.content.next_action ) ) { + return { "type" : "provider_action" }; + } + return {}; + } + + private struct function safeProviderDetails( required struct content ){ + var safe = {}; + var allowed = [ + "object", + "mode", + "payment_status", + "status", + "capture_method", + "cancel_reason", + "refunded" + ]; + allowed.each( function( key ){ + if ( content.keyExists( key ) && isSimpleValue( content[ key ] ) ) { + safe[ key ] = isNumeric( content[ key ] ) || isBoolean( content[ key ] ) + ? content[ key ] + : safeProviderString( content[ key ] ); + } + } ); + return safe; + } + + private string function stripeStatus( required struct content ){ + var raw = content.keyExists( "payment_status" ) && content.payment_status != "unpaid" + ? content.payment_status + : content.keyExists( "status" ) ? content.status : "unknown"; + var mapping = { + "requires_action" : "requires_action", + "requires_confirmation" : "pending", + "requires_payment_method" : "pending", + "requires_capture" : "authorized", + "processing" : "pending", + "open" : "pending", + "complete" : "succeeded", + "paid" : "succeeded", + "succeeded" : "succeeded", + "failed" : "failed", + "canceled" : "cancelled", + "expired" : "cancelled", + "refunded" : "refunded" + }; + return mapping.keyExists( raw ) ? mapping[ raw ] : "unknown"; + } + + private string function safeErrorCode( required struct content ){ + if ( content.keyExists( "error" ) && isStruct( content.error ) && content.error.keyExists( "code" ) ) { + return safeProviderString( content.error.code, "stripe_error" ); + } + return "stripe_error"; + } + + private string function safeDeclineCategory( required struct content ){ + if ( content.keyExists( "error" ) && isStruct( content.error ) && content.error.keyExists( "decline_code" ) ) { + return safeProviderString( content.error.decline_code ); + } + return ""; + } + + private any function mapWebhookEvent( + required struct event, + required string rawBody, + required numeric matchedIndex, + string actualAccount = "" + ){ + if ( + !event.keyExists( "id" ) + || !isSimpleValue( event.id ) + || !len( event.id ) + || !event.keyExists( "type" ) + || !isSimpleValue( event.type ) + || !len( event.type ) + || ( event.keyExists( "data" ) && !isStruct( event.data ) ) + || ( + event.keyExists( "data" ) + && event.data.keyExists( "object" ) + && !isStruct( event.data.object ) + ) + ) { + rejectWebhook( "cbpayments.MalformedWebhook", "The signed webhook envelope is malformed." ); + } + var object = event.keyExists( "data" ) && event.data.keyExists( "object" ) ? event.data.object : {}; + var providerEventType = safeProviderString( event.type ); + var eventArguments = { + "eventId" : safeProviderString( event.id ), + "eventType" : normalizeWebhookEventType( providerEventType, object ), + "providerEventType" : providerEventType, + "occurredAt" : event.keyExists( "created" ) ? event.created : 0, + "livemode" : event.keyExists( "livemode" ) ? event.livemode : false, + "processorName" : variables.processorName, + "providerType" : variables.providerType, + "providerAccountId" : safeProviderString( arguments.actualAccount ), + "objectType" : object.keyExists( "object" ) ? safeProviderString( object.object ) : "", + "objectId" : object.keyExists( "id" ) ? safeProviderString( object.id ) : "", + "status" : stripeStatus( object ), + "providerDetails" : safeProviderDetails( object ), + "payloadChecksum" : hash( arguments.rawBody, "SHA-256" ), + "matchedSecretIndex" : arguments.matchedIndex + }; + try { + eventArguments.amount = moneyFromContent( object ); + } catch ( any invalidAmount ) { + rejectWebhook( "cbpayments.MalformedWebhook", "The signed webhook envelope is malformed." ); + } + return new cbpayments.models.contracts.results.PaymentEvent( argumentCollection = eventArguments ); + } + + private string function normalizeWebhookEventType( + required string providerEventType, + struct providerObject = {} + ){ + var mappings = { + "payment_intent.amount_capturable_updated" : "payment.authorized", + "payment_intent.processing" : "payment.processing", + "payment_intent.requires_action" : "payment.requires_action", + "payment_intent.succeeded" : "payment.succeeded", + "payment_intent.payment_failed" : "payment.failed", + "payment_intent.canceled" : "payment.canceled", + "checkout.session.completed" : "checkout.completed", + "checkout.session.expired" : "checkout.expired", + "charge.refunded" : "payment.refunded", + "refund.created" : "refund.processing", + "refund.updated" : "refund.processing", + "refund.failed" : "refund.failed", + "setup_intent.succeeded" : "setup.succeeded", + "setup_intent.setup_failed" : "setup.failed", + "setup_intent.canceled" : "setup.canceled", + "customer.created" : "customer.created", + "customer.updated" : "customer.updated", + "customer.deleted" : "customer.deleted" + }; + var key = lCase( arguments.providerEventType ); + if ( + key == "charge.refunded" && arguments.providerObject.keyExists( "refunded" ) && !arguments.providerObject.refunded + ) { + return "payment.partially_refunded"; + } + if ( key == "refund.created" || key == "refund.updated" ) { + var refundStatus = arguments.providerObject.keyExists( "status" ) + ? lCase( safeProviderString( arguments.providerObject.status ) ) + : "pending"; + return refundStatus == "succeeded" + ? "refund.succeeded" + : refundStatus == "failed" || refundStatus == "canceled" + ? "refund.failed" + : "refund.processing"; + } + return mappings.keyExists( key ) ? mappings[ key ] : "unknown"; + } + + private string function safeProviderString( required any value, string fallback = "" ){ + if ( !isSimpleValue( arguments.value ) ) { + return arguments.fallback; + } + var safe = variables.redactor.redactString( toString( arguments.value ) ); + return len( safe ) <= 255 ? safe : left( safe, 255 ); + } + + private void function validateWebhookTimestamp( required string signature ){ + var timestamp = ""; + listToArray( arguments.signature ).each( function( item ){ + if ( listFirst( item, "=" ) == "t" ) { + timestamp = listRest( item, "=" ); + } + } ); + if ( !isNumeric( timestamp ) ) { + rejectWebhook( "cbpayments.InvalidWebhookSignature", "The webhook signature timestamp is missing." ); + } + var nowUnix = fix( createObject( "java", "java.lang.System" ).currentTimeMillis() / 1000 ); + if ( variables.toleranceSeconds > 0 && abs( nowUnix - timestamp ) > variables.toleranceSeconds ) { + rejectWebhook( + "cbpayments.StaleWebhook", + "The webhook timestamp is outside the configured tolerance." + ); + } + } + + private void function rejectWebhook( required string failureType, required string failureMessage ){ + announce( + "cbpaymentsOnWebhookRejected", + safeContext().append( { "failureType" : arguments.failureType } ) + ); + throw( type = arguments.failureType, message = arguments.failureMessage ); + } + + private void function validateProperties( required struct properties ){ + var allowed = [ + "apiKey", + "webhookSecrets", + "apiVersion", + "defaultCurrency", + "connectAccount", + "toleranceSeconds", + "client" + ]; + arguments.properties + .keyArray() + .each( function( key ){ + if ( !allowed.findNoCase( key ) ) { + throw( type = "cbpayments.InvalidConfiguration", message = "Unknown Stripe property [#key#]." ); + } + } ); + if ( arguments.properties.keyExists( "webhookSecrets" ) && !isArray( arguments.properties.webhookSecrets ) ) { + throw( type = "cbpayments.InvalidConfiguration", message = "Stripe webhookSecrets must be an array." ); + } + if ( arguments.properties.keyExists( "webhookSecrets" ) ) { + for ( var secret in arguments.properties.webhookSecrets ) { + if ( !isSimpleValue( secret ) || !len( trim( secret ) ) ) { + throw( + type = "cbpayments.InvalidConfiguration", + message = "Stripe webhookSecrets must contain non-empty strings." + ); + } + } + } + if ( + arguments.properties.keyExists( "toleranceSeconds" ) + && ( + !isNumeric( arguments.properties.toleranceSeconds ) + || arguments.properties.toleranceSeconds < 0 + ) + ) { + throw( + type = "cbpayments.InvalidConfiguration", + message = "Stripe toleranceSeconds must be a non-negative number." + ); + } + for ( + var simpleProperty in [ + "apiKey", + "apiVersion", + "defaultCurrency", + "connectAccount" + ] + ) { + if ( + arguments.properties.keyExists( simpleProperty ) + && !isSimpleValue( arguments.properties[ simpleProperty ] ) + ) { + throw( + type = "cbpayments.InvalidConfiguration", + message = "Stripe property [#simpleProperty#] must be a string." + ); + } + } + if ( arguments.properties.keyExists( "defaultCurrency" ) ) { + new cbpayments.models.util.CurrencyMetadata().exponent( arguments.properties.defaultCurrency ); + } + if ( arguments.properties.keyExists( "client" ) && !isObject( arguments.properties.client ) ) { + throw( type = "cbpayments.InvalidConfiguration", message = "Stripe client must be an object." ); + } + } + + private any function propertyValue( required string key, required any defaultValue ){ + return variables.properties.keyExists( arguments.key ) ? variables.properties[ arguments.key ] : arguments.defaultValue; + } + + private numeric function moduleWebhookTolerance(){ + if ( + !isNull( variables.moduleSettings ) + && variables.moduleSettings.keyExists( "webhooks" ) + && variables.moduleSettings.webhooks.keyExists( "toleranceSeconds" ) + ) { + return variables.moduleSettings.webhooks.toleranceSeconds; + } + return 300; + } + +} diff --git a/models/testing/ProviderContract.cfc b/models/testing/ProviderContract.cfc new file mode 100644 index 0000000..e826016 --- /dev/null +++ b/models/testing/ProviderContract.cfc @@ -0,0 +1,89 @@ +/** + * Runtime-neutral provider contract verifier for extension test suites. + */ +component { + + struct function verify( required any provider, array expectedCapabilities = [] ){ + var failures = []; + var requiredMethods = [ + "startup", + "shutdown", + "getProcessorName", + "getProviderType", + "capabilities", + "supports", + "getClient" + ]; + requiredMethods.each( function( method ){ + if ( !structKeyExists( provider, method ) ) { + failures.append( "Missing base method [#method#]." ); + } + } ); + if ( !failures.len() ) { + var advertised = provider.capabilities(); + if ( + advertised.len() != advertised + .duplicate() + .reduce( function( unique, item ){ + unique[ lCase( item ) ] = true; + return unique; + }, {} ) + .count() + ) { + failures.append( "Capabilities must not contain duplicates." ); + } + arguments.expectedCapabilities.each( function( capability ){ + if ( !provider.supports( capability ) || !advertised.findNoCase( capability ) ) { + failures.append( "Expected capability [#capability#] is not advertised consistently." ); + } + } ); + var capabilityMethods = { + "hostedCheckout" : [ + "createCheckout", + "retrieveCheckout", + "expireCheckout" + ], + "paymentIntents" : [ + "createPaymentIntent", + "retrievePaymentIntent", + "confirmPaymentIntent", + "cancelPaymentIntent" + ], + "capture" : [ "capturePayment" ], + "refunds" : [ "createRefund", "retrieveRefund" ], + "setupIntents" : [ + "createSetupIntent", + "retrieveSetupIntent", + "cancelSetupIntent" + ], + "customers" : [ + "createCustomer", + "retrieveCustomer", + "updateCustomer", + "deleteCustomer" + ], + "webhooks" : [ "verifyWebhook" ] + }; + advertised.each( function( capability ){ + if ( !capabilityMethods.keyExists( arguments.capability ) ) { + failures.append( "Unknown advertised capability [#arguments.capability#]." ); + return; + } + capabilityMethods[ arguments.capability ].each( function( method ){ + if ( !structKeyExists( provider, arguments.method ) ) { + failures.append( "Capability [#capability#] requires method [#arguments.method#]." ); + } + } ); + } ); + } + return { "ok" : !failures.len(), "failures" : failures }; + } + + void function assertValid( required any provider, array expectedCapabilities = [] ){ + var report = verify( argumentCollection = arguments ); + if ( !report.ok ) { + throw( type = "cbpayments.ProviderContractFailure", message = report.failures.toList( " " ) ); + } + } + +} diff --git a/models/util/CurrencyMetadata.cfc b/models/util/CurrencyMetadata.cfc new file mode 100644 index 0000000..4df10d3 --- /dev/null +++ b/models/util/CurrencyMetadata.cfc @@ -0,0 +1,33 @@ +/** + * ISO 4217 minor-unit metadata used by Money validation. + */ +component singleton { + + function init(){ + variables.supported = listToArray( "aed,afn,all,amd,ang,aoa,ars,aud,awg,azn,bam,bbd,bdt,bgn,bhd,bif,bmd,bnd,bob,brl,bsd,btn,bwp,byn,bzd,cad,cdf,chf,clp,cny,cop,crc,cup,cve,czk,djf,dkk,dop,dzd,egp,ern,etb,eur,fjd,fkp,gbp,gel,ghs,gip,gmd,gnf,gtq,gyd,hkd,hnl,hrk,htg,huf,idr,ils,inr,iqd,irr,isk,jmd,jod,jpy,kes,kgs,khr,kmf,kpw,krw,kwd,kyd,kzt,lak,lbp,lkr,lrd,lsl,lyd,mad,mdl,mga,mkd,mmk,mnt,mop,mru,mur,mvr,mwk,mxn,myr,mzn,nad,ngn,nio,nok,npr,nzd,omr,pab,pen,pgk,php,pkr,pln,pyg,qar,ron,rsd,rub,rwf,sar,sbd,scr,sdg,sek,sgd,shp,sle,sll,sos,srd,ssp,stn,svc,syp,szl,thb,tjs,tmt,tnd,top,try,ttd,twd,tzs,uah,ugx,usd,uyu,uzs,ves,vnd,vuv,wst,xaf,xcd,xof,xpf,yer,zar,zmw,zwl" ); + variables.zeroDecimal = listToArray( "bif,clp,djf,gnf,jpy,kmf,krw,pyg,rwf,ugx,vnd,vuv,xaf,xof,xpf" ); + variables.threeDecimal = listToArray( "bhd,iqd,jod,kwd,lyd,omr,tnd" ); + return this; + } + + boolean function isSupported( required string currency ){ + return variables.supported.findNoCase( arguments.currency ) > 0; + } + + numeric function exponent( required string currency ){ + if ( !isSupported( arguments.currency ) ) { + throw( + type = "cbpayments.InvalidCurrency", + message = "[#arguments.currency#] is not a supported ISO 4217 currency code." + ); + } + if ( variables.zeroDecimal.findNoCase( arguments.currency ) ) { + return 0; + } + if ( variables.threeDecimal.findNoCase( arguments.currency ) ) { + return 3; + } + return 2; + } + +} diff --git a/models/util/Redactor.cfc b/models/util/Redactor.cfc new file mode 100644 index 0000000..46d2979 --- /dev/null +++ b/models/util/Redactor.cfc @@ -0,0 +1,122 @@ +/** + * Produces safe copies of diagnostic data without mutating caller values. + */ +component singleton { + + function redact( required any value ){ + if ( isObject( arguments.value ) ) { + return "[REDACTED OBJECT]"; + } + if ( isStruct( arguments.value ) ) { + var safe = {}; + arguments.value.each( function( key, item ){ + safe[ key ] = isSensitiveKey( key ) ? "[REDACTED]" : redact( item ); + } ); + return safe; + } + if ( isArray( arguments.value ) ) { + return arguments.value.map( function( item ){ + return redact( item ); + } ); + } + if ( isSimpleValue( arguments.value ) ) { + return redactString( toString( arguments.value ) ); + } + return "[REDACTED OBJECT]"; + } + + boolean function isSensitiveKey( required string key ){ + var normalized = lCase( reReplace( arguments.key, "[_-]", "", "all" ) ); + if ( + arrayFind( + [ + "apikey", + "authorization", + "clientsecret", + "cvv", + "cvc", + "pan", + "password", + "rawbody", + "signature", + "token", + "webhooksecret", + "paymentmethod", + "paymentmethodid" + ], + normalized + ) + ) { + return true; + } + return reFindNoCase( + "(^|[_-])(api[-_]?key|authorization|client[-_]?secret|cvv|cvc|pan|password|raw[-_]?body|signature|token|webhook[-_]?secret)($|[_-])|client_secret|payment_method", + arguments.key + ) > 0; + } + + string function redactString( required string value ){ + var safe = arguments.value; + safe = reReplaceNoCase( + safe, + "sk_(live|test)_[A-Za-z0-9_-]+", + "[REDACTED]", + "all" + ); + safe = reReplaceNoCase( + safe, + "rk_(live|test)_[A-Za-z0-9_-]+", + "[REDACTED]", + "all" + ); + safe = reReplaceNoCase( + safe, + "pk_(live|test)_[A-Za-z0-9_-]+", + "[REDACTED]", + "all" + ); + safe = reReplaceNoCase( + safe, + "whsec_[A-Za-z0-9_-]+", + "[REDACTED]", + "all" + ); + safe = reReplaceNoCase( + safe, + "(pi|seti)_[A-Za-z0-9_]+_secret_[A-Za-z0-9]+", + "[REDACTED]", + "all" + ); + safe = reReplaceNoCase( safe, "pm_[A-Za-z0-9]+", "[REDACTED]", "all" ); + safe = reReplaceNoCase( + safe, + "Bearer[ ]+[A-Za-z0-9._~+/-]+=*", + "Bearer [REDACTED]", + "all" + ); + for ( var candidate in reMatch( "[0-9][0-9 -]{11,25}[0-9]", safe ) ) { + var digits = reReplace( candidate, "[^0-9]", "", "all" ); + if ( len( digits ) >= 13 && len( digits ) <= 19 && passesLuhn( digits ) ) { + safe = replace( safe, candidate, "[REDACTED]", "all" ); + } + } + return safe; + } + + private boolean function passesLuhn( required string digits ){ + var total = 0; + var parity = ( len( arguments.digits ) + 1 ) % 2; + for ( var position = 1; position <= len( arguments.digits ); position++ ) { + var digit = val( mid( arguments.digits, position, 1 ) ); + if ( position % 2 == parity ) { + digit *= 2; + if ( digit > 9 ) { + digit -= 9; + } + } + total += digit; + } + return total % 10 == 0; + } + +} diff --git a/models/util/SecurityValidator.cfc b/models/util/SecurityValidator.cfc new file mode 100644 index 0000000..73b63fc --- /dev/null +++ b/models/util/SecurityValidator.cfc @@ -0,0 +1,108 @@ +component singleton { + + property name="redactor" inject="Redactor@cbpayments"; + + function init( any redactor ){ + variables.redactor = isNull( arguments.redactor ) ? new cbpayments.models.util.Redactor() : arguments.redactor; + return this; + } + + struct function validateMetadata( struct metadata = {} ){ + if ( arguments.metadata.count() > 50 ) { + throw( type = "cbpayments.InvalidMetadata", message = "Metadata may contain at most 50 keys." ); + } + + var safe = {}; + for ( var key in arguments.metadata ) { + var value = arguments.metadata[ key ]; + if ( len( key ) > 40 || redactor.isSensitiveKey( key ) ) { + throw( type = "cbpayments.InvalidMetadata", message = "Metadata key [#key#] is not allowed." ); + } + if ( !isSimpleValue( value ) || len( toString( value ) ) > 500 ) { + throw( + type = "cbpayments.InvalidMetadata", + message = "Metadata values must be scalar and no longer than 500 characters." + ); + } + validateSafeText( toString( value ), "Metadata value" ); + safe[ key ] = value; + } + return safe; + } + + string function validateDescription( string description = "" ){ + if ( len( arguments.description ) > 500 ) { + throw( type = "cbpayments.InvalidRequest", message = "Descriptions may not exceed 500 characters." ); + } + validateSafeText( arguments.description, "Description" ); + return arguments.description; + } + + string function validateUrl( required string url, boolean allowLocalHttp = false ){ + if ( reFindNoCase( "^https://[^[:space:]]+$", arguments.url ) ) { + return arguments.url; + } + if ( + arguments.allowLocalHttp + && reFindNoCase( "^http://(localhost|127[.]0[.]0[.]1)(:[0-9]+)?(/|$)", arguments.url ) + ) { + return arguments.url; + } + throw( + type = "cbpayments.InvalidUrl", + message = "Payment return URLs must use HTTPS; local HTTP requires allowLocalHttp=true." + ); + } + + string function requireIdempotencyKey( required string idempotencyKey ){ + var key = trim( arguments.idempotencyKey ); + if ( !len( key ) || len( key ) > 255 ) { + throw( + type = "cbpayments.InvalidIdempotencyKey", + message = "A non-empty idempotency key of at most 255 characters is required." + ); + } + return key; + } + + private void function validateSafeText( required string value, required string label ){ + if ( + variables.redactor.redactString( arguments.value ) != arguments.value || containsCardNumber( + arguments.value + ) + ) { + throw( + type = "cbpayments.InvalidPaymentData", + message = "#arguments.label# contains prohibited payment or secret data." + ); + } + } + + private boolean function containsCardNumber( required string value ){ + var candidates = reMatch( "[0-9][0-9 -]{11,25}[0-9]", arguments.value ); + for ( var candidate in candidates ) { + var digits = reReplace( candidate, "[^0-9]", "", "all" ); + if ( len( digits ) >= 13 && len( digits ) <= 19 && passesLuhn( digits ) ) { + return true; + } + } + return false; + } + + private boolean function passesLuhn( required string digits ){ + var total = 0; + var parity = ( len( arguments.digits ) + 1 ) % 2; + for ( var position = 1; position <= len( arguments.digits ); position++ ) { + var digit = val( mid( arguments.digits, position, 1 ) ); + if ( position % 2 == parity ) { + digit *= 2; + if ( digit > 9 ) { + digit -= 9; + } + } + total += digit; + } + return total % 10 == 0; + } + +} diff --git a/providers/cbpayments-monei/ModuleConfig.cfc b/providers/cbpayments-monei/ModuleConfig.cfc new file mode 100644 index 0000000..8b6903e --- /dev/null +++ b/providers/cbpayments-monei/ModuleConfig.cfc @@ -0,0 +1,18 @@ +/** + * MONEI provider module for cbpayments. + */ +component { + + this.title = "cbpayments MONEI provider"; + this.author = "Ortus Solutions"; + this.description = "MONEI payment provider for cbpayments"; + this.version = "1.0.0"; + this.modelNamespace = "cbpayments-monei"; + this.cfmapping = "cbpaymentsmonei"; + this.dependencies = [ "cbpayments" ]; + + function configure(){ + settings = {}; + } + +} diff --git a/providers/cbpayments-monei/README.md b/providers/cbpayments-monei/README.md new file mode 100644 index 0000000..0c20341 --- /dev/null +++ b/providers/cbpayments-monei/README.md @@ -0,0 +1,17 @@ +# cbpayments MONEI Provider + +`cbpayments-monei` is an independently packaged Provider module for cbpayments. It supplies the full WireBox ID `MoneiProvider@cbpayments-monei` and supports hosted checkout, payment intents, manual capture, refunds, and verified webhooks. + +Configure a named Processor in the consuming application: + +```boxlang +moduleSettings.cbpayments.processors.monei = { + provider : "MoneiProvider@cbpayments-monei", + properties : { + apiKey : getSystemSetting( "MONEI_API_KEY" ), + accountId : getSystemSetting( "MONEI_ACCOUNT_ID", "" ) + } +}; +``` + +See the repository's [MONEI Provider guide](https://github.com/coldbox-modules/cbpayments/blob/development/docs/monei.md) and [webhook guide](https://github.com/coldbox-modules/cbpayments/blob/development/docs/webhooks.md) for the operation API, Provider options, and webhook route boundary. diff --git a/providers/cbpayments-monei/box.json b/providers/cbpayments-monei/box.json new file mode 100644 index 0000000..1e794ee --- /dev/null +++ b/providers/cbpayments-monei/box.json @@ -0,0 +1,29 @@ +{ + "name": "cbpayments MONEI Provider", + "version": "1.0.0", + "location": "https://downloads.ortussolutions.com/ortussolutions/coldbox-modules/cbpayments-monei/@build.version@/cbpayments-monei-@build.version@.zip", + "author": "Ortus Solutions ", + "homepage": "https://github.com/coldbox-modules/cbpayments", + "documentation": "https://github.com/coldbox-modules/cbpayments/blob/development/docs/monei.md", + "repository": { + "type": "git", + "url": "https://github.com/coldbox-modules/cbpayments" + }, + "bugs": "https://github.com/coldbox-modules/cbpayments/issues", + "shortDescription": "MONEI payment Provider for cbpayments", + "slug": "cbpayments-monei", + "type": "modules", + "keywords": "payments,monei,checkout,payment intents,webhooks,coldbox", + "license": [ + { + "type": "Apache2", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + } + ], + "dependencies": { + "cbpayments": "^1.0.0" + }, + "installPaths": { + "cbpayments": "modules/cbpayments/" + } +} diff --git a/providers/cbpayments-monei/models/MoneiClient.cfc b/providers/cbpayments-monei/models/MoneiClient.cfc new file mode 100644 index 0000000..ccbe24b --- /dev/null +++ b/providers/cbpayments-monei/models/MoneiClient.cfc @@ -0,0 +1,131 @@ +/** + * Minimal MONEI REST transport. The provider owns all response normalization. + */ +component accessors="true" { + + property name="apiKey" type="string"; + property name="accountId" type="string"; + property name="baseUrl" type="string"; + property name="timeout" type="numeric"; + + function init( + required string apiKey, + string accountId = "", + string baseUrl = "https://api.monei.com/v1", + numeric timeout = 50 + ){ + variables.apiKey = arguments.apiKey; + variables.accountId = arguments.accountId; + variables.baseUrl = reReplace( arguments.baseUrl, "/+$", "" ); + variables.timeout = arguments.timeout; + return this; + } + + struct function createPayment( required struct payload ){ + return performRequest( "POST", "/payments", arguments.payload ); + } + + struct function retrievePayment( required string paymentId ){ + return performRequest( "GET", "/payments/#pathSegment( arguments.paymentId )#" ); + } + + struct function confirmPayment( required string paymentId, required struct payload ){ + return performRequest( + "POST", + "/payments/#pathSegment( arguments.paymentId )#/confirm", + arguments.payload + ); + } + + struct function cancelPayment( required string paymentId ){ + return performRequest( + "POST", + "/payments/#pathSegment( arguments.paymentId )#/cancel", + {} + ); + } + + struct function capturePayment( required string paymentId, required struct payload ){ + return performRequest( + "POST", + "/payments/#pathSegment( arguments.paymentId )#/capture", + arguments.payload + ); + } + + struct function refundPayment( required string paymentId, required struct payload ){ + return performRequest( + "POST", + "/payments/#pathSegment( arguments.paymentId )#/refund", + arguments.payload + ); + } + + private string function pathSegment( required string value ){ + return replace( + urlEncodedFormat( arguments.value ), + "+", + "%20", + "all" + ); + } + + private struct function performRequest( + required string method, + required string path, + struct payload = {} + ){ + var response = {}; + cfhttp( + url = "#variables.baseUrl##arguments.path#", + method = arguments.method, + timeout = variables.timeout, + result = "response", + throwOnError = false + ) { + cfhttpparam( + type = "header", + name = "Accept", + value = "application/json" + ); + cfhttpparam( + type = "header", + name = "Authorization", + value = variables.apiKey + ); + cfhttpparam( + type = "header", + name = "User-Agent", + value = "cbpayments-monei/1.0.0" + ); + if ( len( variables.accountId ) ) { + cfhttpparam( + type = "header", + name = "MONEI-Account-ID", + value = variables.accountId + ); + } + if ( uCase( arguments.method ) != "GET" ) { + cfhttpparam( + type = "header", + name = "Content-Type", + value = "application/json" + ); + cfhttpparam( type = "body", value = serializeJSON( arguments.payload ) ); + } + } + var statusCode = response.keyExists( "statusCode" ) ? val( listFirst( response.statusCode, " " ) ) : 0; + var content = {}; + if ( response.keyExists( "fileContent" ) && isJSON( response.fileContent ) ) { + content = deserializeJSON( response.fileContent ); + } + return { + "status" : statusCode, + "requestId" : response.keyExists( "responseHeader" ) && response.responseHeader.keyExists( "x-request-id" ) + ? response.responseHeader[ "x-request-id" ] + : "", + "content" : content + }; + } + +} diff --git a/providers/cbpayments-monei/models/MoneiProvider.cfc b/providers/cbpayments-monei/models/MoneiProvider.cfc new file mode 100644 index 0000000..5f483e2 --- /dev/null +++ b/providers/cbpayments-monei/models/MoneiProvider.cfc @@ -0,0 +1,719 @@ +/** + * MONEI provider backed by its REST Payments API. + */ +component + extends ="cbpayments.models.providers.AbstractPaymentProvider" + implements="cbpayments.models.contracts.capabilities.IHostedCheckoutProvider,cbpayments.models.contracts.capabilities.IPaymentIntentsProvider,cbpayments.models.contracts.capabilities.ICaptureProvider,cbpayments.models.contracts.capabilities.IRefundsProvider,cbpayments.models.contracts.capabilities.IWebhookProvider" +{ + + function init(){ + super.init(); + variables.providerType = "MONEI"; + variables.supportedCapabilities = [ + "hostedCheckout", + "paymentIntents", + "capture", + "refunds", + "webhooks" + ]; + variables.apiKey = ""; + variables.accountId = ""; + variables.toleranceSeconds = 300; + return this; + } + + any function startup( required string processorName, struct properties = {} ){ + validateProperties( arguments.properties ); + variables.apiKey = propertyValue( arguments.properties, "apiKey", "" ); + variables.accountId = propertyValue( arguments.properties, "accountId", "" ); + variables.toleranceSeconds = propertyValue( + arguments.properties, + "toleranceSeconds", + moduleWebhookTolerance() + ); + if ( arguments.properties.keyExists( "client" ) ) { + variables.client = arguments.properties.client; + } else { + if ( !len( variables.apiKey ) ) { + throw( + type = "cbpayments.InvalidConfiguration", + message = "MONEI processor [#arguments.processorName#] requires apiKey." + ); + } + variables.client = variables.wirebox.getInstance( + name = "MoneiClient@cbpayments-monei", + initArguments = { + apiKey : variables.apiKey, + accountId : variables.accountId, + baseUrl : propertyValue( + arguments.properties, + "baseUrl", + "https://api.monei.com/v1" + ), + timeout : propertyValue( arguments.properties, "timeout", 50 ) + } + ); + } + return super.startup( arguments.processorName, arguments.properties ); + } + + any function createCheckout( required any paymentRequest ){ + var paymentRequestRef = arguments.paymentRequest; + var options = moneiOptions( + paymentRequestRef.getProviderOptions(), + [ + "callbackUrl", + "failUrl", + "allowedPaymentMethods", + "expireAt" + ] + ); + var payload = { + "amount" : paymentRequestRef.getMoney().getAmountMinor(), + "currency" : uCase( paymentRequestRef.getMoney().getCurrency() ), + "orderId" : paymentRequestRef.getIdempotencyKey(), + "completeUrl" : paymentRequestRef.getReturnUrl(), + "cancelUrl" : paymentRequestRef.getCancelUrl(), + "description" : paymentRequestRef.getDescription(), + "metadata" : paymentRequestRef.getMetadata() + }; + copyDefined( options, payload, "callbackUrl" ); + copyDefined( options, payload, "failUrl" ); + copyDefined( options, payload, "allowedPaymentMethods" ); + copyDefined( options, payload, "expireAt" ); + return moneiOperation( + "hostedCheckout.create", + paymentRequestRef.getIdempotencyKey(), + function(){ + return mapResponse( + "hostedCheckout.create", + variables.client.createPayment( payload ), + paymentRequestRef.getIdempotencyKey() + ); + } + ); + } + + any function retrieveCheckout( required string externalId ){ + return retrievePayment( "hostedCheckout.retrieve", arguments.externalId ); + } + + any function expireCheckout( required string externalId, required string idempotencyKey ){ + return cancelPayment( + "hostedCheckout.expire", + arguments.externalId, + arguments.idempotencyKey + ); + } + + any function createPaymentIntent( required any paymentRequest ){ + var paymentRequestRef = arguments.paymentRequest; + var options = moneiOptions( + paymentRequestRef.getProviderOptions(), + [ + "callbackUrl", + "completeUrl", + "failUrl", + "cancelUrl", + "sessionId", + "generatePaymentToken" + ] + ); + var payload = { + "amount" : paymentRequestRef.getMoney().getAmountMinor(), + "currency" : uCase( paymentRequestRef.getMoney().getCurrency() ), + "orderId" : paymentRequestRef.getIdempotencyKey(), + "transactionType" : paymentRequestRef.getCaptureMethod() == "manual" ? "AUTH" : "SALE", + "description" : paymentRequestRef.getDescription(), + "metadata" : paymentRequestRef.getMetadata() + }; + if ( len( paymentRequestRef.getPaymentMethodId() ) ) { + payload.paymentToken = paymentRequestRef.getPaymentMethodId(); + } + for ( var key in options ) { + payload[ key ] = options[ key ]; + } + return moneiOperation( + "paymentIntents.create", + paymentRequestRef.getIdempotencyKey(), + function(){ + return mapResponse( + "paymentIntents.create", + variables.client.createPayment( payload ), + paymentRequestRef.getIdempotencyKey() + ); + } + ); + } + + any function retrievePaymentIntent( required string externalId ){ + return retrievePayment( "paymentIntents.retrieve", arguments.externalId ); + } + + any function confirmPaymentIntent( + required string externalId, + required string idempotencyKey, + struct options = {} + ){ + var paymentId = requireExternalId( arguments.externalId ); + var normalizedIdempotency = requireIdempotencyKey( arguments.idempotencyKey ); + var payload = validateConfirmOptions( arguments.options ); + return moneiOperation( + "paymentIntents.confirm", + normalizedIdempotency, + function(){ + return mapResponse( + "paymentIntents.confirm", + variables.client.confirmPayment( paymentId, payload ), + normalizedIdempotency + ); + } + ); + } + + any function cancelPaymentIntent( required string externalId, required string idempotencyKey ){ + return cancelPayment( + "paymentIntents.cancel", + arguments.externalId, + arguments.idempotencyKey + ); + } + + any function capturePayment( required any paymentRequest ){ + var paymentRequestRef = arguments.paymentRequest; + var payload = {}; + if ( !isNull( paymentRequestRef.getMoney() ) ) { + payload.amount = paymentRequestRef.getMoney().getAmountMinor(); + } + return mutatePayment( + "capture.create", + paymentRequestRef.getExternalId(), + paymentRequestRef.getIdempotencyKey(), + function(){ + return variables.client.capturePayment( paymentRequestRef.getExternalId(), payload ); + } + ); + } + + any function createRefund( required any paymentRequest ){ + var paymentRequestRef = arguments.paymentRequest; + var payload = {}; + if ( len( paymentRequestRef.getReason() ) ) { + payload.refundReason = paymentRequestRef.getReason(); + } + if ( !isNull( paymentRequestRef.getMoney() ) ) { + payload.amount = paymentRequestRef.getMoney().getAmountMinor(); + } + return mutatePayment( + "refunds.create", + paymentRequestRef.getExternalId(), + paymentRequestRef.getIdempotencyKey(), + function(){ + return variables.client.refundPayment( paymentRequestRef.getExternalId(), payload ); + } + ); + } + + any function retrieveRefund( required string externalId ){ + return retrievePayment( "refunds.retrieve", arguments.externalId ); + } + + any function verifyWebhook( + required string rawBody, + required string signature, + string accountId = "" + ){ + if ( !len( variables.apiKey ) ) { + rejectWebhook( "cbpayments.InvalidConfiguration", "MONEI webhook verification requires apiKey." ); + } + var signatureParts = parseSignature( arguments.signature ); + validateTimestamp( signatureParts.timestamp ); + var expected = lCase( + hmac( + signatureParts.timestamp & "." & arguments.rawBody, + variables.apiKey, + "hmacSHA256", + "utf-8" + ) + ); + var signatureMatches = signatureParts.signatures.some( function( candidate ){ + return secureEquals( expected, candidate ); + } ); + if ( !signatureMatches ) { + rejectWebhook( "cbpayments.InvalidWebhookSignature", "The webhook signature is invalid." ); + } + if ( !isJSON( arguments.rawBody ) ) { + rejectWebhook( "cbpayments.MalformedWebhook", "The signed webhook body is malformed." ); + } + var envelope = deserializeJSON( arguments.rawBody ); + if ( !isStruct( envelope ) ) { + rejectWebhook( "cbpayments.MalformedWebhook", "The signed webhook envelope is malformed." ); + } + if ( envelope.keyExists( "id" ) && envelope.keyExists( "status" ) && !envelope.keyExists( "object" ) ) { + envelope = { + "id" : "callback-#hash( arguments.rawBody, "SHA-256" )#", + "type" : eventTypeForStatus( envelope.status ), + "object" : envelope, + "createdAt" : envelope.keyExists( "createdAt" ) ? envelope.createdAt : 0 + }; + } + if ( + !envelope.keyExists( "id" ) + || !envelope.keyExists( "type" ) + || !envelope.keyExists( "object" ) + || !isStruct( envelope.object ) + ) { + rejectWebhook( "cbpayments.MalformedWebhook", "The signed webhook envelope is malformed." ); + } + var actualAccount = envelope.object.keyExists( "accountId" ) ? safeString( envelope.object.accountId ) : variables.accountId; + var expectedAccount = len( arguments.accountId ) ? arguments.accountId : variables.accountId; + if ( len( expectedAccount ) && actualAccount != expectedAccount ) { + rejectWebhook( + "cbpayments.WebhookAccountMismatch", + "The webhook account does not match this processor." + ); + } + var paymentEvent = paymentEventFromEnvelope( envelope, arguments.rawBody, actualAccount ); + return announceVerifiedWebhook( paymentEvent ); + } + + private any function retrievePayment( required string operation, required string externalId ){ + var operationName = arguments.operation; + var paymentId = requireExternalId( arguments.externalId ); + return moneiOperation( + operationName, + "", + function(){ + return mapResponse( operationName, variables.client.retrievePayment( paymentId ) ); + } + ); + } + + private any function cancelPayment( + required string operation, + required string externalId, + required string idempotencyKey + ){ + var paymentId = arguments.externalId; + return mutatePayment( + arguments.operation, + paymentId, + arguments.idempotencyKey, + function(){ + return variables.client.cancelPayment( paymentId ); + } + ); + } + + private any function mutatePayment( + required string operation, + required string externalId, + required string idempotencyKey, + required any callback + ){ + var operationName = arguments.operation; + var normalizedIdempotency = requireIdempotencyKey( arguments.idempotencyKey ); + requireExternalId( arguments.externalId ); + var callbackRef = arguments.callback; + return moneiOperation( + operationName, + normalizedIdempotency, + function(){ + return mapResponse( + operationName, + callbackRef(), + normalizedIdempotency + ); + } + ); + } + + private any function moneiOperation( + required string operation, + required string idempotencyKey, + required any callback + ){ + var operationName = arguments.operation; + var idempotency = arguments.idempotencyKey; + var callbackRef = arguments.callback; + return runOperation( + operationName, + function(){ + try { + return callbackRef(); + } catch ( any exception ) { + if ( findNoCase( "Connection", exception.type ) || findNoCase( "timeout", exception.message ) ) { + return failureResult( + operation = operationName, + category = "network", + code = "connection_failure", + retryable = true, + idempotencyKey = idempotency + ); + } + rethrow; + } + }, + idempotency + ); + } + + private any function mapResponse( + required string operation, + required any response, + string idempotencyKey = "" + ){ + if ( + !isStruct( arguments.response ) + || !arguments.response.keyExists( "status" ) + || !arguments.response.keyExists( "content" ) + || !isStruct( arguments.response.content ) + ) { + return failureResult( + operation = arguments.operation, + category = "provider", + code = "malformed_response", + idempotencyKey = arguments.idempotencyKey + ); + } + var httpStatus = val( arguments.response.status ); + var content = arguments.response.content; + var requestId = arguments.response.keyExists( "requestId" ) ? safeString( arguments.response.requestId ) : ""; + if ( httpStatus < 200 || httpStatus >= 300 ) { + return failureResult( + operation = arguments.operation, + category = httpStatus == 401 || httpStatus == 403 + ? "authentication" + : httpStatus == 429 + ? "rate_limited" + : httpStatus >= 500 || !httpStatus + ? "provider" + : "validation", + code = content.keyExists( "statusCode" ) ? safeString( content.statusCode ) : "monei_error", + retryable = httpStatus == 429 || httpStatus >= 500 || !httpStatus, + requestId = requestId, + idempotencyKey = arguments.idempotencyKey + ); + } + if ( !content.keyExists( "id" ) ) { + return failureResult( + operation = arguments.operation, + category = "provider", + code = "malformed_response", + requestId = requestId, + idempotencyKey = arguments.idempotencyKey + ); + } + var resultArguments = { + "operation" : arguments.operation, + "status" : normalizeStatus( content.keyExists( "status" ) ? content.status : "unknown" ), + "externalId" : safeString( content.id ), + "requestId" : requestId, + "idempotencyKey" : arguments.idempotencyKey, + "nextAction" : nextAction( content ), + "providerDetails" : providerDetails( content ) + }; + if ( content.keyExists( "amount" ) && content.keyExists( "currency" ) && isNumeric( content.amount ) ) { + resultArguments.amount = new cbpayments.models.contracts.Money( + int( content.amount ), + lCase( content.currency ) + ); + } + return successResult( argumentCollection = resultArguments ); + } + + private struct function nextAction( required struct content ){ + if ( + !arguments.content.keyExists( "nextAction" ) + || !isStruct( arguments.content.nextAction ) + || !arguments.content.nextAction.keyExists( "redirectUrl" ) + ) { + return {}; + } + try { + return { + "type" : "redirect", + "redirectUrl" : variables.securityValidator.validateUrl( arguments.content.nextAction.redirectUrl ) + }; + } catch ( any unsafeUrl ) { + return {}; + } + } + + private struct function providerDetails( required struct content ){ + var result = {}; + for ( var key in [ "statusCode", "orderId", "transactionType" ] ) { + if ( arguments.content.keyExists( key ) && isSimpleValue( arguments.content[ key ] ) ) { + result[ key ] = safeString( arguments.content[ key ] ); + } + } + return result; + } + + private any function paymentEventFromEnvelope( + required struct envelope, + required string rawBody, + string actualAccount = "" + ){ + var object = arguments.envelope.object; + var providerEventType = safeString( arguments.envelope.type ); + var eventArguments = { + "eventId" : safeString( arguments.envelope.id ), + "eventType" : normalizeWebhookEventType( providerEventType ), + "providerEventType" : providerEventType, + "occurredAt" : arguments.envelope.keyExists( "createdAt" ) ? arguments.envelope.createdAt : 0, + "livemode" : object.keyExists( "livemode" ) ? object.livemode : false, + "processorName" : variables.processorName, + "providerType" : variables.providerType, + "providerAccountId" : arguments.actualAccount, + "objectType" : listFirst( providerEventType, "." ), + "objectId" : object.keyExists( "id" ) ? safeString( object.id ) : "", + "status" : normalizeStatus( object.keyExists( "status" ) ? object.status : "unknown" ), + "providerDetails" : providerDetails( object ), + "payloadChecksum" : hash( arguments.rawBody, "SHA-256" ), + "matchedSecretIndex" : 1 + }; + if ( object.keyExists( "amount" ) && object.keyExists( "currency" ) && isNumeric( object.amount ) ) { + eventArguments.amount = new cbpayments.models.contracts.Money( + int( object.amount ), + lCase( object.currency ) + ); + } + return new cbpayments.models.contracts.results.PaymentEvent( argumentCollection = eventArguments ); + } + + private string function normalizeWebhookEventType( required string providerEventType ){ + var mappings = { + "charge.authorized" : "payment.authorized", + "charge.pending" : "payment.processing", + "charge.pending_processing" : "payment.processing", + "charge.succeeded" : "payment.succeeded", + "charge.captured" : "payment.succeeded", + "charge.failed" : "payment.failed", + "charge.canceled" : "payment.canceled", + "charge.expired" : "payment.canceled", + "charge.refunded" : "payment.refunded", + "charge.partially_refunded" : "payment.partially_refunded", + "refund.succeeded" : "refund.succeeded", + "refund.pending" : "refund.processing", + "refund.failed" : "refund.failed" + }; + var key = lCase( arguments.providerEventType ); + return mappings.keyExists( key ) ? mappings[ key ] : "unknown"; + } + + private struct function parseSignature( required string signature ){ + var timestamp = ""; + var signatures = []; + for ( var part in listToArray( arguments.signature ) ) { + var name = listFirst( part, "=" ); + var value = listRest( part, "=" ); + if ( name == "t" ) { + timestamp = value; + } else if ( name == "v1" && reFind( "^[a-fA-F0-9]{64}$", value ) ) { + signatures.append( lCase( value ) ); + } + } + if ( !isNumeric( timestamp ) || !signatures.len() ) { + rejectWebhook( "cbpayments.InvalidWebhookSignature", "The webhook signature is invalid." ); + } + return { "timestamp" : timestamp, "signatures" : signatures }; + } + + private string function eventTypeForStatus( required any status ){ + var mappings = { + "AUTHORIZED" : "charge.authorized", + "PENDING" : "charge.pending", + "PENDING_PROCESSING" : "charge.pending_processing", + "SUCCEEDED" : "charge.succeeded", + "FAILED" : "charge.failed", + "CANCELED" : "charge.canceled", + "EXPIRED" : "charge.expired", + "REFUNDED" : "charge.refunded", + "PARTIALLY_REFUNDED" : "charge.partially_refunded" + }; + var key = uCase( safeString( arguments.status ) ); + return mappings.keyExists( key ) ? mappings[ key ] : "charge.updated"; + } + + private void function validateTimestamp( required numeric timestamp ){ + var nowUnix = fix( createObject( "java", "java.lang.System" ).currentTimeMillis() / 1000 ); + if ( variables.toleranceSeconds > 0 && abs( nowUnix - arguments.timestamp ) > variables.toleranceSeconds ) { + rejectWebhook( + "cbpayments.StaleWebhook", + "The webhook timestamp is outside the configured tolerance." + ); + } + } + + private boolean function secureEquals( required string expected, required string actual ){ + try { + return createObject( "java", "java.security.MessageDigest" ).isEqual( + binaryDecode( arguments.expected, "hex" ), + binaryDecode( arguments.actual, "hex" ) + ); + } catch ( any invalidSignature ) { + return false; + } + } + + private void function rejectWebhook( required string failureType, required string failureMessage ){ + announce( + "cbpaymentsOnWebhookRejected", + safeContext().append( { "failureType" : arguments.failureType } ) + ); + throw( type = arguments.failureType, message = arguments.failureMessage ); + } + + private struct function validateConfirmOptions( required struct options ){ + var allowed = [ + "paymentToken", + "sessionId", + "generatePaymentToken" + ]; + for ( var key in arguments.options ) { + if ( !allowed.findNoCase( key ) ) { + throw( + type = "cbpayments.InvalidProviderOptions", + message = "Unknown MONEI confirmation option [#key#]." + ); + } + } + return duplicate( arguments.options ); + } + + private struct function moneiOptions( required struct providerOptions, required array allowed ){ + if ( !arguments.providerOptions.count() ) { + return {}; + } + if ( !arguments.providerOptions.keyExists( "MONEI" ) || !isStruct( arguments.providerOptions.MONEI ) ) { + throw( + type = "cbpayments.InvalidProviderOptions", + message = "MONEI options must be namespaced under providerOptions.MONEI." + ); + } + for ( var key in arguments.providerOptions.MONEI ) { + if ( !arguments.allowed.findNoCase( key ) ) { + throw( type = "cbpayments.InvalidProviderOptions", message = "Unknown MONEI option [#key#]." ); + } + } + return duplicate( arguments.providerOptions.MONEI ); + } + + private void function copyDefined( + required struct source, + required struct target, + required string key + ){ + if ( arguments.source.keyExists( arguments.key ) ) { + arguments.target[ arguments.key ] = arguments.source[ arguments.key ]; + } + } + + private string function normalizeStatus( required any status ){ + var mappings = { + "PENDING" : "pending", + "PENDING_PROCESSING" : "pending", + "AUTHORIZED" : "authorized", + "SUCCEEDED" : "succeeded", + "FAILED" : "failed", + "CANCELED" : "cancelled", + "EXPIRED" : "cancelled", + "REFUNDED" : "refunded", + "PARTIALLY_REFUNDED" : "partially_refunded" + }; + var key = uCase( safeString( arguments.status ) ); + return mappings.keyExists( key ) ? mappings[ key ] : "unknown"; + } + + private string function safeString( required any value, string fallback = "" ){ + if ( !isSimpleValue( arguments.value ) ) { + return arguments.fallback; + } + var safe = variables.redactor.redactString( toString( arguments.value ) ); + return len( safe ) <= 255 ? safe : left( safe, 255 ); + } + + private void function validateProperties( required struct properties ){ + var allowed = [ + "apiKey", + "accountId", + "baseUrl", + "timeout", + "toleranceSeconds", + "client" + ]; + for ( var key in arguments.properties ) { + if ( !allowed.findNoCase( key ) ) { + throw( type = "cbpayments.InvalidConfiguration", message = "Unknown MONEI property [#key#]." ); + } + } + if ( + arguments.properties.keyExists( "timeout" ) + && ( !isNumeric( arguments.properties.timeout ) || arguments.properties.timeout <= 0 ) + ) { + throw( type = "cbpayments.InvalidConfiguration", message = "MONEI timeout must be positive." ); + } + if ( + arguments.properties.keyExists( "toleranceSeconds" ) + && ( + !isNumeric( arguments.properties.toleranceSeconds ) + || arguments.properties.toleranceSeconds < 0 + ) + ) { + throw( + type = "cbpayments.InvalidConfiguration", + message = "MONEI toleranceSeconds must be a non-negative number." + ); + } + for ( var simpleProperty in [ "apiKey", "accountId", "baseUrl" ] ) { + if ( + arguments.properties.keyExists( simpleProperty ) + && ( + !isSimpleValue( arguments.properties[ simpleProperty ] ) + || reFind( "[\r\n]", toString( arguments.properties[ simpleProperty ] ) ) + ) + ) { + throw( + type = "cbpayments.InvalidConfiguration", + message = "MONEI property [#simpleProperty#] must be a single-line string." + ); + } + } + if ( arguments.properties.keyExists( "baseUrl" ) ) { + try { + variables.securityValidator.validateUrl( arguments.properties.baseUrl, true ); + } catch ( cbpayments.InvalidUrl invalidUrl ) { + throw( + type = "cbpayments.InvalidConfiguration", + message = "MONEI baseUrl must use HTTPS or local HTTP." + ); + } + } + if ( arguments.properties.keyExists( "client" ) && !isObject( arguments.properties.client ) ) { + throw( type = "cbpayments.InvalidConfiguration", message = "MONEI client must be an object." ); + } + } + + private any function propertyValue( + required struct properties, + required string name, + required any defaultValue + ){ + return arguments.properties.keyExists( arguments.name ) + ? arguments.properties[ arguments.name ] + : arguments.defaultValue; + } + + private numeric function moduleWebhookTolerance(){ + if ( + !isNull( variables.moduleSettings ) + && variables.moduleSettings.keyExists( "webhooks" ) + && variables.moduleSettings.webhooks.keyExists( "toleranceSeconds" ) + ) { + return variables.moduleSettings.webhooks.toleranceSeconds; + } + return 300; + } + +} diff --git a/readme.md b/readme.md index c537974..3535a93 100644 --- a/readme.md +++ b/readme.md @@ -1,3 +1,106 @@ -`cbpayments` - The Coldbox Payments and Subscription Processing Module +# cbpayments +Provider-neutral payment processors for ColdBox 8 applications. +cbpayments gives an application one stable API for checkout, payment intents, capture, refunds, setup intents, customers, and verified webhooks. Provider modules own vendor SDKs and transport. Applications continue to own authorization, customers, invoices, accounting, durable idempotency records, ledgers, and fulfillment. + +## Requirements + +- ColdBox 8 +- BoxLang 1 native or CFML compatibility, Lucee 6/7, or Adobe ColdFusion 2023/2025 +- Java 21 + +## Installation + +```bash +box install cbpayments +``` + +## Terminology + +- A **Provider** is code that implements `IPaymentProvider`, such as `StripeProvider`, `MoneiProvider`, `MockProvider`, or `NullProvider`. +- A **Processor** is one configured Provider plus its properties. An application can configure several Processors backed by the same Provider. + +## Five-minute example + +Configure a deterministic Processor for local development and tests: + +```boxlang +moduleSettings.cbpayments = { + defaultProcessor : "payments", + processors : { + payments : { + provider : "MockProvider@cbpayments", + properties : {} + } + } +}; +``` + +Use WireBox to inject both the service and request objects: + +```boxlang +component { + + property name="paymentService" inject="PaymentService@cbpayments"; + property name="wirebox" inject="wirebox"; + + function checkout( event, rc, prc ){ + var money = wirebox.getInstance( + name = "Money@cbpayments", + initArguments = { amountMinor : 2500, currency : "usd" } + ); + var checkoutRequest = wirebox.getInstance( + name = "HostedCheckoutRequest@cbpayments", + initArguments = { + money : money, + idempotencyKey : "order-42-checkout-v1", + returnUrl : "https://example.com/payments/complete", + cancelUrl : "https://example.com/payments/cancel" + } + ); + var result = paymentService.createCheckout( checkoutRequest ); + + return relocate( url = result.getNextAction().redirectUrl ); + } + +} +``` + +Switch the Processor to `StripeProvider@cbpayments` or `MoneiProvider@cbpayments-monei` without changing the operation code. + +## Capabilities in use + +Capability checks should guard real operations, not stand alone as dead code: + +```boxlang +if ( !paymentService.supports( "receivables", "refunds" ) ) { + throw( type = "App.RefundsUnavailable" ); +} + +var refundRequest = wirebox.getInstance( + name = "RefundRequest@cbpayments", + initArguments = { + externalId : payment.providerPaymentId, + idempotencyKey : "refund-#payment.id#-v1" + } +); +var refund = paymentService.createRefund( refundRequest, "receivables" ); +``` + +## Documentation + +- [Configuration](docs/configuration.md) +- [Providers, Processors, and PaymentService API](docs/providers.md) +- [Contracts and capabilities](docs/contracts.md) +- [Stripe guide](docs/stripe.md) +- [MONEI guide](docs/monei.md) +- [Webhook processing](docs/webhooks.md) +- [Custom Provider author guide](docs/custom-providers.md) +- [Testing guide](docs/testing.md) +- [Security and go-live checklist](docs/security.md) +- [Compatibility and migration](docs/compatibility.md) + +## License + +Apache License 2.0. diff --git a/server-adobe@2018.json b/server-adobe@2018.json deleted file mode 100644 index b532835..0000000 --- a/server-adobe@2018.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name":"cbpayments-adobe@2018", - "app":{ - "serverHomeDirectory":".engine/adobe2018", - "cfengine":"adobe@2018" - }, - "web":{ - "http":{ - "port":"60299" - }, - "rewrites":{ - "enable":"true" - }, - "webroot": "test-harness", - "aliases":{ - "/moduleroot/cbpayments":"../" - } - }, - "openBrowser":"false", - "cfconfig": { - "file" : ".cfconfig.json" - } -} diff --git a/server-adobe@2023.json b/server-adobe@2023.json index 1c691f1..02576b3 100644 --- a/server-adobe@2023.json +++ b/server-adobe@2023.json @@ -11,19 +11,20 @@ "rewrites":{ "enable":"true" }, - "webroot": "test-harness", - "aliases":{ + "webroot":"test-harness", + "aliases":{ "/moduleroot/cbpayments":"../" } }, "jvm":{ - "heapSize":"1024" + "heapSize":"768", + "javaVersion":"openjdk21_jre" }, "openBrowser":"false", - "cfconfig": { - "file" : ".cfconfig.json" - }, - "scripts" : { + "cfconfig":{ + "file":".cfconfig.json" + }, + "scripts":{ "onServerInstall":"cfpm install zip,debugger" } } diff --git a/server-adobe@2021.json b/server-adobe@2025.json similarity index 50% rename from server-adobe@2021.json rename to server-adobe@2025.json index 6c46b31..6287962 100644 --- a/server-adobe@2021.json +++ b/server-adobe@2025.json @@ -1,8 +1,8 @@ { - "name":"cbpayments-adobe@2021", + "name":"cbpayments-adobe@2025", "app":{ - "serverHomeDirectory":".engine/adobe2021", - "cfengine":"adobe@2021" + "serverHomeDirectory":".engine/adobe2025", + "cfengine":"adobe@2025" }, "web":{ "http":{ @@ -11,19 +11,20 @@ "rewrites":{ "enable":"true" }, - "webroot": "test-harness", - "aliases":{ + "webroot":"test-harness", + "aliases":{ "/moduleroot/cbpayments":"../" } }, "jvm":{ - "heapSize":"1024" + "heapSize":"768", + "javaVersion":"openjdk21_jre" }, "openBrowser":"false", - "cfconfig": { - "file" : ".cfconfig.json" - }, - "scripts" : { + "cfconfig":{ + "file":".cfconfig.json" + }, + "scripts":{ "onServerInstall":"cfpm install zip,debugger" } } diff --git a/server-boxlang-cfml@1.json b/server-boxlang-cfml@1.json new file mode 100644 index 0000000..3c63e99 --- /dev/null +++ b/server-boxlang-cfml@1.json @@ -0,0 +1,32 @@ +{ + "name":"cbpayments-boxlang-cfml@1", + "app":{ + "serverHomeDirectory":".engine/boxlang-cfml", + "cfengine":"boxlang@1" + }, + "web":{ + "http":{ + "port":"60299" + }, + "rewrites":{ + "enable":"true" + }, + "webroot":"test-harness", + "aliases":{ + "/moduleroot/cbpayments":"../" + } + }, + "JVM":{ + "heapSize":"768", + "javaVersion":"openjdk21_jre", + "args":"-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=8888" + }, + "openBrowser":"false", + "cfconfig":{ + "file":".cfconfig.json" + }, + "env":{}, + "scripts":{ + "onServerInitialInstall":"install bx-compat-cfml --noSave" + } +} diff --git a/server-boxlang@1.json b/server-boxlang@1.json index 3f55d97..843c341 100644 --- a/server-boxlang@1.json +++ b/server-boxlang@1.json @@ -1,13 +1,10 @@ { + "name":"cbpayments-boxlang@1", "app":{ - "cfengine":"boxlang@be", - "serverHomeDirectory":".engine/boxlang" + "serverHomeDirectory":".engine/boxlang", + "cfengine":"boxlang@1" }, - "name":"cbpayments-boxlang@1", - "force":true, - "openBrowser":false, "web":{ - "directoryBrowsing":true, "http":{ "port":"60299" }, @@ -16,21 +13,17 @@ }, "webroot":"test-harness", "aliases":{ - "/moduleroot/cbpayments":"./" + "/moduleroot/cbpayments":"../" } }, "JVM":{ - "heapSize":"1024", - "javaVersion":"openjdk21_jdk", - "args":"-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=9999" + "heapSize":"768", + "javaVersion":"openjdk21_jre", + "args":"-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=8888" }, + "openBrowser":"false", "cfconfig":{ "file":".cfconfig.json" }, - "env":{ - "BOXLANG_DEBUG":true - }, - "scripts":{ - "onServerInitialInstall":"install bx-mail,bx-mysql,bx-derby,bx-compat-cfml@be,bx-unsafe-evaluate,bx-esapi --noSave" - } -} \ No newline at end of file + "env":{} +} diff --git a/server-lucee@6.json b/server-lucee@6.json index 1959826..c7030cc 100644 --- a/server-lucee@6.json +++ b/server-lucee@6.json @@ -11,13 +11,17 @@ "rewrites":{ "enable":"true" }, - "webroot": "test-harness", - "aliases":{ + "webroot":"test-harness", + "aliases":{ "/moduleroot/cbpayments":"../" } }, "openBrowser":"false", - "cfconfig": { - "file" : ".cfconfig.json" - } + "JVM":{ + "heapSize":"768", + "javaVersion":"openjdk21_jre" + }, + "cfconfig":{ + "file":".cfconfig.json" + } } diff --git a/server-lucee@5.json b/server-lucee@7.json similarity index 64% rename from server-lucee@5.json rename to server-lucee@7.json index f5c1b1b..54eb2f3 100644 --- a/server-lucee@5.json +++ b/server-lucee@7.json @@ -1,8 +1,8 @@ { - "name":"cbpayments-lucee@5", + "name":"cbpayments-lucee@7", "app":{ - "serverHomeDirectory":".engine/lucee5", - "cfengine":"lucee@5" + "serverHomeDirectory":".engine/lucee7", + "cfengine":"lucee@7" }, "web":{ "http":{ @@ -17,6 +17,10 @@ } }, "openBrowser":"false", + "JVM":{ + "heapSize":"768", + "javaVersion":"openjdk21_jre" + }, "cfconfig":{ "file":".cfconfig.json" } diff --git a/test-harness/Application.cfc b/test-harness/Application.cfc index dc575cb..b10f9ef 100644 --- a/test-harness/Application.cfc +++ b/test-harness/Application.cfc @@ -46,23 +46,7 @@ component{ // Module Root + Path Mappings this.mappings[ "/moduleroot" ] = moduleRootPath; this.mappings[ "/#request.MODULE_NAME#" ] = modulePath; - - // ORM definitions: ENABLE IF NEEDED - //this.datasource = "coolblog"; - //this.ormEnabled = "true"; - /** - this.ormSettings = { - cfclocation = [ "models" ], - logSQL = true, - dbcreate = "update", - secondarycacheenabled = false, - cacheProvider = "ehcache", - flushAtRequestEnd = false, - eventhandling = true, - eventHandler = "cborm.models.EventHandler", - skipcfcWithError = true - }; - **/ + this.mappings[ "/cbpaymentsproviders" ] = modulePath & "providers/"; // application start public boolean function onApplicationStart(){ diff --git a/test-harness/box.json b/test-harness/box.json index f50a63a..9a24db8 100644 --- a/test-harness/box.json +++ b/test-harness/box.json @@ -1,18 +1,20 @@ { - "name":"Tester", + "name":"cbpayments Test Harness", "version":"0.0.0", "slug":"tester", "private":true, "description":"", "dependencies":{ - "coldbox":"^7.0.0" + "coldbox":"8.1.0+34", + "stripecfml":"4.1.0" }, "devDependencies":{ - "testbox":"*" + "testbox":"7.0.0+19" }, "installPaths":{ - "coldbox":"coldbox", - "testbox":"testbox" + "coldbox":"coldbox/", + "testbox":"testbox/", + "stripecfml":"modules/stripecfml/" }, "testbox":{ "runner":"http://localhost:60299/tests/runner.cfm" diff --git a/test-harness/config/Coldbox.cfc b/test-harness/config/Coldbox.cfc index 440b6b4..bc32823 100644 --- a/test-harness/config/Coldbox.cfc +++ b/test-harness/config/Coldbox.cfc @@ -6,7 +6,7 @@ // coldbox directives coldbox = { //Application Setup - appName = "Module Tester", + appName = "cbpayments Test Harness", //Development Settings reinitPassword = "", @@ -44,15 +44,19 @@ modules = { // An array of modules names to load, empty means all of them include = [], - // An array of modules names to NOT load, empty means none - exclude = [] + // Provider modules are activated explicitly by their integration specs. + exclude = [ "cbpayments-fixture", "cbpayments-monei" ] }; moduleSettings = { - "stripecfml" : { - "apiKey" : getSystemSetting( "STRIPE_API_KEY", "" ) + cbpayments : { + defaultProcessor : "mock", + processors : { + mock : { provider : "MockProvider@cbpayments", properties : {} }, + disabled : { provider : "NullProvider@cbpayments", properties : {} } + } } - } + }; //Register interceptors as an array, we need order interceptors = [ @@ -88,6 +92,11 @@ moduleName = request.MODULE_NAME, invocationPath = "moduleroot" ); + + // Reload the renderer in case we have module helpers + controller.getRenderer().startup() + // Reload all interceptors with new mixins if available. + controller.getInterceptorService().announce( "cbLoadInterceptorHelpers" ) } } diff --git a/test-harness/index.cfm b/test-harness/index.cfm index 7331009..d53ceed 100644 --- a/test-harness/index.cfm +++ b/test-harness/index.cfm @@ -1,9 +1,2 @@  - diff --git a/test-harness/layouts/Main.cfm b/test-harness/layouts/Main.cfm index b50f9ab..ef4e5ca 100644 --- a/test-harness/layouts/Main.cfm +++ b/test-harness/layouts/Main.cfm @@ -1,5 +1,5 @@  -

Module Tester

+

cbpayments Test Harness

#view()#
diff --git a/test-harness/modules/cbpayments-fixture/ModuleConfig.cfc b/test-harness/modules/cbpayments-fixture/ModuleConfig.cfc new file mode 100644 index 0000000..6d9ceab --- /dev/null +++ b/test-harness/modules/cbpayments-fixture/ModuleConfig.cfc @@ -0,0 +1,28 @@ +component { + + this.title = "cbpayments fixture provider"; + this.modelNamespace = "cbpayments-fixture"; + this.cfmapping = "cbpayments-fixture"; + this.dependencies = [ "cbpayments" ]; + + function configure(){ + settings = { + cbpayments : { + processors : { + configured : { + provider : "FixtureProvider@cbpayments-fixture", + properties : {} + } + } + } + }; + } + + function onLoad(){ + } + + function onUnload(){ + wirebox.getInstance( "PaymentService@cbpayments" ).unregisterProcessor( "configured@cbpayments-fixture" ); + } + +} diff --git a/test-harness/modules/cbpayments-fixture/models/FixtureClient.cfc b/test-harness/modules/cbpayments-fixture/models/FixtureClient.cfc new file mode 100644 index 0000000..fef02d4 --- /dev/null +++ b/test-harness/modules/cbpayments-fixture/models/FixtureClient.cfc @@ -0,0 +1,12 @@ +component { + + function init(){ + variables.identifier = createUUID(); + return this; + } + + string function getIdentifier(){ + return variables.identifier; + } + +} diff --git a/test-harness/modules/cbpayments-fixture/models/FixtureProvider.cfc b/test-harness/modules/cbpayments-fixture/models/FixtureProvider.cfc new file mode 100644 index 0000000..6b639c8 --- /dev/null +++ b/test-harness/modules/cbpayments-fixture/models/FixtureProvider.cfc @@ -0,0 +1,10 @@ +component extends="cbpayments.models.providers.MockProvider" { + + function init(){ + super.init(); + variables.providerType = "FixturePay"; + variables.client = createObject( "component", "root.modules.cbpayments-fixture.models.FixtureClient" ).init(); + return this; + } + +} diff --git a/test-harness/monei-http-stub.cfm b/test-harness/monei-http-stub.cfm new file mode 100644 index 0000000..f88ef97 --- /dev/null +++ b/test-harness/monei-http-stub.cfm @@ -0,0 +1,25 @@ + + +requestData = getHTTPRequestData(); +payload = {}; +if ( requestData.keyExists( "content" ) && isJSON( requestData.content ) ) { + payload = deserializeJSON( requestData.content ); +} + +response = { + "id" : payload.keyExists( "id" ) ? payload.id : "pay_transport", + "status" : "SUCCEEDED", + "amount" : payload.keyExists( "amount" ) ? payload.amount : 1250, + "currency" : payload.keyExists( "currency" ) ? payload.currency : "EUR", + "receivedMethod" : cgi.request_method, + "receivedPath" : url.keyExists( "route" ) ? url.route : "", + "authorized" : requestData.headers.keyExists( "Authorization" ) + && requestData.headers.Authorization == "pk_test_cbpayments_transport", + "accountScoped" : requestData.headers.keyExists( "MONEI-Account-ID" ) + && requestData.headers[ "MONEI-Account-ID" ] == "acct_transport" +}; + +cfheader( name = "x-request-id", value = "req_monei_transport" ); +cfcontent( type = "application/json; charset=utf-8", reset = true ); +writeOutput( serializeJSON( response ) ); + diff --git a/test-harness/tests/Application.cfc b/test-harness/tests/Application.cfc index 26f3fb6..839007a 100644 --- a/test-harness/tests/Application.cfc +++ b/test-harness/tests/Application.cfc @@ -35,23 +35,7 @@ component { ); this.mappings[ "/moduleroot" ] = moduleRootPath; this.mappings[ "/#request.MODULE_NAME#" ] = moduleRootPath & "#request.MODULE_PATH#"; - - // ORM Definitions - /** - this.datasource = "coolblog"; - this.ormEnabled = "true"; - this.ormSettings = { - cfclocation = [ "/root/models" ], - logSQL = true, - dbcreate = "update", - secondarycacheenabled = false, - cacheProvider = "ehcache", - flushAtRequestEnd = false, - eventhandling = true, - eventHandler = "cborm.models.EventHandler", - skipcfcWithError = false - }; - **/ + this.mappings[ "/cbpaymentsproviders" ] = moduleRootPath & request.MODULE_PATH & "/providers/"; function onRequestStart( required targetPage ){ // Set a high timeout for long running tests @@ -61,7 +45,7 @@ component { // If hitting the runner or specs, prep our virtual app if ( getBaseTemplatePath().replace( expandPath( "/tests" ), "" ).reFindNoCase( "(runner|specs)" ) ) { - request.coldBoxVirtualApp.startup(); + request.coldBoxVirtualApp.startup( true ); } // ORM Reload for fresh results @@ -77,7 +61,9 @@ component { } public void function onRequestEnd( required targetPage ){ - request.coldBoxVirtualApp.shutdown(); + if ( request.keyExists( "coldBoxVirtualApp" ) ) { + request.coldBoxVirtualApp.shutdown(); + } } private boolean function shouldEnableFullNullSupport(){ diff --git a/test-harness/tests/resources/BaseProcessorTest.cfc b/test-harness/tests/resources/BaseProcessorTest.cfc deleted file mode 100644 index 2cb8f5c..0000000 --- a/test-harness/tests/resources/BaseProcessorTest.cfc +++ /dev/null @@ -1,169 +0,0 @@ -component extends="coldbox.system.testing.BaseTestCase" { - - property name="processor"; - - /*********************************** LIFE CYCLE Methods ***********************************/ - - function beforeAll(){ - super.beforeAll(); - variables.model = getInstance( variables.processor ); - } - - function afterAll(){ - super.afterAll(); - } - - /*********************************** BDD SUITES ***********************************/ - - function run(){ - describe( "Stripe Processor", function(){ - it( "can be created", function(){ - expect( variables.model ).toBeComponent(); - } ); - - - it( "can give me its name and version", function(){ - var name = variables.model.getName(); - var version = variables.model.getVersion(); - - expect( name.len() ).toBeTrue(); - expect( version.len() ).toBeTrue(); - } ); - - it( "can get the processor", function(){ - var processor = variables.model.getProcessor(); - expect( processor ).toBeComponent(); - } ); - - it( "Will throw an error on a fake charge", function(){ - var response = variables.model.charge( - amount = 1, - source = "bogus", - description = "Unit test charge" - ); - - expect( response.getError() ).toBeTrue(); - debug( response.getContent() ); - } ); - - it( "can make a test valid charge", function(){ - var response = variables.model.charge( - amount = 100, - source = "tok_visa", - description = "Unit test charge" - ); - - expect( response.getError() ).toBeFalse(); - expect( response.getContent().paid ).toBeTrue(); - expect( response.getContent() ) - .toBeStruct() - .toHaveKey( "id" ) - .toHaveKey( "object" ) - .toHaveKey( "amount" ) - .toHaveKey( "amount_captured" ) - .toHaveKey( "amount_refunded" ) - .toHaveKey( "balance_transaction" ) - .toHaveKey( "billing_details" ) - .toHaveKey( "processor" ) - .toHaveKey( "captured" ) - .toHaveKey( "created" ) - .toHaveKey( "currency" ) - .toHaveKey( "disputed" ) - .toHaveKey( "fraud_details" ) - .toHaveKey( "livemode" ) - .toHaveKey( "metadata" ) - .toHaveKey( "outcome" ) - .toHaveKey( "paid" ) - .toHaveKey( "payment_method" ) - .toHaveKey( "payment_method_details" ) - .toHaveKey( "receipt_url" ) - .toHaveKey( "refunded" ) - .toHaveKey( "status" ); - variables.testCharge = response.getContent().id; - } ); - - it( "can make a test preAuth", function(){ - var response = variables.model.preAuthorize( - amount = 100, - source = "tok_visa", - description = "Unit test charge" - ); - - expect( response.getError() ).toBeFalse(); - expect( response.getContent().paid ).toBeTrue(); - expect( response.getContent() ) - .toBeStruct() - .toHaveKey( "id" ) - .toHaveKey( "object" ) - .toHaveKey( "amount" ) - .toHaveKey( "amount_captured" ) - .toHaveKey( "amount_refunded" ) - .toHaveKey( "billing_details" ) - .toHaveKey( "processor" ) - .toHaveKey( "captured" ) - .toHaveKey( "created" ) - .toHaveKey( "currency" ) - .toHaveKey( "disputed" ) - .toHaveKey( "fraud_details" ) - .toHaveKey( "livemode" ) - .toHaveKey( "metadata" ) - .toHaveKey( "outcome" ) - .toHaveKey( "paid" ) - .toHaveKey( "payment_method" ) - .toHaveKey( "payment_method_details" ) - .toHaveKey( "receipt_url" ) - .toHaveKey( "refunded" ) - .toHaveKey( "status" ); - expect( response.getContent().captured ).toBeFalse(); - expect( structKeyExists( response.getContent(), "balance_transaction" ) ).toBeFalse(); - variables.testPreAuth = response.getContent().id; - } ); - - it( "Can capture a pre-authorization", function(){ - if ( !variables.keyExists( "testPreAuth" ) ) { - variables.testPreAuth = variables.model - .preAuthorize( - amount = 100, - source = "tok_visa", - description = "Unit test charge" - ) - .getContent() - .id; - } - var response = variables.model.capture( variables.testPreAuth ); - - expect( response.getContent() ) - .toBeStruct() - .toHaveKey( "id" ) - .toHaveKey( "object" ) - .toHaveKey( "amount" ) - .toHaveKey( "amount_captured" ) - .toHaveKey( "amount_refunded" ) - .toHaveKey( "balance_transaction" ) - .toHaveKey( "billing_details" ) - .toHaveKey( "processor" ) - .toHaveKey( "captured" ) - .toHaveKey( "created" ) - .toHaveKey( "currency" ) - .toHaveKey( "disputed" ) - .toHaveKey( "fraud_details" ) - .toHaveKey( "livemode" ) - .toHaveKey( "metadata" ) - .toHaveKey( "outcome" ) - .toHaveKey( "paid" ) - .toHaveKey( "payment_method" ) - .toHaveKey( "payment_method_details" ) - .toHaveKey( "receipt_url" ) - .toHaveKey( "refunded" ) - .toHaveKey( "status" ); - } ); - - it( "Will error on a fake refund", function(){ - var response = variables.model.refund( charge = 123, reason = "Unit test refund" ); - expect( response.getError() ).toBeTrue(); - } ); - } ); - } - -} - diff --git a/test-harness/tests/resources/CountingProvider.cfc b/test-harness/tests/resources/CountingProvider.cfc new file mode 100644 index 0000000..fb4f59b --- /dev/null +++ b/test-harness/tests/resources/CountingProvider.cfc @@ -0,0 +1,25 @@ +component extends="cbpayments.models.providers.MockProvider" { + + function init(){ + super.init(); + variables.providerType = "Counting"; + return this; + } + + any function startup( required string processorName, struct properties = {} ){ + if ( arguments.properties.keyExists( "counter" ) ) { + arguments.properties.counter.incrementAndGet(); + } + if ( arguments.properties.keyExists( "startupEnteredLatch" ) ) { + arguments.properties.startupEnteredLatch.countDown(); + } + if ( arguments.properties.keyExists( "startupReleaseLatch" ) ) { + arguments.properties.startupReleaseLatch.await( + 5, + createObject( "java", "java.util.concurrent.TimeUnit" ).SECONDS + ); + } + return super.startup( argumentCollection = arguments ); + } + +} diff --git a/test-harness/tests/resources/FakeMoneiClient.cfc b/test-harness/tests/resources/FakeMoneiClient.cfc new file mode 100644 index 0000000..8dfb660 --- /dev/null +++ b/test-harness/tests/resources/FakeMoneiClient.cfc @@ -0,0 +1,58 @@ +component { + + function init(){ + variables.responses = {}; + variables.calls = []; + return this; + } + + any function enqueue( required string method, required struct response ){ + if ( !variables.responses.keyExists( arguments.method ) ) { + variables.responses[ arguments.method ] = []; + } + variables.responses[ arguments.method ].append( arguments.response ); + return this; + } + + array function getCalls(){ + return duplicate( variables.calls ); + } + + struct function createPayment( required struct payload ){ + return record( "createPayment", arguments ); + } + + struct function retrievePayment( required string paymentId ){ + return record( "retrievePayment", arguments ); + } + + struct function confirmPayment( required string paymentId, required struct payload ){ + return record( "confirmPayment", arguments ); + } + + struct function cancelPayment( required string paymentId ){ + return record( "cancelPayment", arguments ); + } + + struct function capturePayment( required string paymentId, required struct payload ){ + return record( "capturePayment", arguments ); + } + + struct function refundPayment( required string paymentId, required struct payload ){ + return record( "refundPayment", arguments ); + } + + private struct function record( required string method, required struct methodArguments ){ + variables.calls.append( { + "method" : arguments.method, + "arguments" : duplicate( arguments.methodArguments ) + } ); + if ( !variables.responses.keyExists( arguments.method ) || !variables.responses[ arguments.method ].len() ) { + throw( type = "FakeMoneiClient.NoResponse", message = "No response queued for #arguments.method#." ); + } + var response = variables.responses[ arguments.method ][ 1 ]; + variables.responses[ arguments.method ].deleteAt( 1 ); + return response; + } + +} diff --git a/test-harness/tests/resources/FakeStripeClient.cfc b/test-harness/tests/resources/FakeStripeClient.cfc new file mode 100644 index 0000000..418d230 --- /dev/null +++ b/test-harness/tests/resources/FakeStripeClient.cfc @@ -0,0 +1,85 @@ +component accessors="true" { + + property name="checkout" type="struct"; + property name="paymentIntents"; + property name="refunds"; + property name="setupIntents"; + property name="customers"; + property name="webhooks"; + property name="validWebhookSecret" type="string"; + property name="identifier" type="string"; + + function init(){ + variables.identifier = createUUID(); + variables.calls = []; + variables.responses = {}; + variables.validWebhookSecret = "whsec_cbpayments_valid"; + variables.checkout = { "sessions" : new tests.resources.FakeStripeResource( "checkout.sessions", this ) }; + variables.paymentIntents = new tests.resources.FakeStripeResource( "paymentIntents", this ); + variables.refunds = new tests.resources.FakeStripeResource( "refunds", this ); + variables.setupIntents = new tests.resources.FakeStripeResource( "setupIntents", this ); + variables.customers = new tests.resources.FakeStripeResource( "customers", this ); + variables.webhooks = new tests.resources.FakeStripeWebhooks( this ); + this.checkout = variables.checkout; + this.paymentIntents = variables.paymentIntents; + this.refunds = variables.refunds; + this.setupIntents = variables.setupIntents; + this.customers = variables.customers; + this.webhooks = variables.webhooks; + return this; + } + + any function enqueue( + required string resource, + required string method, + required any response + ){ + var key = lCase( arguments.resource & "." & arguments.method ); + if ( !variables.responses.keyExists( key ) ) { + variables.responses[ key ] = []; + } + variables.responses[ key ].append( arguments.response ); + return this; + } + + any function handleCall( + required string resource, + required string method, + required struct callArguments + ){ + record( + arguments.resource, + arguments.method, + arguments.callArguments + ); + var key = lCase( arguments.resource & "." & arguments.method ); + if ( !variables.responses.keyExists( key ) || !variables.responses[ key ].len() ) { + throw( type = "FakeStripe.NoResponse", message = "No response queued for [#key#]." ); + } + var response = variables.responses[ key ].shift(); + if ( isStruct( response ) && response.keyExists( "__throw" ) ) { + throw( + type = response.__throw.keyExists( "type" ) ? response.__throw.type : "FakeStripe.Error", + message = response.__throw.keyExists( "message" ) ? response.__throw.message : "Queued Stripe exception." + ); + } + return response; + } + + void function record( + required string resource, + required string method, + required struct callArguments + ){ + variables.calls.append( { + "resource" : arguments.resource, + "method" : arguments.method, + "arguments" : arguments.callArguments + } ); + } + + array function getCalls(){ + return variables.calls; + } + +} diff --git a/test-harness/tests/resources/FakeStripeResource.cfc b/test-harness/tests/resources/FakeStripeResource.cfc new file mode 100644 index 0000000..e90f47f --- /dev/null +++ b/test-harness/tests/resources/FakeStripeResource.cfc @@ -0,0 +1,17 @@ +component { + + function init( required string resourceName, required any client ){ + variables.resourceName = arguments.resourceName; + variables.client = arguments.client; + return this; + } + + function onMissingMethod( required string missingMethodName, required struct missingMethodArguments ){ + return variables.client.handleCall( + variables.resourceName, + arguments.missingMethodName, + arguments.missingMethodArguments + ); + } + +} diff --git a/test-harness/tests/resources/FakeStripeWebhooks.cfc b/test-harness/tests/resources/FakeStripeWebhooks.cfc new file mode 100644 index 0000000..868c5b5 --- /dev/null +++ b/test-harness/tests/resources/FakeStripeWebhooks.cfc @@ -0,0 +1,28 @@ +component { + + function init( required any client ){ + variables.client = arguments.client; + return this; + } + + struct function constructEvent( + required string payload, + required string header, + required string secret, + numeric tolerance = 300 + ){ + variables.client.record( + "webhooks", + "constructEvent", + duplicate( arguments ) + ); + if ( secret != variables.client.getValidWebhookSecret() ) { + throw( type = "StripeSignatureVerificationException", message = "No matching signature." ); + } + if ( !isJSON( payload ) ) { + throw( type = "JSONException", message = "Malformed JSON." ); + } + return deserializeJSON( payload ); + } + +} diff --git a/test-harness/tests/resources/IncompleteCapabilityProvider.cfc b/test-harness/tests/resources/IncompleteCapabilityProvider.cfc new file mode 100644 index 0000000..77257c5 --- /dev/null +++ b/test-harness/tests/resources/IncompleteCapabilityProvider.cfc @@ -0,0 +1,10 @@ +component extends="cbpayments.models.providers.AbstractPaymentProvider" { + + function init(){ + super.init(); + variables.providerType = "IncompleteCapability"; + variables.supportedCapabilities = [ "hostedCheckout" ]; + return this; + } + +} diff --git a/test-harness/tests/resources/InvalidProvider.cfc b/test-harness/tests/resources/InvalidProvider.cfc new file mode 100644 index 0000000..d27196f --- /dev/null +++ b/test-harness/tests/resources/InvalidProvider.cfc @@ -0,0 +1,7 @@ +component { + + function init(){ + return this; + } + +} diff --git a/test-harness/tests/resources/RecordingInterceptor.cfc b/test-harness/tests/resources/RecordingInterceptor.cfc new file mode 100644 index 0000000..90c20b8 --- /dev/null +++ b/test-harness/tests/resources/RecordingInterceptor.cfc @@ -0,0 +1,16 @@ +component { + + function init(){ + variables.events = []; + return this; + } + + void function announce( required string state, required struct data ){ + variables.events.append( { "state" : arguments.state, "data" : arguments.data } ); + } + + array function getEvents(){ + return variables.events; + } + +} diff --git a/test-harness/tests/resources/coolblog.sql b/test-harness/tests/resources/coolblog.sql deleted file mode 100644 index d1af264..0000000 --- a/test-harness/tests/resources/coolblog.sql +++ /dev/null @@ -1,473 +0,0 @@ -# ************************************************************ -# Sequel Pro SQL dump -# Version 4529 -# -# http://www.sequelpro.com/ -# https://github.com/sequelpro/sequelpro -# -# Host: Localhost (MySQL 5.6.21) -# Database: coolblog -# Generation Time: 2016-02-27 23:03:57 +0000 -# ************************************************************ - - -/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; -/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; -/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; -/*!40101 SET NAMES utf8 */; -/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; -/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; -/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; - - -# Dump of table blogEntries -# ------------------------------------------------------------ -USE `coolblog`; - -DROP TABLE IF EXISTS `blogEntries`; - -CREATE TABLE `blogEntries` ( - `blogEntriesID` int(11) NOT NULL AUTO_INCREMENT, - `blogEntriesLink` longtext NOT NULL, - `blogEntriesTitle` longtext NOT NULL, - `blogEntriesDescription` longtext NOT NULL, - `blogEntriesDatePosted` datetime NOT NULL, - `blogEntriesdateUpdated` datetime NOT NULL, - `blogEntriesIsActive` bit(1) NOT NULL, - `blogsID` int(11) DEFAULT NULL, - PRIMARY KEY (`blogEntriesID`), - KEY `FK2828728E45296FD` (`blogsID`), - CONSTRAINT `FK2828728E45296FD` FOREIGN KEY (`blogsID`) REFERENCES `blogs` (`blogsID`) -) ENGINE=InnoDB DEFAULT CHARSET=latin1; - -LOCK TABLES `blogEntries` WRITE; -/*!40000 ALTER TABLE `blogEntries` DISABLE KEYS */; - -INSERT INTO `blogEntries` (`blogEntriesID`, `blogEntriesLink`, `blogEntriesTitle`, `blogEntriesDescription`, `blogEntriesDatePosted`, `blogEntriesdateUpdated`, `blogEntriesIsActive`, `blogsID`) -VALUES - (1,'http://blog.coldbox.org/post.cfm/coldbox-wiki-docs-skins-shared','ColdBox Wiki Docs Skins Shared','Since we love collaboration and giving back to the community, we have just opened our Wiki Docs Skins github repository so you can check out how we build out our wiki docs skins for CodexWiki and hopefully you guys can send us your skins and we can use them on the wiki docs site :)','2011-04-06 11:13:52','2011-04-06 11:13:52',b'1',1), - (2,'http://blog.coldbox.org/post.cfm/new-coldbox-wiki-docs','New ColdBox Wiki Docs','We have been wanting to update all our sites for a long time and the docs where first. Yesterday we updated our codex skins for the coldbox wiki docs and also started our documentation revisions and updates. You will see that it is now much much better organized and our new quick index feature enables you to get to content even faster. Hopefully in the coming weeks we will have all our documentation updated and running. Thank you for your support and feedback.','2011-04-06 10:57:17','2011-04-06 10:57:17',b'1',1), - (3,'http://blog.coldbox.org/post.cfm/modules-contest-ends-this-friday','Modules Contest Ends This Friday','Just a quick reminder that our Modules Contest ends this Friday! So get to it, build some apps! Modules Contest URL: http://blog.coldbox.org/post.cfm/coldbox-modules-contest-extended','2011-04-04 11:22:19','2011-04-04 11:22:19',b'1',1), - (4,'http://blog.coldbox.org/post.cfm/coldbox-connection-recording-coldbox-3-0-0','ColdBox Connection Recording: ColdBox 3.0.0','Thanks for attending our 3rd ColdBox Connection webinar today!  This webinar focused on ColdBox 3.0.0 release and goodies.  Here is the recording for the show!','2011-03-30 15:42:16','2011-03-30 15:42:16',b'1',1), - (5,'http://blog.coldbox.org/post.cfm/coldbox-platform-3-0-0-released','ColdBox Platform 3.0.0 Released','\n \n \nI am so happy to finally announce ColdBox Platform 3.0.0 today on March 3.0, 2011. It has been over a year of research, testing, development, coding, long long nights, 1 beautiful baby girl, lots of headaches, lots of smiles, inspiration, blessings, new contributors, new team members, new company, new hopes, and ambitions. Overall, what an incredible year for ColdFusion and ColdBox development. I can finally say that this release has been the most ambitious release and project I have tackled in my entire professional life. I am so happy of the results and its incredible community response and involvement. So thank you so much Team ColdBox and all the community for the support and long hours of testing, ideas and development.\nColdBox 3 has been on a journey of 6 defined milestones and 2 release candidates in a spawn of over a year of development. Our vision was revamping the engine into discrete and isolated parts:\n\nCore\nLogBox : Enterprise Logging Library\nWireBox : Enterprise Dependency Injection and AOP framework\nCacheBox : Enterprise Caching Engine & Cache Aggregator\nMockBox : Mocking/Stubbing Framework\n\nAll of these parts are now standalone and can be used with any ColdFusion application or ColdFusion framework. We believe we build great tools and would like everybody to have access to them even though they might not even use ColdBox MVC. Apart from the incredible amount of enhancements, we also ventured into several incredible new features:\n\nWhat\'s New\nColdBox Modules : Bringing Modular Architecture to ANY ColdBox application\nProgrammatic configuration, no more XML\nIncredible caching enhancements and integrations\nExtensible and enterprise dependency injection\nAspect oriented programming\nIntegration testing, mocking, stubbing and incredible amount of tools for testing and verification\nCustomizable Flash RAM and future web flows\nColdFusion ORM and Hibernate Services\nRESTful web services enhancement and easy creations\nTons more\n\n \nThe What\'s New page can say it all! An incredible more than 700 issue tickets closed and ColdBox 3.1 is already in full planning phases. So apart from all this work culminating, we can also say we have transitioned into a complete professional open source software offering an incredible amount of professional services and backup to any enterprise or company running ColdBox or any of our supporting products (Relax, CodexWiki, ForumMan, DataBoss, Messaging, ...):\n\nSupport & Mentoring Plans\nArchitecture & Design\nOver 4 professional training courses\nServer Setup, Tuning and Optimizations\nCustom Consulting and','2011-03-29 23:30:18','2011-03-29 23:30:18',b'1',1), - (6,'http://blog.coldbox.org/post.cfm/cachebox-1-2-released','CacheBox 1.2 Released','\n \n In the spirit of more releases, here is: CacheBox 1.2.0.  CacheBox is an enterprise caching engine, aggregator and API for ColdFusion applications. It is part of the ColdBox 3.0.0 Platform but it can also function on its own as a standalone framework and use it in any ColdFusion application and in any ColdFusion framework. \nThe milestone page for this release can be found in our Assembla Code Tracker. Here is a synopsis of the tickets closed:\n \n\n \n\n1179 new cachebox store: BlackholeStore used for optimization and testing\n1180 cf store does not use createTimeSpan to create minute timespans for puts\n1181 railo store does not use createTimeSpan to create minute timespans for puts\n1182 updates to make it coldbox 3.0 compatible\n1192 store locking mechanisms updated to improve locking and concurrency\n\nSo have fun playing with our new CacheBox release:\n\nDownload\nCheatsheet\nSource Code\nDocumentation\n\n ','2011-03-29 23:26:09','2011-03-29 23:26:09',b'1',1), - (7,'http://blog.coldbox.org/post.cfm/wirebox-1-1-1-released','WireBox 1.1.1 Released!','I am happy to announce WireBox 1.1.1 to the ColdFusion community. This release sports 3 critical fixes that will make your WireBox injectors run smoother and happier, especially for those doing java integration, this will help you some more.\n\n\nDownload\nCheatsheet\nSource Code\nDocumentation\nOur primer: Getting Jiggy Wit It!\n\n Issues Fixed\n\n1184 changed way providers accessed scoped injectors via scope registration structure instead of injector references to avoid memory leaks\n 1188 updated the java builder to ignore empty init arguments.\n 1189 updated the java builder to do noInit() as it was ignoring it\n','2011-03-29 23:20:32','2011-03-29 23:20:32',b'1',1), - (8,'http://blog.coldbox.org/post.cfm/module-lifecycles-explained','Module Lifecycles Explained','In this short entry I just wanted to lay out a few new diagrams that explain the lifecycle of ColdBox modules.  As always, all our documentation reflects these changes as well.  This might help some of you developers getting ready to win that ColdBox Modules contest and get some cash and beer!\n\nModule Service\nThe beauty of ColdBox Modules is that you have an internal module service that you can tap to in order to dynamically interact with the ColdBox Modules. This service is available by talking to the main ColdBox controller and calling its getModuleService() method: \n// get module service from handlers, plugins, layouts, interceptors or views.\nms = controller.getModuleService();\n\n// You can also inject it via our autowire DSL\nproperty name=\"moduleService\" inject=\"coldbox:moduleService\";\n\n \nModule Lifecycle\n\n \n\nHowever, before we start reviewing the module service methods let\'s review how modules get loaded in a ColdBox application. Below is a simple bullet point of what happens in your application when it starts up and you can also look at the diagram above: \n\nColdBox main application and configuration loads \nColdBox Cache, Logging and WireBox are created \nModule Service calls on registerAllModules() to read all the modules in the modules locations (with include/excludes) and start registering their configurations one by one. If the module had parent settings, interception points, datasoures or webservices, these are registered here. \nAll main application interceptors are loaded and configured \nColdBox is marked as initialized \nModule service calls on activateAllModules() so it begins activating only the registered modules one by one. This registers the module\'s SES URL Mappings, model objects, etc \nafterConfigurationLoad interceptors are fired \nColdBox aspects such as i18n, javaloader, ColdSpring/LightWire factories are loaded \nafterAspectsLoad interceptors are fired \n\nThe most common methods that you can use to control the modules in your application are the following: \n\nreloadAll() : Reload all modules in the application. This clears out all module settings, re-registers from disk, re-configures them and activates them \nreload(module) : Target a module reload by name \nunloadAll() : Unload all modules \nunload(module) : Target a module unload by name \nregisterAllModules() : Registers all module configurations \nregisterModule(module) : Target a module configuration registration \nactivateAllModules() : Activate all registered modules \nactivateModule(module) : Target activate a module that has been registered already \ngetLoadedModules() : Get an array of loaded module names \nrebuildModuleRegistry() : Rescan all the module lcoations for newly installed modules and rebuild the registry so these modules can be registered and activated. \nregisterAndActivateModule(module) : Registe','2011-03-29 11:42:49','2011-03-29 11:42:49',b'1',1), - (9,'http://blog.coldbox.org/post.cfm/coldbox-connection-show-wednesday','ColdBox Connection Show Wednesday','Just a reminder that this March 3.0.0, 2011 we will be holding a special ColdBox Open Forum Connection at 9 AM PST.  You can find more information below:Location:  http://experts.adobeconnect.com/coldbox-connection/ColdBox Connection Shows: http://www.coldbox.org/media/connectionWatch out!! Something is coming!!','2011-03-28 20:59:29','2011-03-28 20:59:29',b'1',1), - (10,'http://blog.coldbox.org/post.cfm/coldbox-modules-contest-extended','ColdBox Modules Contest Extended','We are extending our Modules Contest to allow for more time for entries to trickle in and of course to leverage ColdBox 3 coming this week.\nDeadline: Module entries must be submitted by March 29th EXTENDED: April 8th, 2011 no later than 12PM PST to contests@ortussolutions.com\nWinners Announced on March 30th EXTENDED: April 14th, 2011 The ColdBox Connection show at 9AM PST\nColdBox 3.0 Modules ContestCreate a ColdBox 3.0.0 module that is a fully functional application that can be portable for any ColdBox 3.0 application. Here are some guidelines the ColdBox team will be evaluating the module on\n\nDownload ColdBox\n\nThe code must reside on either github or a public repository so it is publicly accessible\n\nThe user must create a forgebox entry and submit the module code to it: http://coldbox.org/forgebox\n\nThe more internal libraries it uses the more points it gets: LogBox, MockBox, WireBox, CacheBox\n\nThe module should do something productive, no say hello modules accepted\n\nBest practices on MVC separation of concerns\n\nPortability\n\nDocumentation (You had that one coming!!) as it might need DB setup or DSN setup\n\nBe creative!\n\nMake sure it works!\n\n\n1st Prize\n\nAn Adobe ColdFusion 9 Standard License\n\n$100 Amazon Gift Card\n\nSix pack of \"BrewFather\" beer\n\n\n2nd Prize\n\nA ColdBox Book\n\nA ColdBox T-Shirt\n\n$25 Amazon Gift Card\n\nSix pack of \"BrewFather\" beer\n','2011-03-27 20:29:07','2011-03-27 20:29:07',b'1',1), - (11,'http://blog.coldbox.org/post.cfm/coldbox-3-release-training-special-discounts','ColdBox 3 Release Training Special Discounts','\n We are currently holding a special promotion that starts today March 27, 2011 until April 3rd, 2011\n at 3:00 PM PST. Take advantage of this insane $300 off any training of your choice in honor \n of our ColdBox 3.0.0 release this week.  Just use our discount code \n viva3 in our training registration pages or follow our links below and get this discount. \n Hurry as the code expires on April 3rd, 2011 at 3PM PST.\n \n \nCalifornia Ontario/Los Angeles Training - April 27 to May 1, 2011\n\nDiscount Link: http://coldbox.eventbrite.com/?discount=viva3 \nCBOX-101 ColdBox Core on April 27 - April 29, 2011\nCBOX-203 ColdBox Modules on April 30 - May 1, 2011\n\nPre-CFObjective Minneapolis Training - May 10-11, 2011\n\nDiscount Link: http://coldbox-cfobjective.eventbrite.com/?discount=viva3 \nCBOX-100 ColdBox Core on May 10-11, 2011\nCBOX-202 WireBox Dependency Injection on May 10-11, 2011\n\nHouston, Texas Training - April 27 to May 1, 2011\n\nDiscount Link: http://coldbox-texas.eventbrite.com/?discount=viva3 \nCBOX-101 ColdBox Core on July 6-8, 2011\nCBOX-203 ColdBox Modules on July 7-8, 2011\n','2011-03-27 20:18:44','2011-03-27 20:18:44',b'1',1), - (12,'http://blog.coldbox.org/post.cfm/coldbox-connection-recordings-page','ColdBox Connection Recordings Page','We just created our new recordings page for the ColdBox Connection today, so you can get in one location all of the recordings.  Hopefully in the near future we will expand it with tags and search.','2011-03-25 11:36:08','2011-03-25 11:36:08',b'1',1), - (13,'http://blog.coldbox.org/post.cfm/coldbox-connection-recording-coldbox-modules','ColdBox Connection Recording: ColdBox Modules','Thanks for attending our 2nd ColdBox Connection webinar today!  This webinar focused on ColdBox modules, modularity and architecture.  Thanks go to Curt Gratz for presenting such excellent topic.  Here is the recording for the show and also please note that we will have another show March 3.0!','2011-03-24 11:41:53','2011-03-24 11:41:53',b'1',1), - (14,'http://blog.coldbox.org/post.cfm/coldbox-connection-thursday-modules','ColdBox Connection Thursday: Modules','Just a reminder that our ColdBox Connection Show continues this Thursday at 9 AM PST! Curt Gratz will be presenting on ColdBox Modules and of course we will all be there for questions and help. See you there!Location: http://experts.adobeconnect.com/coldbox-connection/Our full calendar of events can be found here: http://coldbox.org/about/eventscalendar','2011-03-22 08:48:10','2011-03-22 08:48:10',b'1',1), - (15,'http://blog.coldbox.org/post.cfm/coldbox-relax-v1-4-released','ColdBox Relax v1.4 released!','Here is a cool new update for ColdBox Relax - RESTful Tools For Lazy Experts!  This update fixes a few issues reported and also enhances the Relaxer console and updates its ability to support definitions for multiple tiers and much more. So download it now!\nHere are the closed issues for this release:\n\n #14 api_logs direct usage reference removed fixes\n #15 basic http authentication added to relaxer console so you can easily hit resources that require basic auth\n #10 entry points can now be a structure of name value pairs for multiple tiers\n #16 new browser results tab window to show how the results are rendered by a browser\n #17 addition http proxy as advanced settings to relaxer console so you can proxy your relaxed requests\n #11 Route Auto Generation - Method security fixes so implicit structures are generated alongside json structures\n\nHere is also a nice screencast showcasing version 1.4 capabilities:\n \n\n\n\n \nWhat is Relax? ColdBox Relax is a set of RESTful tools for lazy experts. We pride ourselves in helping developers work smarter and of course document more in less time by providing them the necessary tools to automagically document and test. ColdBox Relax is a way to describe RESTful web services, test RESTful web services, monitor RESTful web services and document RESTful web services. The following introductory video will explain it better than words!\n \n\n\n\nSo what are you waiting for? Get Relax Now!\n\n Source Code\n Download\n Documentation\n\n \n','2011-03-21 16:51:09','2011-03-21 16:51:09',b'1',1); - -/*!40000 ALTER TABLE `blogEntries` ENABLE KEYS */; -UNLOCK TABLES; - - -# Dump of table blogs -# ------------------------------------------------------------ - -DROP TABLE IF EXISTS `blogs`; - -CREATE TABLE `blogs` ( - `blogsID` int(11) NOT NULL AUTO_INCREMENT, - `blogsURL` longtext NOT NULL, - `blogsWebsiteurl` longtext NOT NULL, - `blogslanguage` varchar(10) NOT NULL, - `blogsTitle` longtext NOT NULL, - `blogsDescription` longtext NOT NULL, - `blogsdateBuilt` datetime NOT NULL, - `blogsdateSumitted` datetime NOT NULL, - `blogsIsActive` bit(1) NOT NULL, - `blogsAuthorname` varchar(200) DEFAULT NULL, - `blogsauthorEmail` varchar(200) DEFAULT NULL, - `blogsauthorURL` longtext, - PRIMARY KEY (`blogsID`) -) ENGINE=InnoDB DEFAULT CHARSET=latin1; - -LOCK TABLES `blogs` WRITE; -/*!40000 ALTER TABLE `blogs` DISABLE KEYS */; - -INSERT INTO `blogs` (`blogsID`, `blogsURL`, `blogsWebsiteurl`, `blogslanguage`, `blogsTitle`, `blogsDescription`, `blogsdateBuilt`, `blogsdateSumitted`, `blogsIsActive`, `blogsAuthorname`, `blogsauthorEmail`, `blogsauthorURL`) -VALUES - (1,'http://blog.coldbox.org/feeds/rss.cfm','http://blog.coldbox.org/','','ColdBox Platform','The official ColdBox Blog','2011-04-08 15:19:13','2011-04-08 15:19:13',b'1',NULL,NULL,NULL); - -/*!40000 ALTER TABLE `blogs` ENABLE KEYS */; -UNLOCK TABLES; - - -# Dump of table cacheBox -# ------------------------------------------------------------ - -DROP TABLE IF EXISTS `cacheBox`; - -CREATE TABLE `cacheBox` ( - `id` varchar(100) NOT NULL, - `objectKey` varchar(255) NOT NULL, - `objectValue` longtext NOT NULL, - `hits` int(11) NOT NULL DEFAULT '1', - `timeout` int(11) NOT NULL, - `lastAccessTimeout` int(11) NOT NULL, - `created` datetime NOT NULL, - `lastAccessed` datetime NOT NULL, - `isExpired` tinyint(4) NOT NULL DEFAULT '1', - `isSimple` tinyint(4) NOT NULL DEFAULT '0', - PRIMARY KEY (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; - -LOCK TABLES `cacheBox` WRITE; -/*!40000 ALTER TABLE `cacheBox` DISABLE KEYS */; - -INSERT INTO `cacheBox` (`id`, `objectKey`, `objectValue`, `hits`, `timeout`, `lastAccessTimeout`, `created`, `lastAccessed`, `isExpired`, `isSimple`) -VALUES - ('DF658A103F07DC012AB905014C32D4C7','myKey','hello',1,0,0,'2016-02-25 16:34:00','2016-02-25 16:34:00',1,1); - -/*!40000 ALTER TABLE `cacheBox` ENABLE KEYS */; -UNLOCK TABLES; - - -# Dump of table categories -# ------------------------------------------------------------ - -DROP TABLE IF EXISTS `categories`; - -CREATE TABLE `categories` ( - `category_id` varchar(50) NOT NULL, - `category` varchar(100) NOT NULL, - `description` varchar(100) NOT NULL, - `modifydate` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - `testValue` varchar(100) DEFAULT NULL, - PRIMARY KEY (`category_id`) -) ENGINE=InnoDB DEFAULT CHARSET=latin1; - -LOCK TABLES `categories` WRITE; -/*!40000 ALTER TABLE `categories` DISABLE KEYS */; - -INSERT INTO `categories` (`category_id`, `category`, `description`, `modifydate`, `testValue`) -VALUES - ('3A2C516C-41CE-41D3-A9224EA690ED1128','Presentations','

Presso

','2011-02-18 00:00:00',NULL), - ('40288110380cda3301382644c7f90008','LM','LM
','2012-06-10 23:00:00',NULL), - ('402881882814615e012826481061000c','Marc','This is marcs category
','2010-04-21 22:00:00',NULL), - ('402881882814615e01282bb047fd001e','Cool Wow','A cool wow category
','2010-04-22 22:00:00',NULL), - ('402881882b89b49b012b9201bda80002','PascalNews','PascalNews','2010-10-09 00:00:00',NULL), - ('402881a144f57bfd0144fa47bf040007','ads','asdf','2014-01-25 00:00:00',NULL), - ('5898F818-A9B6-4F5D-96FE70A31EBB78AC','Release','

Releases

','2009-04-18 11:48:53',NULL), - ('88B689EA-B1C0-8EEF-143A84813ACADA35','general','A general category','2010-03-31 12:53:21',NULL), - ('88B689EA-B1C0-8EEF-143A84813BCADA35','general','A second test general category','2010-03-31 12:53:21',NULL), - ('88B6C087-F37E-7432-A13A84D45A0F703B','News','A news cateogyr','2009-04-18 11:48:53',NULL), - ('99fc94fd3b98c834013b98c9b2140002','Fancy','Fancy Editor
','2012-12-14 00:00:00',NULL), - ('99fc94fd3b9a459d013b9db89c060002','Markus','Hello Markus
','2012-12-14 15:00:00',NULL), - ('A13C0DB0-0CBC-4D85-A5261F2E3FCBEF91','Training','unittest','2014-05-07 19:05:21',NULL), - ('ff80808128c9fa8b0128cc3af5d90007','Geeky Stuff','Geeky Stuff','2010-05-25 16:00:00',NULL), - ('ff80808128c9fa8b0128cc3b20bf0008','ColdBox','ColdBox','2010-05-23 16:00:00',NULL), - ('ff80808128c9fa8b0128cc3b7cdd000a','ColdFusion','ColdFusion','2010-05-23 16:00:00',NULL); - -/*!40000 ALTER TABLE `categories` ENABLE KEYS */; -UNLOCK TABLES; - - -# Dump of table comments -# ------------------------------------------------------------ - -DROP TABLE IF EXISTS `comments`; - -CREATE TABLE `comments` ( - `comment_id` varchar(50) NOT NULL, - `FKentry_id` varchar(50) NOT NULL, - `comment` text NOT NULL, - `time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (`comment_id`), - KEY `FK_comments_1` (`FKentry_id`), - KEY `FKentry_id` (`FKentry_id`), - CONSTRAINT `comments_ibfk_1` FOREIGN KEY (`FKentry_id`) REFERENCES `entries` (`entry_id`) -) ENGINE=InnoDB DEFAULT CHARSET=latin1; - -LOCK TABLES `comments` WRITE; -/*!40000 ALTER TABLE `comments` DISABLE KEYS */; - -INSERT INTO `comments` (`comment_id`, `FKentry_id`, `comment`, `time`) -VALUES - ('40288110380cda330138265bf9c4000a','8a64b3712e3a0a5e012e3a11a2cf0004','tt','2012-06-12 23:00:00'), - ('40288110380cda3301382c7fe50d0012','88B82629-B264-B33E-D1A144F97641614E','Test','2012-06-06 23:00:00'), - ('402881882814615e01282b13bbc20013','88B82629-B264-B33E-D1A144F97641614E','This entire blog post really offended me, I hate you','2010-04-22 22:00:00'), - ('402881882814615e01282b13fb290014','88B82629-B264-B33E-D1A144F97641614E','Why are you so hurtful man!','2010-04-22 22:00:00'), - ('402881882814615e01282b142cc60015','88B82629-B264-B33E-D1A144F97641614E','La realidad, que barbaro!','2010-04-22 22:00:00'), - ('88B8C6C7-DFB7-0F34-C2B0EFA4E5D7DA4C','88B82629-B264-B33E-D1A144F97641614E','this blog sucks.','2010-09-02 11:39:04'), - ('8a64b3712e3a0a5e012e3a10321d0002','402881882814615e01282b14964d0016','Vlad is awesome!','2011-02-18 00:00:00'), - ('8a64b3712e3a0a5e012e3a12b1d10005','8a64b3712e3a0a5e012e3a11a2cf0004','Vlad is awesome!','2011-02-18 00:00:00'); - -/*!40000 ALTER TABLE `comments` ENABLE KEYS */; -UNLOCK TABLES; - - -# Dump of table contact -# ------------------------------------------------------------ - -DROP TABLE IF EXISTS `contact`; - -CREATE TABLE `contact` ( - `id` int(11) NOT NULL AUTO_INCREMENT, - `firstName` varchar(255) DEFAULT NULL, - `lastName` varchar(255) DEFAULT NULL, - `email` varchar(255) DEFAULT NULL, - PRIMARY KEY (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=latin1; - -LOCK TABLES `contact` WRITE; -/*!40000 ALTER TABLE `contact` DISABLE KEYS */; - -INSERT INTO `contact` (`id`, `firstName`, `lastName`, `email`) -VALUES - (1,'Luis','Majano','lmajano@ortussolutions.com'), - (2,'Jorge','Reyes','lmajano@gmail.com'), - (3,'','',''); - -/*!40000 ALTER TABLE `contact` ENABLE KEYS */; -UNLOCK TABLES; - - -# Dump of table entries -# ------------------------------------------------------------ - -DROP TABLE IF EXISTS `entries`; - -CREATE TABLE `entries` ( - `entry_id` varchar(50) NOT NULL, - `entryBody` text NOT NULL, - `title` varchar(50) NOT NULL, - `postedDate` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - `FKuser_id` varchar(36) NOT NULL, - PRIMARY KEY (`entry_id`), - KEY `FKuser_id` (`FKuser_id`), - CONSTRAINT `entries_ibfk_1` FOREIGN KEY (`FKuser_id`) REFERENCES `users` (`user_id`) ON DELETE CASCADE ON UPDATE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=latin1 COMMENT='InnoDB free: 9216 kB; (`FKuser_id`) REFER `coolblog/users`(`'; - -LOCK TABLES `entries` WRITE; -/*!40000 ALTER TABLE `entries` DISABLE KEYS */; - -INSERT INTO `entries` (`entry_id`, `entryBody`, `title`, `postedDate`, `FKuser_id`) -VALUES - ('402881882814615e01282b14964d0016','Wow, welcome to my new blog, enjoy your stay
','My awesome post','2010-04-22 22:00:00','88B73A03-FEFA-935D-AD8036E1B7954B76'), - ('88B82629-B264-B33E-D1A144F97641614E','A first cool blog,hope it does not crash','A cool blog first posting','2009-04-08 00:00:00','88B73A03-FEFA-935D-AD8036E1B7954B76'), - ('8a64b3712e3a0a5e012e3a11a2cf0004','ContentBox is a professional open source modular content management engine that allows you to easily build websites adfsadf adfsadf asfddasfddasfddasfdd','My First Awesome Post My First Awesome Post','2013-04-16 22:00:00','88B73A03-FEFA-935D-AD8036E1B7954B76'), - ('8aee965b3cfff278013d0007d9540002','Mobile browsing popularity is skyrocketing.  According to a new Pew Internet Project report, 25% of Americans use smartphones instead of computers for the majority of their web browsing.\r\nMissing out on the mobile marketing trend is\r\n likely to translate into loss of market share and decreased sales. \r\nThat’s not to say that it’s right for every business, but you at least \r\nneed to consider your target market persona before simply dismissing \r\nmobile as a fad.\r\nOne simple step you can take in the mobile direction is to learn how to add Apple icons to your website.\r\n

What Are Apple Icons & Why Use Them?

\r\n\"GuavaBoxApple\r\n Icons are simply the graphics you’ve chosen to represent your site when\r\n a user saves your page to their home screen in iOS.\r\nIf you don’t have Apple Icons created for your site, iOS grabs a \r\ncompressed thumbnail of your website and displays it as the icon.  The \r\nresult is typically indistinguishable and unappealing.\r\nApple Icons are an awesome branding opportunity and give you the chance to g
','Test','2013-04-23 00:00:00','402884cc310b1ae901311be89381000a'), - ('99fc94fd3ba7f266013bad4a8a3b0004','This is my first blog post from Bern!
','This is my first blog post from Bern!','2012-12-17 15:00:00','99fc94fd3ba7f266013bad49e3c50003'); - -/*!40000 ALTER TABLE `entries` ENABLE KEYS */; -UNLOCK TABLES; - - -# Dump of table entry_categories -# ------------------------------------------------------------ - -DROP TABLE IF EXISTS `entry_categories`; - -CREATE TABLE `entry_categories` ( - `FKcategory_id` varchar(50) NOT NULL, - `FKentry_id` varchar(50) NOT NULL, - KEY `FKcategory_id` (`FKcategory_id`), - KEY `FKentry_id` (`FKentry_id`), - CONSTRAINT `entry_categories_ibfk_1` FOREIGN KEY (`FKcategory_id`) REFERENCES `categories` (`category_id`) ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT `entry_categories_ibfk_2` FOREIGN KEY (`FKentry_id`) REFERENCES `entries` (`entry_id`) ON DELETE CASCADE ON UPDATE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=latin1; - -LOCK TABLES `entry_categories` WRITE; -/*!40000 ALTER TABLE `entry_categories` DISABLE KEYS */; - -INSERT INTO `entry_categories` (`FKcategory_id`, `FKentry_id`) -VALUES - ('88B689EA-B1C0-8EEF-143A84813ACADA35','88B82629-B264-B33E-D1A144F97641614E'), - ('88B6C087-F37E-7432-A13A84D45A0F703B','88B82629-B264-B33E-D1A144F97641614E'), - ('3A2C516C-41CE-41D3-A9224EA690ED1128','99fc94fd3ba7f266013bad4a8a3b0004'), - ('5898F818-A9B6-4F5D-96FE70A31EBB78AC','99fc94fd3ba7f266013bad4a8a3b0004'), - ('99fc94fd3b98c834013b98c9b2140002','99fc94fd3ba7f266013bad4a8a3b0004'), - ('5898F818-A9B6-4F5D-96FE70A31EBB78AC','402881882814615e01282b14964d0016'), - ('40288110380cda3301382644c7f90008','402881882814615e01282b14964d0016'), - ('3A2C516C-41CE-41D3-A9224EA690ED1128','402881882814615e01282b14964d0016'), - ('402881882b89b49b012b9201bda80002','402881882814615e01282b14964d0016'), - ('99fc94fd3b98c834013b98c9b2140002','402881882814615e01282b14964d0016'), - ('5898F818-A9B6-4F5D-96FE70A31EBB78AC','8a64b3712e3a0a5e012e3a11a2cf0004'), - ('A13C0DB0-0CBC-4D85-A5261F2E3FCBEF91','8a64b3712e3a0a5e012e3a11a2cf0004'), - ('3A2C516C-41CE-41D3-A9224EA690ED1128','8a64b3712e3a0a5e012e3a11a2cf0004'); - -/*!40000 ALTER TABLE `entry_categories` ENABLE KEYS */; -UNLOCK TABLES; - - -# Dump of table logs -# ------------------------------------------------------------ - -DROP TABLE IF EXISTS `logs`; - -CREATE TABLE `logs` ( - `id` varchar(36) NOT NULL, - `severity` varchar(10) NOT NULL, - `category` varchar(100) NOT NULL, - `logdate` datetime NOT NULL, - `appendername` varchar(100) NOT NULL, - `message` text, - `extrainfo` text, - PRIMARY KEY (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=latin1; - - - -# Dump of table relax_logs -# ------------------------------------------------------------ - -DROP TABLE IF EXISTS `relax_logs`; - -CREATE TABLE `relax_logs` ( - `id` varchar(36) NOT NULL, - `severity` varchar(10) NOT NULL, - `category` varchar(100) NOT NULL, - `logdate` datetime NOT NULL, - `appendername` varchar(100) NOT NULL, - `message` longtext, - `extrainfo` longtext, - PRIMARY KEY (`id`) -) ENGINE=InnoDB DEFAULT CHARSET=latin1; - - - -# Dump of table roles -# ------------------------------------------------------------ - -DROP TABLE IF EXISTS `roles`; - -CREATE TABLE `roles` ( - `roleID` int(11) NOT NULL AUTO_INCREMENT, - `role` varchar(100) DEFAULT NULL, - PRIMARY KEY (`roleID`) -) ENGINE=InnoDB DEFAULT CHARSET=latin1; - -LOCK TABLES `roles` WRITE; -/*!40000 ALTER TABLE `roles` DISABLE KEYS */; - -INSERT INTO `roles` (`roleID`, `role`) -VALUES - (1,'Administrator'), - (2,'Moderator'), - (3,'Anonymous'), - (4,'Super User'), - (5,'Editor'); - -/*!40000 ALTER TABLE `roles` ENABLE KEYS */; -UNLOCK TABLES; - - -# Dump of table todo -# ------------------------------------------------------------ - -DROP TABLE IF EXISTS `todo`; - -CREATE TABLE `todo` ( - `blogsID` int(11) NOT NULL AUTO_INCREMENT, - `name` varchar(100) DEFAULT NULL, - PRIMARY KEY (`blogsID`) -) ENGINE=MyISAM DEFAULT CHARSET=utf8; - -LOCK TABLES `todo` WRITE; -/*!40000 ALTER TABLE `todo` DISABLE KEYS */; - -INSERT INTO `todo` (`blogsID`, `name`) -VALUES - (1,'AL-{ts \'2011-04-07 11:15:55\'}'), - (2,'AL-{ts \'2011-04-07 11:16:22\'}'), - (3,'AL-{ts \'2011-04-07 11:17:06\'}'), - (4,'AL-{ts \'2011-04-07 11:21:52\'}'), - (5,'AL-{ts \'2011-04-07 11:23:06\'}'), - (6,'AL-{ts \'2011-04-07 11:23:08\'}'), - (7,'AL-{ts \'2011-04-18 17:23:59\'}'), - (8,'AL-{ts \'2011-04-18 17:37:15\'}'), - (9,'AL-{ts \'2011-04-18 17:37:20\'}'), - (10,'AL-{ts \'2011-04-18 17:38:06\'}'), - (11,'AL-{ts \'2011-04-18 17:38:08\'}'), - (12,'AL-{ts \'2011-04-18 17:38:09\'}'), - (13,'AL-{ts \'2011-04-18 17:38:10\'}'), - (14,'AL-{ts \'2011-04-18 17:38:11\'}'), - (15,'AL-{ts \'2011-04-18 17:38:12\'}'), - (16,'AL-{ts \'2011-04-18 17:38:14\'}'), - (17,'AL-{ts \'2011-04-18 17:38:15\'}'), - (18,'AL-{ts \'2011-04-18 17:38:16\'}'), - (19,'AL-{ts \'2011-04-18 17:38:17\'}'), - (20,'AL-{ts \'2011-04-18 17:38:18\'}'), - (21,'AL-{ts \'2011-04-18 17:38:19\'}'), - (22,'AL-{ts \'2011-04-18 17:38:20\'}'), - (23,'AL-{ts \'2011-04-18 17:38:21\'}'), - (24,'AL-{ts \'2011-04-18 17:40:41\'}'), - (25,'AL-{ts \'2011-04-18 17:40:44\'}'), - (26,'AL-{ts \'2011-04-18 17:40:47\'}'), - (27,'AL-{ts \'2011-04-18 17:41:38\'}'), - (28,'AL-{ts \'2011-04-18 17:44:15\'}'), - (29,'AL-{ts \'2011-04-18 17:44:25\'}'), - (30,'AL-{ts \'2011-04-18 17:44:39\'}'), - (31,'AL-{ts \'2011-04-18 17:49:44\'}'), - (32,'AL-{ts \'2011-04-18 17:50:10\'}'), - (33,'AL-{ts \'2011-04-18 17:51:07\'}'), - (34,'AL-{ts \'2011-04-18 17:57:44\'}'), - (35,'AL-{ts \'2011-04-18 18:03:33\'}'), - (36,'AL-{ts \'2011-04-18 19:32:04\'}'), - (37,'AL-{ts \'2011-04-18 19:32:08\'}'), - (38,'AL-{ts \'2011-04-18 19:32:31\'}'), - (39,'AL-{ts \'2011-04-18 19:32:51\'}'), - (40,'AL-{ts \'2011-04-18 20:02:55\'}'), - (41,'AL-{ts \'2011-04-18 20:03:52\'}'), - (42,'AL-{ts \'2011-04-18 20:04:10\'}'), - (43,'AL-{ts \'2011-04-18 20:12:52\'}'), - (44,'AL-{ts \'2011-04-19 15:43:36\'}'), - (45,'AL-{ts \'2011-04-19 15:44:20\'}'), - (46,'AL-{ts \'2011-04-19 15:48:26\'}'), - (47,'AL-{ts \'2011-04-19 15:50:59\'}'), - (48,'AL-{ts \'2011-04-19 15:51:08\'}'), - (49,'AL-{ts \'2011-04-19 15:51:15\'}'), - (50,'AL-{ts \'2011-04-23 12:58:04\'}'); - -/*!40000 ALTER TABLE `todo` ENABLE KEYS */; -UNLOCK TABLES; - - -# Dump of table users -# ------------------------------------------------------------ - -DROP TABLE IF EXISTS `users`; - -CREATE TABLE `users` ( - `user_id` varchar(50) NOT NULL, - `firstName` varchar(50) NOT NULL, - `lastName` varchar(50) NOT NULL, - `userName` varchar(50) NOT NULL, - `password` varchar(50) NOT NULL, - `lastLogin` datetime DEFAULT NULL, - `FKRoleID` int(11) DEFAULT NULL, - `isActive` bit(1) DEFAULT b'1', - PRIMARY KEY (`user_id`), - KEY `FKRoleID` (`FKRoleID`), - CONSTRAINT `users_ibfk_1` FOREIGN KEY (`FKRoleID`) REFERENCES `roles` (`roleID`) ON DELETE CASCADE ON UPDATE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=latin1; - -LOCK TABLES `users` WRITE; -/*!40000 ALTER TABLE `users` DISABLE KEYS */; - -INSERT INTO `users` (`user_id`, `firstName`, `lastName`, `userName`, `password`, `lastLogin`, `FKRoleID`, `isActive`) -VALUES - ('4028818e2fb6c893012fe637c5db00a7','George','Form Injector','george','george',NULL,2,b'1'), - ('402884cc310b1ae901311be89381000a','ken','Advanced Guru','kenneth','smith','2014-03-25 00:00:00',2,b'1'), - ('4A386F4D-DCF4-6587-7B89B3BD57C97155','Joe','Fernando','joe','joe','2009-05-15 00:00:00',1,b'1'), - ('88B73A03-FEFA-935D-AD8036E1B7954B76','Luis','Majano','lui','lmajano','2009-04-08 00:00:00',1,b'1'), - ('8a64b3712e3a0a5e012e3a110fab0003','Vladymir','Ugryumov','vlad','vlad','2011-02-18 00:00:00',1,b'1'), - ('99fc94fd3b98c834013b98c928120001','Juerg','Anderegg','juerg','juerg','2012-12-14 00:00:00',NULL,b'1'), - ('99fc94fd3ba7f266013bad49e3c50003','Tanja','Zogg','tanja','tanja','2012-12-18 00:00:00',NULL,b'1'); - -/*!40000 ALTER TABLE `users` ENABLE KEYS */; -UNLOCK TABLES; - - - -/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; -/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; -/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; -/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; -/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; -/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; diff --git a/test-harness/tests/specs/ModuleSpec.cfc b/test-harness/tests/specs/ModuleSpec.cfc deleted file mode 100644 index 1bb8aac..0000000 --- a/test-harness/tests/specs/ModuleSpec.cfc +++ /dev/null @@ -1,23 +0,0 @@ -component extends="coldbox.system.testing.BaseTestCase" appMapping="root" { - - /*********************************** LIFE CYCLE Methods ***********************************/ - - function beforeAll(){ - super.beforeAll(); - setup(); - } - - function afterAll(){ - super.afterAll(); - } - - /*********************************** BDD SUITES ***********************************/ - - function run(){ - describe( "MockData CFC", function(){ - beforeEach( function( currentSpec ){ - } ); - } ); - } - -} diff --git a/test-harness/tests/specs/integration/ModuleIntegrationSpec.cfc b/test-harness/tests/specs/integration/ModuleIntegrationSpec.cfc new file mode 100644 index 0000000..65e7669 --- /dev/null +++ b/test-harness/tests/specs/integration/ModuleIntegrationSpec.cfc @@ -0,0 +1,156 @@ +component extends="coldbox.system.testing.BaseTestCase" { + + this.loadColdbox = true; + this.unLoadColdBox = false; + + function beforeAll(){ + super.beforeAll(); + setup(); + } + + function run(){ + describe( "cbpayments module integration", function(){ + it( "loads the module and public service", function(){ + expect( getController().getModuleService().isModuleActive( "cbpayments" ) ).toBeTrue(); + var service = getInstance( "PaymentService@cbpayments" ); + expect( service ).toBeComponent(); + expect( service.processorNames() ).toInclude( "mock" ).toInclude( "disabled" ); + } ); + + it( "resolves default and named processors through the DSL", function(){ + expect( getInstance( dsl = "cbpayments" ).getProcessorName() ).toBe( "mock" ); + expect( getInstance( dsl = "cbpayments:default" ).getProcessorName() ).toBe( "mock" ); + expect( getInstance( dsl = "cbpayments:disabled" ).getProviderType() ).toBe( "Null" ); + } ); + + it( "constructs requests through WireBox and executes the service facade", function(){ + var wirebox = getController().getWireBox(); + var money = wirebox.getInstance( + name = "Money@cbpayments", + initArguments = { "amountMinor" : 2500, "currency" : "USD" } + ); + var paymentRequest = wirebox.getInstance( + name = "HostedCheckoutRequest@cbpayments", + initArguments = { + "money" : money, + "idempotencyKey" : "integration-checkout-1", + "returnUrl" : "https://example.test/complete", + "cancelUrl" : "https://example.test/cancel" + } + ); + var result = getInstance( "PaymentService@cbpayments" ).createCheckout( paymentRequest ); + expect( result.getOk() ).toBeTrue(); + expect( result.getMemento().amount.amountMinor ).toBe( 2500 ); + expect( result.getNextAction().type ).toBe( "redirect" ); + } ); + + it( "verifies webhooks through the normalized service boundary", function(){ + var rawBody = serializeJSON( { + "id" : "evt_integration", + "type" : "payment_intent.succeeded", + "created" : 1788552000, + "livemode" : false, + "data" : { + "object" : { + "id" : "pi_integration", + "object" : "payment_intent", + "status" : "succeeded" + } + } + } ); + var paymentEvent = getInstance( "PaymentService@cbpayments" ).verifyWebhook( + rawBody, + "mock-test-signature", + "acct_integration", + "mock" + ); + expect( paymentEvent.getEventId() ).toBe( "evt_integration" ); + expect( paymentEvent.getObjectId() ).toBe( "pi_integration" ); + expect( paymentEvent.getProviderAccountId() ).toBe( "acct_integration" ); + } ); + + it( "switches processors without changing the consumer API", function(){ + var service = getInstance( "PaymentService@cbpayments" ); + var stripeClient = new tests.resources.FakeStripeClient().enqueue( + "checkout.sessions", + "create", + { + "status" : 200, + "requestId" : "req_provider_switch", + "content" : { + "id" : "cs_provider_switch", + "object" : "checkout.session", + "status" : "open", + "payment_status" : "unpaid", + "url" : "https://checkout.stripe.test/c/pay/cs_provider_switch", + "amount_total" : 2500, + "currency" : "usd" + } + } + ); + service.registerProcessor( + "stripe-switch", + "StripeProvider@cbpayments", + { + "client" : stripeClient, + "webhookSecrets" : [ "whsec_provider_switch" ] + } + ); + try { + var paymentRequest = new cbpayments.models.contracts.requests.HostedCheckoutRequest( + money = new cbpayments.models.contracts.Money( 2500, "USD" ), + idempotencyKey = "integration-provider-switch", + returnUrl = "https://example.test/complete", + cancelUrl = "https://example.test/cancel" + ); + var memoryResult = service.createCheckout( paymentRequest, "mock" ); + var stripeResult = service.createCheckout( paymentRequest, "stripe-switch" ); + expect( memoryResult.getOk() ).toBeTrue(); + expect( stripeResult.getOk() ).toBeTrue(); + expect( memoryResult.getMemento().amount ).toBe( stripeResult.getMemento().amount ); + expect( memoryResult.getNextAction().type ).toBe( "redirect" ); + expect( stripeResult.getNextAction().type ).toBe( "redirect" ); + } finally { + service.unregisterProcessor( "stripe-switch" ); + } + } ); + + it( "loads and cleanly unloads an independently packaged provider module", function(){ + var service = getInstance( "PaymentService@cbpayments" ); + var moduleService = getController().getModuleService(); + moduleService.registerModule( + moduleName = "cbpayments-fixture", + invocationPath = "root.modules", + force = true + ); + moduleService.activateModule( "cbpayments-fixture" ); + service.registerModuleContributions(); + expect( service.hasProcessor( "configured@cbpayments-fixture" ) ).toBeTrue(); + var fixtureProvider = service.processor( "configured@cbpayments-fixture" ); + expect( fixtureProvider.getProviderType() ).toBe( "FixturePay" ); + expect( fixtureProvider.getClient().getIdentifier() ).notToBeEmpty(); + moduleService.unload( "cbpayments-fixture" ); + expect( service.hasProcessor( "configured@cbpayments-fixture" ) ).toBeFalse(); + expect( fixtureProvider.hasStarted() ).toBeFalse(); + } ); + + it( "shuts down processors and reloads cleanly", function(){ + var moduleService = getController().getModuleService(); + var oldService = getInstance( "PaymentService@cbpayments" ); + var oldProvider = oldService.processor( "mock" ); + moduleService.unload( "cbpayments" ); + expect( oldProvider.hasStarted() ).toBeFalse(); + moduleService.registerModule( + moduleName = "cbpayments", + invocationPath = "moduleroot", + force = true + ); + moduleService.activateModule( "cbpayments" ); + var reloadedService = getInstance( "PaymentService@cbpayments" ); + expect( reloadedService.processorNames() ).toInclude( "mock" ).toInclude( "disabled" ); + expect( reloadedService.processor( "mock" ).hasStarted() ).toBeTrue(); + } ); + } ); + } + +} diff --git a/test-harness/tests/specs/live/StripeLiveSpec.cfc b/test-harness/tests/specs/live/StripeLiveSpec.cfc new file mode 100644 index 0000000..9304c1c --- /dev/null +++ b/test-harness/tests/specs/live/StripeLiveSpec.cfc @@ -0,0 +1,140 @@ +component extends="coldbox.system.testing.BaseTestCase" { + + function run(){ + describe( "Stripe test-mode live contract", function(){ + it( "creates and cleans up Checkout, Payment Intent, refund, and signed webhook resources", function(){ + var apiKey = environmentValue( "STRIPE_API_KEY" ); + if ( !len( apiKey ) ) { + if ( environmentValue( "CBPAYMENTS_LIVE_REQUIRED" ) == "true" ) { + fail( "STRIPE_API_KEY is required for the live contract." ); + } + return skip( "A Stripe test-mode API key was not supplied." ); + } + if ( left( apiKey, 8 ) != "sk_test_" ) { + fail( "The live contract accepts only a Stripe test-mode sk_test_ key." ); + } + + var suffix = lCase( replace( createUUID(), "-", "", "all" ) ); + var webhookSecret = "whsec_cbpayments_live_#suffix#"; + var provider = new cbpayments.models.providers.StripeProvider().startup( + "stripe-live", + { + "apiKey" : apiKey, + "webhookSecrets" : [ webhookSecret ], + "apiVersion" : "2026-02-25.clover", + "defaultCurrency" : "usd" + } + ); + var checkoutId = ""; + var paymentId = ""; + var paymentState = ""; + + try { + var checkoutResult = provider.createCheckout( + new cbpayments.models.contracts.requests.HostedCheckoutRequest( + money = new cbpayments.models.contracts.Money( 125, "usd" ), + idempotencyKey = "cbpayments-live-checkout-#suffix#", + returnUrl = "https://example.com/cbpayments/success", + cancelUrl = "https://example.com/cbpayments/cancel", + description = "cbpayments live contract", + metadata = { "cbpayments_run" : suffix } + ) + ); + expectSuccessful( checkoutResult, "Checkout Session create" ); + checkoutId = checkoutResult.getExternalId(); + expect( checkoutResult.getNextAction().type ).toBe( "redirect" ); + + var intentResult = provider.createPaymentIntent( + new cbpayments.models.contracts.requests.PaymentIntentRequest( + money = new cbpayments.models.contracts.Money( 125, "usd" ), + idempotencyKey = "cbpayments-live-intent-#suffix#", + paymentMethodId = "pm_card_visa", + description = "cbpayments live contract", + metadata = { "cbpayments_run" : suffix } + ) + ); + expectSuccessful( intentResult, "Payment Intent create" ); + paymentId = intentResult.getExternalId(); + paymentState = intentResult.getStatus(); + + var confirmed = provider.confirmPaymentIntent( + paymentId, + "cbpayments-live-confirm-#suffix#", + { + "paymentMethodId" : "pm_card_visa", + "returnUrl" : "https://example.com/cbpayments/return" + } + ); + expectSuccessful( confirmed, "Payment Intent confirm" ); + paymentState = confirmed.getStatus(); + expect( paymentState ).toBe( "succeeded" ); + + var refundResult = provider.createRefund( + new cbpayments.models.contracts.requests.RefundRequest( + externalId = paymentId, + idempotencyKey = "cbpayments-live-refund-#suffix#", + money = new cbpayments.models.contracts.Money( 125, "usd" ), + metadata = { "cbpayments_run" : suffix } + ) + ); + expectSuccessful( refundResult, "Refund create" ); + + var timestamp = nowUnix(); + var rawBody = serializeJSON( { + "id" : "evt_cbpayments_#suffix#", + "type" : "payment_intent.succeeded", + "created" : timestamp, + "livemode" : false, + "data" : { + "object" : { + "id" : paymentId, + "object" : "payment_intent", + "status" : "succeeded", + "amount" : 125, + "currency" : "usd" + } + } + } ); + var signature = lCase( + hmac( + timestamp & "." & rawBody, + webhookSecret, + "hmacSHA256", + "utf-8" + ) + ); + var paymentEvent = provider.verifyWebhook( rawBody, "t=#timestamp#,v1=#signature#" ); + expect( paymentEvent.getObjectId() ).toBe( paymentId ); + } finally { + if ( len( checkoutId ) ) { + try { + provider.expireCheckout( checkoutId, "cbpayments-live-expire-#suffix#" ); + } catch ( any ignoredCheckoutCleanup ) { + } + } + if ( len( paymentId ) && paymentState != "succeeded" ) { + try { + provider.cancelPaymentIntent( paymentId, "cbpayments-live-cancel-#suffix#" ); + } catch ( any ignoredPaymentCleanup ) { + } + } + provider.shutdown(); + } + } ); + } ); + } + + private void function expectSuccessful( required any result, required string operation ){ + expect( result.getOk(), "#arguments.operation# failed: #serializeJSON( result.getMemento() )#" ).toBeTrue(); + } + + private string function environmentValue( required string name ){ + var value = createObject( "java", "java.lang.System" ).getenv( arguments.name ); + return isNull( value ) ? "" : value; + } + + private numeric function nowUnix(){ + return fix( createObject( "java", "java.lang.System" ).currentTimeMillis() / 1000 ); + } + +} diff --git a/test-harness/tests/specs/unit/ContractsSpec.cfc b/test-harness/tests/specs/unit/ContractsSpec.cfc new file mode 100644 index 0000000..7886ff6 --- /dev/null +++ b/test-harness/tests/specs/unit/ContractsSpec.cfc @@ -0,0 +1,115 @@ +component extends="coldbox.system.testing.BaseTestCase" { + + function run(){ + describe( "normalized payment contracts", function(){ + it( "stores integer minor units and normalizes currency", function(){ + var money = new cbpayments.models.contracts.Money( 1234, "USD" ); + expect( money.getMemento() ).toBe( { "amountMinor" : 1234, "currency" : "usd" } ); + expect( new cbpayments.models.util.CurrencyMetadata().exponent( "JPY" ) ).toBe( 0 ); + expect( new cbpayments.models.util.CurrencyMetadata().exponent( "KWD" ) ).toBe( 3 ); + } ); + + it( "rejects floating-point amounts and unknown currencies", function(){ + expect( function(){ + new cbpayments.models.contracts.Money( 10.25, "usd" ); + } ).toThrow( "cbpayments.InvalidMoney" ); + expect( function(){ + new cbpayments.models.contracts.Money( 10, "zzz" ); + } ).toThrow( "cbpayments.InvalidCurrency" ); + } ); + + it( "requires idempotency keys for mutating requests", function(){ + expect( function(){ + new cbpayments.models.contracts.requests.PaymentIntentRequest( + money = new cbpayments.models.contracts.Money( 500, "usd" ), + idempotencyKey = "" + ); + } ).toThrow( "cbpayments.InvalidIdempotencyKey" ); + } ); + + it( "validates HTTPS redirect URLs with an explicit local-only escape hatch", function(){ + var money = new cbpayments.models.contracts.Money( 500, "usd" ); + expect( function(){ + new cbpayments.models.contracts.requests.HostedCheckoutRequest( + money = money, + idempotencyKey = "checkout-http", + returnUrl = "http://example.test/ok", + cancelUrl = "https://example.test/cancel" + ); + } ).toThrow( "cbpayments.InvalidUrl" ); + var paymentRequest = new cbpayments.models.contracts.requests.HostedCheckoutRequest( + money = money, + idempotencyKey = "checkout-local", + returnUrl = "http://localhost:8080/ok", + cancelUrl = "http://127.0.0.1/cancel", + allowLocalHttp = true + ); + expect( paymentRequest.getReturnUrl() ).toInclude( "localhost" ); + } ); + + it( "bounds metadata and rejects secret-like keys and nested values", function(){ + var validator = new cbpayments.models.util.SecurityValidator(); + expect( function(){ + validator.validateMetadata( { "api_key" : "not-allowed" } ); + } ).toThrow( "cbpayments.InvalidMetadata" ); + expect( function(){ + validator.validateMetadata( { "nested" : { "value" : true } } ); + } ).toThrow( "cbpayments.InvalidMetadata" ); + expect( function(){ + validator.validateMetadata( { "reference" : "sk_test_cbpayments_ABCDEFGHIJKLMNOPQRSTUVWXYZ" } ); + } ).toThrow( "cbpayments.InvalidPaymentData" ); + expect( function(){ + validator.validateMetadata( { "reference" : "4242 4242 4242 4242" } ); + } ).toThrow( "cbpayments.InvalidPaymentData" ); + expect( validator.validateMetadata( { "bookingId" : "B-10" } ) ).toBe( { "bookingId" : "B-10" } ); + } ); + + it( "rejects prohibited payment data in descriptions", function(){ + expect( function(){ + new cbpayments.models.contracts.requests.PaymentIntentRequest( + money = new cbpayments.models.contracts.Money( 500, "usd" ), + idempotencyKey = "description-pan", + description = "Card 4242424242424242" + ); + } ).toThrow( "cbpayments.InvalidPaymentData" ); + } ); + + it( "serializes a stable result envelope and maps unknown statuses", function(){ + var result = new cbpayments.models.contracts.results.PaymentResult( + ok = true, + operation = "paymentIntents.create", + processorName = "primary", + providerType = "Test", + status = "future_provider_status", + externalId = "external-1", + idempotencyKey = "idem-1", + amount = new cbpayments.models.contracts.Money( 1000, "usd" ), + providerDetails = { "status" : "future_provider_status" } + ); + var serialized = result.getMemento(); + expect( serialized ).toHaveKey( + "ok,operation,processorName,providerType,status,externalId,requestId,idempotencyKey,createdAt,amount,nextAction,failure,providerDetails" + ); + expect( serialized.status ).toBe( "unknown" ); + expect( serialized.providerDetails.status ).toBe( "future_provider_status" ); + } ); + + it( "keeps sensitive client actions out of default serialization", function(){ + var action = new cbpayments.models.contracts.results.ClientAction( + "stripe_client_secret", + "pi_cbpayments_secret_value" + ); + var result = new cbpayments.models.contracts.results.PaymentResult( + ok = true, + operation = "paymentIntents.create", + processorName = "primary", + providerType = "Stripe", + clientAction = action + ); + expect( serializeJSON( result.getMemento() ) ).notToInclude( "secret" ); + expect( result.getClientAction().getClientSecret() ).toBe( "pi_cbpayments_secret_value" ); + } ); + } ); + } + +} diff --git a/test-harness/tests/specs/unit/MockProviderSpec.cfc b/test-harness/tests/specs/unit/MockProviderSpec.cfc new file mode 100644 index 0000000..244842a --- /dev/null +++ b/test-harness/tests/specs/unit/MockProviderSpec.cfc @@ -0,0 +1,131 @@ +component extends="coldbox.system.testing.BaseTestCase" { + + function run(){ + describe( "Mock and Null providers", function(){ + beforeEach( function(){ + provider = new cbpayments.models.providers.MockProvider().startup( "memory" ); + money = new cbpayments.models.contracts.Money( 4200, "usd" ); + } ); + + it( "advertises every 1.0 core capability", function(){ + expect( provider.capabilities() ).toInclude( "hostedCheckout" ); + expect( provider.capabilities() ).toInclude( "paymentIntents" ); + expect( provider.capabilities() ).toInclude( "capture" ); + expect( provider.capabilities() ).toInclude( "refunds" ); + expect( provider.capabilities() ).toInclude( "setupIntents" ); + expect( provider.capabilities() ).toInclude( "customers" ); + expect( provider.capabilities() ).toInclude( "webhooks" ); + } ); + + it( "creates deterministic hosted checkout results and sanitized recordings", function(){ + var paymentRequest = new cbpayments.models.contracts.requests.HostedCheckoutRequest( + money = money, + idempotencyKey = "memory-checkout-1", + returnUrl = "https://example.test/success", + cancelUrl = "https://example.test/cancel", + metadata = { "bookingId" : "B-1" } + ); + var result = provider.createCheckout( paymentRequest ); + expect( result.getStatus() ).toBe( "pending" ); + expect( result.getExternalId() ).toStartWith( "mock_hostedCheckout_" ); + expect( result.getNextAction().redirectUrl ).toStartWith( "https://payments.invalid/" ); + expect( provider.getRecordedRequests() ).toHaveLength( 1 ); + expect( provider.getRecordedRequests()[ 1 ].payload.metadata.bookingId ).toBe( "B-1" ); + } ); + + it( "records payment-method identifiers only as redacted values", function(){ + var paymentRequest = new cbpayments.models.contracts.requests.PaymentIntentRequest( + money = money, + idempotencyKey = "memory-intent-1", + paymentMethodId = "pm_cbpayments_private" + ); + provider.createPaymentIntent( paymentRequest ); + expect( provider.getRecordedRequests()[ 1 ].payload.paymentMethodId ).toBe( "[REDACTED]" ); + } ); + + it( "supports deterministic queued failures and reset", function(){ + provider.enqueueResult( + "refunds.create", + { + "ok" : false, + "category" : "rate_limited", + "retryable" : true + } + ); + var result = provider.createRefund( + new cbpayments.models.contracts.requests.RefundRequest( + externalId = "pi_memory", + idempotencyKey = "memory-refund-1", + money = new cbpayments.models.contracts.Money( 1000, "usd" ) + ) + ); + expect( result.getOk() ).toBeFalse(); + expect( result.getFailure().getCategory() ).toBe( "rate_limited" ); + expect( result.getFailure().getRetryable() ).toBeTrue(); + provider.reset(); + expect( provider.getRecordedRequests() ).toBeEmpty(); + } ); + + it( "covers capture, setup-intent, customer, and lifecycle operations", function(){ + var capture = provider.capturePayment( + new cbpayments.models.contracts.requests.CaptureRequest( + externalId = "pi_memory", + idempotencyKey = "capture-1", + money = money + ) + ); + var setup = provider.createSetupIntent( + new cbpayments.models.contracts.requests.SetupIntentRequest( + idempotencyKey = "setup-1", + customerId = "cus_memory" + ) + ); + var customer = provider.createCustomer( + new cbpayments.models.contracts.requests.CustomerRequest( + idempotencyKey = "customer-1", + email = "buyer@example.test" + ) + ); + expect( capture.getStatus() ).toBe( "succeeded" ); + expect( setup.getOperation() ).toBe( "setupIntents.create" ); + expect( customer.getOperation() ).toBe( "customers.create" ); + expect( provider.retrievePaymentIntent( "pi_memory" ).getExternalId() ).toBe( "pi_memory" ); + expect( provider.cancelPaymentIntent( "pi_memory", "cancel-1" ).getStatus() ).toBe( "cancelled" ); + provider.shutdown(); + expect( provider.hasStarted() ).toBeFalse(); + } ); + + it( "verifies test webhooks without retaining the raw body", function(){ + var raw = serializeJSON( { + "id" : "evt_memory", + "type" : "payment.succeeded", + "created" : 123, + "livemode" : false, + "data" : { + "object" : { + "id" : "pi_memory", + "object" : "payment_intent", + "status" : "succeeded" + } + } + } ); + var event = provider.verifyWebhook( raw, "mock-test-signature" ); + expect( event.getEventId() ).toBe( "evt_memory" ); + expect( event.getEventType() ).toBe( "payment.succeeded" ); + expect( event.getProviderEventType() ).toBe( "mock.payment.succeeded" ); + expect( event.getPayloadChecksum() ).toBe( hash( raw, "SHA-256" ) ); + expect( serializeJSON( event.getMemento() ) ).notToInclude( raw ); + expect( function(){ + provider.verifyWebhook( raw, "wrong" ); + } ).toThrow( "cbpayments.InvalidWebhookSignature" ); + } ); + + it( "keeps the Null provider capability set intentionally small", function(){ + var nullProvider = new cbpayments.models.providers.NullProvider().startup( "disabled" ); + expect( nullProvider.capabilities() ).toBe( [ "hostedCheckout" ] ); + expect( nullProvider.supports( "refunds" ) ).toBeFalse(); + } ); + } ); + } + +} diff --git a/test-harness/tests/specs/unit/MoneiClientSpec.cfc b/test-harness/tests/specs/unit/MoneiClientSpec.cfc new file mode 100644 index 0000000..38d8909 --- /dev/null +++ b/test-harness/tests/specs/unit/MoneiClientSpec.cfc @@ -0,0 +1,48 @@ +component extends="coldbox.system.testing.BaseTestCase" { + + function run(){ + describe( "MONEI REST transport", function(){ + it( "sends authenticated JSON mutations and parses response metadata", function(){ + var moneiClient = createObject( + "component", + "cbpaymentsproviders.cbpayments-monei.models.MoneiClient" + ).init( + apiKey = "pk_test_cbpayments_transport", + accountId = "acct_transport", + baseUrl = "http://127.0.0.1:60299/monei-http-stub.cfm?route=" + ); + var response = moneiClient.createPayment( { + "amount" : 2599, + "currency" : "EUR", + "orderId" : "transport-order" + } ); + + expect( response.status ).toBe( 200 ); + expect( response.requestId ).toBe( "req_monei_transport" ); + expect( response.content.receivedMethod ).toBe( "POST" ); + expect( response.content.receivedPath ).toBe( "/payments" ); + expect( response.content.authorized ).toBeTrue(); + expect( response.content.accountScoped ).toBeTrue(); + expect( response.content.amount ).toBe( 2599 ); + } ); + + it( "uses encoded payment paths and omits a body for reads", function(){ + var moneiClient = createObject( + "component", + "cbpaymentsproviders.cbpayments-monei.models.MoneiClient" + ).init( + apiKey = "pk_test_cbpayments_transport", + baseUrl = "http://127.0.0.1:60299/monei-http-stub.cfm?route=" + ); + var response = moneiClient.retrievePayment( "pay space" ); + + expect( response.status ).toBe( 200 ); + expect( response.content.receivedMethod ).toBe( "GET" ); + expect( response.content.receivedPath ).toBe( "/payments/pay space" ); + expect( response.content.authorized ).toBeTrue(); + expect( response.content.accountScoped ).toBeFalse(); + } ); + } ); + } + +} diff --git a/test-harness/tests/specs/unit/MoneiProviderSpec.cfc b/test-harness/tests/specs/unit/MoneiProviderSpec.cfc new file mode 100644 index 0000000..bc92ebd --- /dev/null +++ b/test-harness/tests/specs/unit/MoneiProviderSpec.cfc @@ -0,0 +1,301 @@ +component extends="coldbox.system.testing.BaseTestCase" { + + this.loadColdbox = true; + this.unLoadColdBox = false; + + function beforeAll(){ + super.beforeAll(); + setup(); + var moduleService = getController().getModuleService(); + if ( !moduleService.isModuleRegistered( "cbpayments-monei" ) ) { + moduleService.registerModule( moduleName = "cbpayments-monei", invocationPath = "cbpaymentsproviders" ); + } + if ( !moduleService.isModuleActive( "cbpayments-monei" ) ) { + moduleService.activateModule( "cbpayments-monei" ); + } + } + + function run(){ + describe( "MONEI provider", function(){ + beforeEach( function(){ + moneiClient = new tests.resources.FakeMoneiClient(); + provider = getInstance( "MoneiProvider@cbpayments-monei" ); + provider.startup( + "monei", + { + "apiKey" : "pk_test_cbpayments_contract", + "accountId" : "acct_monei", + "client" : moneiClient, + "toleranceSeconds" : 300 + } + ); + } ); + + it( "is resolved by its provider module WireBox ID and advertises mapped capabilities", function(){ + expect( provider.getProviderType() ).toBe( "MONEI" ); + expect( provider.capabilities() ).toBe( [ + "hostedCheckout", + "paymentIntents", + "capture", + "refunds", + "webhooks" + ] ); + expect( provider.supports( "customers" ) ).toBeFalse(); + } ); + + it( "creates hosted checkout payments and normalizes redirect results", function(){ + moneiClient.enqueue( "createPayment", successResponse( "pay_checkout", "PENDING", true ) ); + var result = provider.createCheckout( + new cbpayments.models.contracts.requests.HostedCheckoutRequest( + money = new cbpayments.models.contracts.Money( 2599, "eur" ), + idempotencyKey = "order-checkout-1", + returnUrl = "https://merchant.test/complete", + cancelUrl = "https://merchant.test/cancel", + description = "Order 1", + providerOptions = { "MONEI" : { "callbackUrl" : "https://merchant.test/webhooks/monei" } } + ) + ); + var payload = moneiClient.getCalls()[ 1 ].arguments.payload; + expect( payload.amount ).toBe( 2599 ); + expect( payload.currency ).toBe( "EUR" ); + expect( payload.orderId ).toBe( "order-checkout-1" ); + expect( payload.callbackUrl ).toBe( "https://merchant.test/webhooks/monei" ); + expect( result.getNextAction().redirectUrl ).toBe( "https://payments.monei.test/pay_checkout" ); + expect( result.getMemento().processorName ).toBe( "monei" ); + } ); + + it( "maps deferred authorization, confirmation, capture, cancel, and refund operations", function(){ + moneiClient + .enqueue( "createPayment", successResponse( "pay_auth", "AUTHORIZED" ) ) + .enqueue( "confirmPayment", successResponse( "pay_auth", "SUCCEEDED" ) ) + .enqueue( "capturePayment", successResponse( "pay_auth", "SUCCEEDED" ) ) + .enqueue( "cancelPayment", successResponse( "pay_auth", "CANCELED" ) ) + .enqueue( "refundPayment", successResponse( "pay_auth", "PARTIALLY_REFUNDED" ) ); + var created = provider.createPaymentIntent( + new cbpayments.models.contracts.requests.PaymentIntentRequest( + money = new cbpayments.models.contracts.Money( 2599, "eur" ), + idempotencyKey = "order-auth-1", + captureMethod = "manual" + ) + ); + var confirmed = provider.confirmPaymentIntent( + "pay_auth", + "confirm-auth-1", + { "paymentToken" : "payment-token-reference" } + ); + var captured = provider.capturePayment( + new cbpayments.models.contracts.requests.CaptureRequest( + externalId = "pay_auth", + idempotencyKey = "capture-auth-1", + money = new cbpayments.models.contracts.Money( 2599, "eur" ) + ) + ); + var canceled = provider.cancelPaymentIntent( "pay_auth", "cancel-auth-1" ); + var refunded = provider.createRefund( + new cbpayments.models.contracts.requests.RefundRequest( + externalId = "pay_auth", + idempotencyKey = "refund-auth-1", + money = new cbpayments.models.contracts.Money( 500, "eur" ), + reason = "requested_by_customer" + ) + ); + var calls = moneiClient.getCalls(); + expect( calls[ 1 ].arguments.payload.transactionType ).toBe( "AUTH" ); + expect( calls[ 2 ].arguments.payload.paymentToken ).toBe( "payment-token-reference" ); + expect( calls[ 3 ].arguments.payload.amount ).toBe( 2599 ); + expect( calls[ 5 ].arguments.payload.refundReason ).toBe( "requested_by_customer" ); + expect( created.getStatus() ).toBe( "authorized" ); + expect( confirmed.getStatus() ).toBe( "succeeded" ); + expect( captured.getOperation() ).toBe( "capture.create" ); + expect( canceled.getStatus() ).toBe( "cancelled" ); + expect( refunded.getStatus() ).toBe( "partially_refunded" ); + } ); + + it( "normalizes authentication, rate-limit, provider, validation, and malformed failures", function(){ + for ( + var testCase in [ + { + "http" : 401, + "category" : "authentication", + "retryable" : false + }, + { + "http" : 422, + "category" : "validation", + "retryable" : false + }, + { + "http" : 429, + "category" : "rate_limited", + "retryable" : true + }, + { + "http" : 503, + "category" : "provider", + "retryable" : true + } + ] + ) { + moneiClient.enqueue( + "retrievePayment", + { + "status" : testCase.http, + "content" : { "statusCode" : "E#testCase.http#" } + } + ); + var result = provider.retrievePaymentIntent( "pay_failure" ); + expect( result.getFailure().getCategory() ).toBe( testCase.category ); + expect( result.getFailure().getRetryable() ).toBe( testCase.retryable ); + } + moneiClient.enqueue( + "retrievePayment", + { "status" : 200, "content" : { "status" : "SUCCEEDED" } } + ); + expect( + provider + .retrievePaymentIntent( "pay_malformed" ) + .getFailure() + .getCode() + ).toBe( "malformed_response" ); + } ); + + it( "rejects unnamespaced and unknown provider options", function(){ + expect( function(){ + provider.createPaymentIntent( + new cbpayments.models.contracts.requests.PaymentIntentRequest( + money = new cbpayments.models.contracts.Money( 100, "eur" ), + idempotencyKey = "bad-options", + providerOptions = { "callbackUrl" : "https://merchant.test/webhook" } + ) + ); + } ).toThrow( "cbpayments.InvalidProviderOptions" ); + } ); + + it( "rejects unsafe transport and malformed Processor properties", function(){ + for ( + var properties in [ + { + "baseUrl" : "http://provider.invalid/v1", + "client" : moneiClient + }, + { "toleranceSeconds" : -1, "client" : moneiClient }, + { + "accountId" : "acct_ok#chr( 10 )#Injected: true", + "client" : moneiClient + }, + { "client" : "not-an-object" } + ] + ) { + expect( function(){ + getInstance( "MoneiProvider@cbpayments-monei" ).startup( "invalid-monei", properties ); + } ).toThrow( "cbpayments.InvalidConfiguration" ); + } + } ); + + it( "verifies account webhooks, normalizes events, and announces shared interception points", function(){ + var recorder = new tests.resources.RecordingInterceptor(); + provider.setInterceptorService( recorder ); + var raw = serializeJSON( { + "id" : "evt_monei", + "type" : "charge.succeeded", + "object" : { + "id" : "pay_monei", + "accountId" : "acct_monei", + "status" : "SUCCEEDED", + "amount" : 2599, + "currency" : "EUR" + } + } ); + var signature = signedHeader( raw ); + var event = provider.verifyWebhook( raw, "v0=ignored,#signature#" ); + expect( event.getEventType() ).toBe( "payment.succeeded" ); + expect( event.getProviderEventType() ).toBe( "charge.succeeded" ); + expect( event.getAmount().getAmountMinor() ).toBe( 2599 ); + var announced = serializeJSON( recorder.getEvents() ); + expect( announced ).toInclude( "cbpaymentsOnWebhookVerified" ); + expect( announced ).toInclude( "cbpaymentsOnPaymentSucceeded" ); + expect( announced ).notToInclude( raw ); + + var refundRaw = serializeJSON( { + "id" : "evt_monei_refund", + "type" : "refund.pending", + "object" : { + "id" : "pay_monei", + "accountId" : "acct_monei", + "status" : "PENDING", + "amount" : 500, + "currency" : "EUR" + } + } ); + expect( provider.verifyWebhook( refundRaw, signedHeader( refundRaw ) ).getEventType() ).toBe( "refund.processing" ); + expect( serializeJSON( recorder.getEvents() ) ).toInclude( "cbpaymentsOnRefundProcessing" ); + } ); + + it( "verifies direct payment callbacks and rejects stale, invalid, malformed, and mismatched deliveries", function(){ + var raw = serializeJSON( { + "id" : "pay_callback", + "accountId" : "acct_monei", + "status" : "AUTHORIZED", + "amount" : 2599, + "currency" : "EUR" + } ); + expect( provider.verifyWebhook( raw, signedHeader( raw ) ).getEventType() ).toBe( "payment.authorized" ); + expect( function(){ + provider.verifyWebhook( raw, signedHeader( raw, nowUnix() - 301 ) ); + } ).toThrow( "cbpayments.StaleWebhook" ); + expect( function(){ + provider.verifyWebhook( raw, "t=#nowUnix()#,v1=#repeatString( "0", 64 )#" ); + } ).toThrow( "cbpayments.InvalidWebhookSignature" ); + var malformed = "not-json"; + expect( function(){ + provider.verifyWebhook( malformed, signedHeader( malformed ) ); + } ).toThrow( "cbpayments.MalformedWebhook" ); + var mismatch = replace( raw, "acct_monei", "acct_other" ); + expect( function(){ + provider.verifyWebhook( mismatch, signedHeader( mismatch ) ); + } ).toThrow( "cbpayments.WebhookAccountMismatch" ); + } ); + } ); + } + + private struct function successResponse( + required string id, + required string status, + boolean redirect = false + ){ + var content = { + "id" : arguments.id, + "status" : arguments.status, + "amount" : 2599, + "currency" : "EUR" + }; + if ( arguments.redirect ) { + content.nextAction = { + "type" : "REDIRECT", + "redirectUrl" : "https://payments.monei.test/#arguments.id#" + }; + } + return { + "status" : 200, + "requestId" : "req_#arguments.id#", + "content" : content + }; + } + + private string function signedHeader( required string rawBody, numeric timestamp = nowUnix() ){ + var signature = lCase( + hmac( + arguments.timestamp & "." & arguments.rawBody, + "pk_test_cbpayments_contract", + "hmacSHA256", + "utf-8" + ) + ); + return "t=#arguments.timestamp#,v1=#signature#"; + } + + private numeric function nowUnix(){ + return fix( createObject( "java", "java.lang.System" ).currentTimeMillis() / 1000 ); + } + +} diff --git a/test-harness/tests/specs/unit/ObservabilitySpec.cfc b/test-harness/tests/specs/unit/ObservabilitySpec.cfc new file mode 100644 index 0000000..436732c --- /dev/null +++ b/test-harness/tests/specs/unit/ObservabilitySpec.cfc @@ -0,0 +1,93 @@ +component extends="coldbox.system.testing.BaseTestCase" { + + function run(){ + describe( "safe provider observability", function(){ + it( "announces lifecycle and operation data without requests or secrets", function(){ + var recorder = new tests.resources.RecordingInterceptor(); + var provider = new cbpayments.models.providers.MockProvider(); + provider.setInterceptorService( recorder ); + provider.startup( "memory", { "apiKey" : "sk_test_cbpayments_observability" } ); + provider.createCheckout( + new cbpayments.models.contracts.requests.HostedCheckoutRequest( + money = new cbpayments.models.contracts.Money( 100, "usd" ), + idempotencyKey = "observability-1", + returnUrl = "https://example.test/success", + cancelUrl = "https://example.test/cancel" + ) + ); + provider.shutdown(); + var states = recorder + .getEvents() + .map( function( event ){ + return event.state; + } ); + var serialized = serializeJSON( recorder.getEvents() ); + expect( states ).toInclude( "cbpaymentsOnProcessorStart" ); + expect( states ).toInclude( "cbpaymentsPreOperation" ); + expect( states ).toInclude( "cbpaymentsPostOperation" ); + expect( states ).toInclude( "cbpaymentsOnProcessorShutdown" ); + expect( serialized ).notToInclude( "sk_test" ); + expect( serialized ).notToInclude( "returnUrl" ); + } ); + + it( "honors the module request-ID observability setting", function(){ + var recorder = new tests.resources.RecordingInterceptor(); + var provider = new cbpayments.models.providers.MockProvider(); + provider.setInterceptorService( recorder ); + provider.setModuleSettings( { "logging" : { "includeProviderRequestIds" : false } } ); + provider.startup( "memory" ); + provider.createCheckout( + new cbpayments.models.contracts.requests.HostedCheckoutRequest( + money = new cbpayments.models.contracts.Money( 100, "usd" ), + idempotencyKey = "observability-no-request-id", + returnUrl = "https://example.test/success", + cancelUrl = "https://example.test/cancel" + ) + ); + expect( serializeJSON( recorder.getEvents() ) ).notToInclude( "requestId" ); + } ); + + it( "announces verified and rejected webhooks without signatures or payloads", function(){ + var recorder = new tests.resources.RecordingInterceptor(); + var provider = new cbpayments.models.providers.StripeProvider(); + provider.setInterceptorService( recorder ); + provider.startup( + "stripe", + { + "client" : new tests.resources.FakeStripeClient(), + "webhookSecrets" : [ "whsec_cbpayments_valid" ], + "toleranceSeconds" : 300 + } + ); + var rawBody = serializeJSON( { + "id" : "evt_observability", + "type" : "payment_intent.succeeded", + "created" : nowUnix(), + "data" : { + "object" : { + "id" : "pi_observability", + "object" : "payment_intent", + "status" : "succeeded" + } + } + } ); + provider.verifyWebhook( rawBody, "t=#nowUnix()#,v1=fake" ); + expect( function(){ + provider.verifyWebhook( rawBody, "t=0,v1=forbidden-signature" ); + } ).toThrow( "cbpayments.StaleWebhook" ); + var serialized = serializeJSON( recorder.getEvents() ); + expect( serialized ).toInclude( "cbpaymentsOnWebhookVerified" ); + expect( serialized ).toInclude( "cbpaymentsOnPaymentSucceeded" ); + expect( serialized ).toInclude( "cbpaymentsOnWebhookRejected" ); + expect( serialized ).notToInclude( rawBody ); + expect( serialized ).notToInclude( "forbidden-signature" ); + expect( serialized ).notToInclude( "whsec" ); + } ); + } ); + } + + private numeric function nowUnix(){ + return fix( createObject( "java", "java.lang.System" ).currentTimeMillis() / 1000 ); + } + +} diff --git a/test-harness/tests/specs/unit/PaymentServiceSpec.cfc b/test-harness/tests/specs/unit/PaymentServiceSpec.cfc new file mode 100644 index 0000000..f597a60 --- /dev/null +++ b/test-harness/tests/specs/unit/PaymentServiceSpec.cfc @@ -0,0 +1,196 @@ +component extends="coldbox.system.testing.BaseTestCase" { + + this.loadColdbox = true; + this.unLoadColdBox = false; + + function beforeAll(){ + super.beforeAll(); + setup(); + } + + function run(){ + describe( "PaymentService processor registry", function(){ + beforeEach( function(){ + service = new cbpayments.models.PaymentService(); + service.setWirebox( getController().getWireBox() ); + service.setModuleSettings( { + "defaultProcessor" : "Primary", + "processors" : {}, + "webhooks" : { "toleranceSeconds" : 300 }, + "logging" : { "includeProviderRequestIds" : true } + } ); + } ); + + it( "looks up processor names case-insensitively and preserves display spelling", function(){ + service.registerProcessor( "Primary", "MockProvider@cbpayments" ); + expect( service.hasProcessor( "primary" ) ).toBeTrue(); + expect( service.processorNames() ).toInclude( "Primary" ); + expect( service.processor( "PRIMARY" ).getProcessorName() ).toBe( "Primary" ); + expect( service.defaultProcessor() ).toBe( service.processor( "primary" ) ); + } ); + + it( "fails duplicate processor names unless override is explicit", function(){ + service.registerProcessor( "Primary", "MockProvider@cbpayments" ); + expect( function(){ + service.registerProcessor( "primary", "MockProvider@cbpayments" ); + } ).toThrow( "cbpayments.DuplicateProcessor" ); + var original = service.processor( "Primary" ); + service.registerProcessor( "PRIMARY", "MockProvider@cbpayments", {}, true ); + expect( original.hasStarted() ).toBeFalse(); + expect( service.processor( "primary" ).getIdentifier() ).notToBe( original.getIdentifier() ); + } ); + + it( "validates the configured default and unknown lookups", function(){ + expect( function(){ + service.validateDefaultProcessor(); + } ).toThrow( "cbpayments.InvalidConfiguration" ); + expect( function(){ + service.processor( "missing" ); + } ).toThrow( "cbpayments.UnknownProcessor" ); + } ); + + it( "validates settings and processor definition shapes", function(){ + service.setModuleSettings( { + "defaultProcessor" : "Primary", + "processors" : {}, + "webhooks" : { "toleranceSeconds" : -1 }, + "logging" : { "includeProviderRequestIds" : true } + } ); + expect( function(){ + service.validateSettings(); + } ).toThrow( "cbpayments.InvalidConfiguration" ); + service.setModuleSettings( { + "defaultProcessor" : "Primary", + "processors" : { + "Primary" : { + "provider" : "MockProvider@cbpayments", + "properties" : "invalid" + } + }, + "webhooks" : { "toleranceSeconds" : 300 }, + "logging" : { "includeProviderRequestIds" : true } + } ); + expect( function(){ + service.registerAppProcessors(); + } ).toThrow( "cbpayments.InvalidConfiguration" ); + service.setModuleSettings( { + "defaultProcessor" : "Primary", + "processors" : { "Primary" : { "provider" : "MockProvider" } }, + "webhooks" : { "toleranceSeconds" : 300 }, + "logging" : { "includeProviderRequestIds" : true } + } ); + expect( function(){ + service.registerAppProcessors(); + } ).toThrow( "cbpayments.InvalidConfiguration" ); + } ); + + it( "resolves a provider directly by its full WireBox ID", function(){ + service.registerProcessor( + "Primary", + "MockProvider@cbpayments", + { "label" : "primary" } + ); + var processor = service.processor( "Primary" ); + expect( processor.getProviderType() ).toBe( "Mock" ); + expect( processor.getProperties().label ).toBe( "primary" ); + } ); + + it( "rejects objects that do not implement the provider contract", function(){ + service.registerProcessor( "Primary", "tests.resources.InvalidProvider" ); + expect( function(){ + service.processor( "Primary" ); + } ).toThrow( "cbpayments.InvalidProviderContract" ); + } ); + + it( "fails unsupported capabilities before invocation", function(){ + service.registerProcessor( "Primary", "NullProvider@cbpayments" ); + var request = new cbpayments.models.contracts.requests.RefundRequest( + externalId = "pi_test", + idempotencyKey = "refund-test" + ); + expect( function(){ + service.createRefund( request ); + } ).toThrow( "cbpayments.UnsupportedCapability" ); + } ); + + it( "constructs exactly one provider under concurrent first access", function(){ + var counter = createObject( "java", "java.util.concurrent.atomic.AtomicInteger" ).init( 0 ); + var threadNames = []; + var serviceKey = "cbpaymentsConcurrency#replace( createUUID(), "-", "", "all" )#"; + service.registerProcessor( + "Primary", + "tests.resources.CountingProvider", + { "counter" : counter } + ); + server[ serviceKey ] = service; + try { + for ( var index = 1; index <= 8; index++ ) { + var threadName = "cbpayments-concurrency-#index#-#createUUID()#"; + threadNames.append( threadName ); + thread name=threadName action="run" serviceKey=serviceKey { + server[ attributes.serviceKey ].processor( "Primary" ); + } + } + thread action="join" name=threadNames.toList(); + } finally { + server.delete( serviceKey ); + } + expect( counter.get() ).toBe( 1 ); + } ); + + it( "constructs different processor names under independent locks", function(){ + var slowCounter = createObject( "java", "java.util.concurrent.atomic.AtomicInteger" ).init( 0 ); + var fastCounter = createObject( "java", "java.util.concurrent.atomic.AtomicInteger" ).init( 0 ); + var enteredLatch = createObject( "java", "java.util.concurrent.CountDownLatch" ).init( 1 ); + var releaseLatch = createObject( "java", "java.util.concurrent.CountDownLatch" ).init( 1 ); + var timeUnit = createObject( "java", "java.util.concurrent.TimeUnit" ).SECONDS; + var serviceKey = "cbpaymentsIndependentLocks#replace( createUUID(), "-", "", "all" )#"; + var slowThread = "cbpayments-slow-#createUUID()#"; + var fastThread = "cbpayments-fast-#createUUID()#"; + service.registerProcessor( + "Slow", + "tests.resources.CountingProvider", + { + "counter" : slowCounter, + "startupEnteredLatch" : enteredLatch, + "startupReleaseLatch" : releaseLatch + } + ); + service.registerProcessor( + "Fast", + "tests.resources.CountingProvider", + { "counter" : fastCounter } + ); + server[ serviceKey ] = service; + try { + thread name=slowThread action="run" serviceKey=serviceKey { + server[ attributes.serviceKey ].processor( "Slow" ); + } + expect( enteredLatch.await( 2, timeUnit ) ).toBeTrue(); + thread name=fastThread action="run" serviceKey=serviceKey { + server[ attributes.serviceKey ].processor( "Fast" ); + } + thread action="join" name=fastThread timeout=2000; + expect( fastCounter.get() ).toBe( 1 ); + } finally { + releaseLatch.countDown(); + thread action="join" name="#slowThread#,#fastThread#" timeout=6000; + server.delete( serviceKey ); + } + expect( slowCounter.get() ).toBe( 1 ); + } ); + + it( "shuts down every instantiated processor", function(){ + service.registerProcessor( "Primary", "MockProvider@cbpayments" ); + service.registerProcessor( "Secondary", "MockProvider@cbpayments" ); + var first = service.processor( "Primary" ); + var second = service.processor( "Secondary" ); + service.shutdown(); + expect( first.hasStarted() ).toBeFalse(); + expect( second.hasStarted() ).toBeFalse(); + expect( service.processorCount() ).toBe( 0 ); + } ); + } ); + } + +} diff --git a/test-harness/tests/specs/unit/ProviderContractSpec.cfc b/test-harness/tests/specs/unit/ProviderContractSpec.cfc new file mode 100644 index 0000000..5027fcb --- /dev/null +++ b/test-harness/tests/specs/unit/ProviderContractSpec.cfc @@ -0,0 +1,37 @@ +component extends="coldbox.system.testing.BaseTestCase" { + + function run(){ + describe( "provider extension contract kit", function(){ + it( "accepts conforming first-party and external providers", function(){ + var contract = new cbpayments.models.testing.ProviderContract(); + expect( + contract.verify( + new cbpayments.models.providers.MockProvider().startup( "memory" ), + [ "hostedCheckout", "webhooks" ] + ).ok + ).toBeTrue(); + expect( + contract.verify( + createObject( "component", "root.modules.cbpayments-fixture.models.FixtureProvider" ) + .init() + .startup( "fixture" ), + [ "hostedCheckout" ] + ).ok + ).toBeTrue(); + } ); + + it( "reports incomplete providers", function(){ + var contract = new cbpayments.models.testing.ProviderContract(); + var report = contract.verify( new tests.resources.InvalidProvider() ); + expect( report.ok ).toBeFalse(); + expect( report.failures ).notToBeEmpty(); + var capabilityReport = contract.verify( + new tests.resources.IncompleteCapabilityProvider().startup( "incomplete" ) + ); + expect( capabilityReport.ok ).toBeFalse(); + expect( capabilityReport.failures.toList( " " ) ).toInclude( "requires method [createCheckout]" ); + } ); + } ); + } + +} diff --git a/test-harness/tests/specs/unit/RedactorSpec.cfc b/test-harness/tests/specs/unit/RedactorSpec.cfc new file mode 100644 index 0000000..f057431 --- /dev/null +++ b/test-harness/tests/specs/unit/RedactorSpec.cfc @@ -0,0 +1,46 @@ +component extends="coldbox.system.testing.BaseTestCase" { + + function run(){ + describe( "payment-data redaction", function(){ + beforeEach( function(){ + redactor = new cbpayments.models.util.Redactor(); + } ); + + it( "recursively removes sensitive keys without mutating the source", function(){ + var source = { + "apiKey" : "sk_test_cbpayments_source", + "nested" : { + "authorization" : "Bearer private-value", + "safe" : "visible" + }, + "items" : [ { "client_secret" : "pi_one_secret_two" } ] + }; + var safe = redactor.redact( source ); + expect( safe.apiKey ).toBe( "[REDACTED]" ); + expect( safe.nested.authorization ).toBe( "[REDACTED]" ); + expect( safe.nested.safe ).toBe( "visible" ); + expect( safe.items[ 1 ].client_secret ).toBe( "[REDACTED]" ); + expect( source.apiKey ).toInclude( "cbpayments_source" ); + } ); + + it( "scrubs recognizable secrets embedded in messages", function(){ + var safe = redactor.redactString( + "key sk_test_short restricted rk_test_short monei pk_test_short secret whsec_short Bearer abc.def seti_123_secret_abc pm_123 card 4242 4242 4242 4242" + ); + expect( safe ).notToInclude( "sk_test_short" ); + expect( safe ).notToInclude( "rk_test_short" ); + expect( safe ).notToInclude( "pk_test_short" ); + expect( safe ).notToInclude( "whsec_short" ); + expect( safe ).notToInclude( "abc.def" ); + expect( safe ).notToInclude( "seti_123" ); + expect( safe ).notToInclude( "pm_123" ); + expect( safe ).notToInclude( "4242 4242" ); + } ); + + it( "does not serialize arbitrary objects", function(){ + expect( redactor.redact( new cbpayments.models.util.CurrencyMetadata() ) ).toBe( "[REDACTED OBJECT]" ); + } ); + } ); + } + +} diff --git a/test-harness/tests/specs/unit/StripeProviderSpec.cfc b/test-harness/tests/specs/unit/StripeProviderSpec.cfc new file mode 100644 index 0000000..a703da7 --- /dev/null +++ b/test-harness/tests/specs/unit/StripeProviderSpec.cfc @@ -0,0 +1,535 @@ +component extends="coldbox.system.testing.BaseTestCase" { + + function run(){ + describe( "Stripe provider adapter", function(){ + beforeEach( function(){ + stripeClient = new tests.resources.FakeStripeClient(); + provider = new cbpayments.models.providers.StripeProvider().startup( + "stripe-primary", + { + "client" : stripeClient, + "webhookSecrets" : [ + "whsec_cbpayments_old", + "whsec_cbpayments_valid" + ], + "connectAccount" : "acct_cbpayments_primary", + "defaultCurrency" : "usd", + "apiVersion" : "2026-02-25.clover", + "toleranceSeconds" : 300 + } + ); + money = new cbpayments.models.contracts.Money( 2350, "usd" ); + } ); + + it( "requires credentials when no injected client is provided", function(){ + expect( function(){ + new cbpayments.models.providers.StripeProvider().startup( "invalid", {} ); + } ).toThrow( "cbpayments.InvalidConfiguration" ); + } ); + + it( "rejects malformed Stripe configuration during startup", function(){ + expect( function(){ + new cbpayments.models.providers.StripeProvider().startup( + "invalid", + { + "client" : new tests.resources.FakeStripeClient(), + "webhookSecrets" : [ "" ], + "toleranceSeconds" : "later" + } + ); + } ).toThrow( "cbpayments.InvalidConfiguration" ); + } ); + + it( "rejects missing identifiers and idempotency before client calls", function(){ + expect( function(){ + provider.retrievePaymentIntent( "" ); + } ).toThrow( "cbpayments.InvalidRequest" ); + expect( function(){ + provider.cancelPaymentIntent( "pi_test", "" ); + } ).toThrow( "cbpayments.InvalidIdempotencyKey" ); + expect( stripeClient.getCalls() ).toBeEmpty(); + } ); + + it( "creates hosted Checkout Sessions with dynamic methods and idempotency", function(){ + stripeClient.enqueue( + "checkout.sessions", + "create", + successResponse( { + "id" : "cs_test_1", + "object" : "checkout.session", + "status" : "open", + "payment_status" : "unpaid", + "url" : "https://checkout.stripe.test/c/pay/cs_test_1", + "amount_total" : 2350, + "currency" : "usd" + } ) + ); + var paymentRequest = new cbpayments.models.contracts.requests.HostedCheckoutRequest( + money = money, + idempotencyKey = "checkout-stripe-1", + returnUrl = "https://example.test/success", + cancelUrl = "https://example.test/cancel", + description = "Rental invoice" + ); + var result = provider.createCheckout( paymentRequest ); + var call = stripeClient.getCalls()[ 1 ]; + expect( result.getStatus() ).toBe( "pending" ); + expect( result.getNextAction().type ).toBe( "redirect" ); + expect( call.resource ).toBe( "checkout.sessions" ); + expect( call.arguments[ 1 ].mode ).toBe( "payment" ); + expect( call.arguments[ 1 ] ).notToHaveKey( "payment_method_types" ); + expect( call.arguments[ 1 ].line_items[ 1 ].price_data.unit_amount ).toBe( 2350 ); + expect( call.arguments[ 2 ].idempotencyKey ).toBe( "checkout-stripe-1" ); + expect( call.arguments[ 2 ].stripeAccount ).toBe( "acct_cbpayments_primary" ); + } ); + + it( "maps Payment Intent client secrets into the sensitive escape hatch only", function(){ + stripeClient.enqueue( + "paymentIntents", + "create", + successResponse( { + "id" : "pi_test_1", + "object" : "payment_intent", + "status" : "requires_action", + "amount" : 2350, + "currency" : "usd", + "client_secret" : "pi_test_1_secret_cbpayments" + } ) + ); + var paymentRequest = new cbpayments.models.contracts.requests.PaymentIntentRequest( + money = money, + idempotencyKey = "intent-stripe-1", + customerId = "cus_test_1", + paymentMethodId = "pm_test_1" + ); + var result = provider.createPaymentIntent( paymentRequest ); + var params = stripeClient.getCalls()[ 1 ].arguments[ 1 ]; + expect( result.getStatus() ).toBe( "requires_action" ); + expect( result.getClientAction().getClientSecret() ).toBe( "pi_test_1_secret_cbpayments" ); + expect( serializeJSON( result.getMemento() ) ).notToInclude( "client_secret" ); + expect( params.automatic_payment_methods.enabled ).toBeTrue(); + expect( params ).notToHaveKey( "source" ); + } ); + + it( "passes allow-listed Connect options and rejects unknown options", function(){ + stripeClient.enqueue( + "paymentIntents", + "create", + successResponse( { + "id" : "pi_connect", + "status" : "processing", + "amount" : 2350, + "currency" : "usd" + } ) + ); + provider.createPaymentIntent( + new cbpayments.models.contracts.requests.PaymentIntentRequest( + money = money, + idempotencyKey = "intent-connect", + providerOptions = { + "Stripe" : { + "transferDestination" : "acct_destination", + "applicationFeeAmount" : 125 + } + } + ) + ); + var params = stripeClient.getCalls()[ 1 ].arguments[ 1 ]; + expect( params.transfer_data.destination ).toBe( "acct_destination" ); + expect( params.application_fee_amount ).toBe( 125 ); + expect( function(){ + provider.createPaymentIntent( + new cbpayments.models.contracts.requests.PaymentIntentRequest( + money = money, + idempotencyKey = "intent-invalid-option", + providerOptions = { "Stripe" : { "rawParams" : {} } } + ) + ); + } ).toThrow( "cbpayments.InvalidProviderOptions" ); + } ); + + it( "implements capture, refund, Setup Intent, and customer operations", function(){ + stripeClient + .enqueue( + "paymentIntents", + "capture", + successResponse( { "id" : "pi_capture", "status" : "succeeded" } ) + ) + .enqueue( + "refunds", + "create", + successResponse( { + "id" : "re_test", + "status" : "succeeded", + "amount" : 500, + "currency" : "usd" + } ) + ) + .enqueue( + "setupIntents", + "create", + successResponse( { "id" : "seti_test", "status" : "requires_action" } ) + ) + .enqueue( + "customers", + "create", + successResponse( { "id" : "cus_test", "object" : "customer" } ) + ); + provider.capturePayment( + new cbpayments.models.contracts.requests.CaptureRequest( + externalId = "pi_capture", + idempotencyKey = "capture-stripe", + money = money + ) + ); + provider.createRefund( + new cbpayments.models.contracts.requests.RefundRequest( + externalId = "pi_capture", + idempotencyKey = "refund-stripe", + money = new cbpayments.models.contracts.Money( 500, "usd" ) + ) + ); + provider.createSetupIntent( + new cbpayments.models.contracts.requests.SetupIntentRequest( + idempotencyKey = "setup-stripe", + customerId = "cus_test" + ) + ); + provider.createCustomer( + new cbpayments.models.contracts.requests.CustomerRequest( + idempotencyKey = "customer-stripe", + email = "buyer@example.test", + customerName = "Test Buyer" + ) + ); + expect( + stripeClient + .getCalls() + .map( function( call ){ + return call.resource & "." & call.method; + } ) + ).toBe( [ + "paymentIntents.capture", + "refunds.create", + "setupIntents.create", + "customers.create" + ] ); + expect( stripeClient.getCalls()[ 1 ].arguments[ 1 ] ).toBe( "pi_capture" ); + expect( stripeClient.getCalls()[ 1 ].arguments[ 2 ].amount_to_capture ).toBe( 2350 ); + } ); + + it( "retrieves and mutates every supported Stripe resource through the modern APIs", function(){ + stripeClient + .enqueue( + "checkout.sessions", + "retrieve", + successResponse( { "id" : "cs_ops", "status" : "open" } ) + ) + .enqueue( + "checkout.sessions", + "expire", + successResponse( { "id" : "cs_ops", "status" : "expired" } ) + ) + .enqueue( + "paymentIntents", + "retrieve", + successResponse( { "id" : "pi_ops", "status" : "processing" } ) + ) + .enqueue( + "paymentIntents", + "confirm", + successResponse( { "id" : "pi_ops", "status" : "succeeded" } ) + ) + .enqueue( + "paymentIntents", + "cancel", + successResponse( { "id" : "pi_ops", "status" : "canceled" } ) + ) + .enqueue( + "refunds", + "retrieve", + successResponse( { "id" : "re_ops", "status" : "succeeded" } ) + ) + .enqueue( + "setupIntents", + "retrieve", + successResponse( { "id" : "seti_ops", "status" : "processing" } ) + ) + .enqueue( + "setupIntents", + "cancel", + successResponse( { "id" : "seti_ops", "status" : "canceled" } ) + ) + .enqueue( + "customers", + "retrieve", + successResponse( { "id" : "cus_ops", "object" : "customer" } ) + ) + .enqueue( + "customers", + "update", + successResponse( { "id" : "cus_ops", "object" : "customer" } ) + ) + .enqueue( + "customers", + "delete", + successResponse( { "id" : "cus_ops", "deleted" : true } ) + ); + + expect( provider.retrieveCheckout( "cs_ops" ).getExternalId() ).toBe( "cs_ops" ); + expect( provider.expireCheckout( "cs_ops", "expire-ops" ).getStatus() ).toBe( "cancelled" ); + expect( provider.retrievePaymentIntent( "pi_ops" ).getStatus() ).toBe( "pending" ); + expect( + provider + .confirmPaymentIntent( + "pi_ops", + "confirm-ops", + { + "paymentMethodId" : "pm_ops", + "returnUrl" : "https://example.test/payments/return" + } + ) + .getStatus() + ).toBe( "succeeded" ); + expect( provider.cancelPaymentIntent( "pi_ops", "cancel-ops" ).getStatus() ).toBe( "cancelled" ); + expect( provider.retrieveRefund( "re_ops" ).getExternalId() ).toBe( "re_ops" ); + expect( provider.retrieveSetupIntent( "seti_ops" ).getStatus() ).toBe( "pending" ); + expect( provider.cancelSetupIntent( "seti_ops", "cancel-setup-ops" ).getStatus() ).toBe( "cancelled" ); + expect( provider.retrieveCustomer( "cus_ops" ).getExternalId() ).toBe( "cus_ops" ); + expect( + provider + .updateCustomer( + "cus_ops", + new cbpayments.models.contracts.requests.CustomerRequest( + idempotencyKey = "update-customer-ops", + customerName = "Updated" + ) + ) + .getExternalId() + ).toBe( "cus_ops" ); + expect( provider.deleteCustomer( "cus_ops", "delete-customer-ops" ).getExternalId() ).toBe( "cus_ops" ); + expect( + stripeClient + .getCalls() + .map( function( call ){ + return call.resource & "." & call.method; + } ) + ).toBe( [ + "checkout.sessions.retrieve", + "checkout.sessions.expire", + "paymentIntents.retrieve", + "paymentIntents.confirm", + "paymentIntents.cancel", + "refunds.retrieve", + "setupIntents.retrieve", + "setupIntents.cancel", + "customers.retrieve", + "customers.update", + "customers.delete" + ] ); + expect( stripeClient.getCalls()[ 4 ].arguments[ 2 ].return_url ).toBe( "https://example.test/payments/return" ); + } ); + + it( "normalizes every HTTP failure class and malformed responses", function(){ + stripeClient + .enqueue( + "paymentIntents", + "retrieve", + { + "status" : 402, + "requestId" : "req_decline", + "content" : { + "error" : { + "type" : "card_error", + "code" : "card_declined", + "decline_code" : "generic_decline" + } + } + } + ) + .enqueue( + "paymentIntents", + "retrieve", + { + "status" : 429, + "requestId" : "req_rate", + "content" : { "error" : { "code" : "rate_limit" } } + } + ) + .enqueue( + "paymentIntents", + "retrieve", + { + "status" : 500, + "requestId" : "req_provider", + "content" : { "error" : { "code" : "api_error" } } + } + ) + .enqueue( + "paymentIntents", + "retrieve", + { + "status" : 401, + "requestId" : "req_auth", + "content" : { "error" : { "code" : "invalid_api_key" } } + } + ) + .enqueue( + "paymentIntents", + "retrieve", + { + "status" : 400, + "requestId" : "req_validation", + "content" : { "error" : { "code" : "parameter_invalid" } } + } + ) + .enqueue( + "paymentIntents", + "retrieve", + { + "status" : 200, + "requestId" : "req_malformed", + "content" : "not-json" + } + ); + var declined = provider.retrievePaymentIntent( "pi_declined" ); + var limited = provider.retrievePaymentIntent( "pi_limited" ); + var failed = provider.retrievePaymentIntent( "pi_failed" ); + var auth = provider.retrievePaymentIntent( "pi_auth" ); + var invalid = provider.retrievePaymentIntent( "pi_invalid" ); + var malformed = provider.retrievePaymentIntent( "pi_malformed" ); + expect( declined.getFailure().getCategory() ).toBe( "declined" ); + expect( declined.getFailure().getDeclineCategory() ).toBe( "generic_decline" ); + expect( declined.getRequestId() ).toBe( "req_decline" ); + expect( limited.getFailure().getCategory() ).toBe( "rate_limited" ); + expect( limited.getFailure().getRetryable() ).toBeTrue(); + expect( failed.getFailure().getCategory() ).toBe( "provider" ); + expect( failed.getFailure().getRetryable() ).toBeTrue(); + expect( auth.getFailure().getCategory() ).toBe( "authentication" ); + expect( auth.getFailure().getRetryable() ).toBeFalse(); + expect( invalid.getFailure().getCategory() ).toBe( "validation" ); + expect( malformed.getFailure().getCode() ).toBe( "malformed_response" ); + expect( malformed.getRequestId() ).toBe( "req_malformed" ); + } ); + + it( "normalizes connection failures without leaking exception details", function(){ + stripeClient + .enqueue( + "paymentIntents", + "retrieve", + { + "__throw" : { + "type" : "StripeConnectionFailure", + "message" : "Could not connect with sk_test_cbpayments_should_be_scrubbed" + } + } + ) + .enqueue( + "paymentIntents", + "retrieve", + { + "__throw" : { + "type" : "StripeTransport", + "message" : "request timeout after 50 seconds" + } + } + ); + var result = provider.retrievePaymentIntent( "pi_network" ); + var timeoutResult = provider.retrievePaymentIntent( "pi_timeout" ); + expect( result.getFailure().getCategory() ).toBe( "network" ); + expect( result.getFailure().getRetryable() ).toBeTrue(); + expect( serializeJSON( result.getMemento() ) ).notToInclude( "sk_test" ); + expect( timeoutResult.getFailure().getCategory() ).toBe( "network" ); + expect( timeoutResult.getFailure().getRetryable() ).toBeTrue(); + } ); + + it( "scrubs untrusted response scalars and normalizes malformed successes", function(){ + stripeClient + .enqueue( + "paymentIntents", + "retrieve", + { + "status" : 400, + "requestId" : "whsec_short", + "content" : { + "id" : "sk_test_short", + "error" : { + "code" : "rk_test_short", + "decline_code" : "4242 4242 4242 4242" + } + } + } + ) + .enqueue( + "paymentIntents", + "retrieve", + { + "status" : 200, + "content" : { + "id" : "pi_bad_money", + "amount" : 100, + "currency" : "not-a-currency" + } + } + ) + .enqueue( + "paymentIntents", + "retrieve", + "not-a-response-struct" + ) + .enqueue( + "checkout.sessions", + "retrieve", + { + "status" : 200, + "content" : { + "id" : "cs_unsafe", + "url" : "javascript:alert(1)", + "payment_status" : "paid" + } + } + ); + + var unsafeFailure = provider.retrievePaymentIntent( "pi_unsafe" ); + var invalidMoney = provider.retrievePaymentIntent( "pi_bad_money" ); + var invalidShape = provider.retrievePaymentIntent( "pi_bad_shape" ); + var unsafeUrl = provider.retrieveCheckout( "cs_unsafe" ); + var serialized = serializeJSON( unsafeFailure.getMemento() ); + + expect( serialized ).notToInclude( "sk_test_short" ); + expect( serialized ).notToInclude( "rk_test_short" ); + expect( serialized ).notToInclude( "whsec_short" ); + expect( serialized ).notToInclude( "4242 4242" ); + expect( invalidMoney.getFailure().getCode() ).toBe( "malformed_response" ); + expect( invalidShape.getFailure().getCode() ).toBe( "malformed_response" ); + expect( unsafeUrl.getNextAction() ).toBeEmpty(); + } ); + + it( "keeps named Stripe clients completely isolated", function(){ + var secondClient = new tests.resources.FakeStripeClient(); + var secondProvider = new cbpayments.models.providers.StripeProvider().startup( + "stripe-secondary", + { + "client" : secondClient, + "connectAccount" : "acct_cbpayments_secondary" + } + ); + expect( provider.getClient() ).toBe( stripeClient ); + expect( secondProvider.getClient() ).toBe( secondClient ); + expect( secondProvider.getClient().getIdentifier() ).notToBe( + provider.getClient().getIdentifier() + ); + expect( secondProvider.getProperties().connectAccount ).toBe( "acct_cbpayments_secondary" ); + } ); + } ); + } + + private struct function successResponse( required struct content ){ + return { + "status" : 200, + "requestId" : "req_cbpayments_test", + "content" : arguments.content + }; + } + +} diff --git a/test-harness/tests/specs/unit/StripeWebhookSpec.cfc b/test-harness/tests/specs/unit/StripeWebhookSpec.cfc new file mode 100644 index 0000000..b5e03c3 --- /dev/null +++ b/test-harness/tests/specs/unit/StripeWebhookSpec.cfc @@ -0,0 +1,261 @@ +component extends="coldbox.system.testing.BaseTestCase" { + + function run(){ + describe( "Stripe webhook verification", function(){ + it( "supports ordered secret rotation and reports only the matched index", function(){ + var stripeClient = new tests.resources.FakeStripeClient(); + var provider = new cbpayments.models.providers.StripeProvider().startup( + "stripe", + { + "client" : stripeClient, + "webhookSecrets" : [ + "whsec_cbpayments_old", + "whsec_cbpayments_valid" + ], + "toleranceSeconds" : 300 + } + ); + var raw = eventJSON( "evt_rotation", "pi_rotation", "succeeded" ); + var event = provider.verifyWebhook( raw, "t=#nowUnix()#,v1=fake" ); + expect( event.getMatchedSecretIndex() ).toBe( 2 ); + expect( event.getEventType() ).toBe( "payment.succeeded" ); + expect( event.getProviderEventType() ).toBe( "payment_intent.succeeded" ); + expect( stripeClient.getCalls() ).toHaveLength( 2 ); + expect( serializeJSON( event.getMemento() ) ).notToInclude( "whsec" ); + } ); + + it( "verifies a real HMAC signature through stripe-cfml without a network call", function(){ + var secret = "whsec_cbpayments_local_contract"; + var provider = new cbpayments.models.providers.StripeProvider().startup( + "stripe", + { + "apiKey" : "sk_test_cbpayments_local_contract", + "webhookSecrets" : [ secret ], + "defaultCurrency" : "usd" + } + ); + var raw = eventJSON( "evt_signed", "pi_signed", "succeeded" ); + var timestamp = nowUnix(); + var signature = lCase( + hmac( + timestamp & "." & raw, + secret, + "hmacSHA256", + "utf-8" + ) + ); + var event = provider.verifyWebhook( raw, "t=#timestamp#,v1=#signature#" ); + expect( event.getEventId() ).toBe( "evt_signed" ); + expect( event.getStatus() ).toBe( "succeeded" ); + expect( event.getAmount().getAmountMinor() ).toBe( 2350 ); + } ); + + it( "distinguishes invalid signatures, malformed bodies, and account mismatches", function(){ + var stripeClient = new tests.resources.FakeStripeClient(); + var provider = new cbpayments.models.providers.StripeProvider().startup( + "stripe", + { + "client" : stripeClient, + "webhookSecrets" : [ "whsec_cbpayments_valid" ], + "connectAccount" : "acct_expected", + "toleranceSeconds" : 300 + } + ); + expect( function(){ + provider.verifyWebhook( + replace( + eventJSON( "evt_bad", "pi_bad", "failed" ), + "acct_expected", + "acct_other" + ), + "t=#nowUnix()#,v1=fake" + ); + } ).toThrow( "cbpayments.WebhookAccountMismatch" ); + expect( function(){ + provider.verifyWebhook( "{bad-json", "t=#nowUnix()#,v1=fake" ); + } ).toThrow( "cbpayments.MalformedWebhook" ); + stripeClient.setValidWebhookSecret( "a-secret-that-will-not-match" ); + expect( function(){ + provider.verifyWebhook( + eventJSON( "evt_bad_sig", "pi_bad", "failed" ), + "t=#nowUnix()#,v1=fake" + ); + } ).toThrow( "cbpayments.InvalidWebhookSignature" ); + } ); + + it( "rejects malformed signed event envelopes", function(){ + var stripeClient = new tests.resources.FakeStripeClient(); + var provider = new cbpayments.models.providers.StripeProvider().startup( + "stripe", + { + "client" : stripeClient, + "webhookSecrets" : [ "whsec_cbpayments_valid" ] + } + ); + expect( function(){ + provider.verifyWebhook( + serializeJSON( { + "id" : "evt_bad", + "type" : "payment_intent.succeeded", + "data" : { "object" : "not-an-object" } + } ), + "t=#fix( createObject( "java", "java.lang.System" ).currentTimeMillis() / 1000 )#,v1=fake" + ); + } ).toThrow( "cbpayments.MalformedWebhook" ); + } ); + + it( "rejects timestamps on both sides of the tolerance boundary", function(){ + var provider = new cbpayments.models.providers.StripeProvider().startup( + "stripe", + { + "client" : new tests.resources.FakeStripeClient(), + "webhookSecrets" : [ "whsec_cbpayments_valid" ], + "toleranceSeconds" : 300 + } + ); + var raw = eventJSON( "evt_time", "pi_time", "processing" ); + expect( function(){ + provider.verifyWebhook( raw, "t=#nowUnix() - 301#,v1=fake" ); + } ).toThrow( "cbpayments.StaleWebhook" ); + expect( function(){ + provider.verifyWebhook( raw, "t=#nowUnix() + 301#,v1=fake" ); + } ).toThrow( "cbpayments.StaleWebhook" ); + } ); + + it( "uses module webhook tolerance unless the provider overrides it", function(){ + var relaxedClient = new tests.resources.FakeStripeClient(); + var relaxedProvider = new cbpayments.models.providers.StripeProvider(); + relaxedProvider.setModuleSettings( { "webhooks" : { "toleranceSeconds" : 0 } } ); + relaxedProvider.startup( + "stripe-relaxed", + { + "client" : relaxedClient, + "webhookSecrets" : [ "whsec_cbpayments_valid" ] + } + ); + var event = relaxedProvider.verifyWebhook( + eventJSON( "evt_webhook_1", "pi_webhook_1", "succeeded" ), + "t=1,v1=fake" + ); + expect( event.getEventId() ).toBe( "evt_webhook_1" ); + + var strictProvider = new cbpayments.models.providers.StripeProvider(); + strictProvider.setModuleSettings( { "webhooks" : { "toleranceSeconds" : 0 } } ); + strictProvider.startup( + "stripe-strict", + { + "client" : new tests.resources.FakeStripeClient(), + "webhookSecrets" : [ "whsec_cbpayments_valid" ], + "toleranceSeconds" : 300 + } + ); + expect( function(){ + strictProvider.verifyWebhook( + eventJSON( "evt_webhook_1", "pi_webhook_1", "succeeded" ), + "t=1,v1=fake" + ); + } ).toThrow( "cbpayments.StaleWebhook" ); + } ); + + it( "normalizes duplicate and out-of-order events without retaining payloads", function(){ + var provider = new cbpayments.models.providers.StripeProvider().startup( + "stripe", + { + "client" : new tests.resources.FakeStripeClient(), + "webhookSecrets" : [ "whsec_cbpayments_valid" ] + } + ); + var succeededRaw = eventJSON( "evt_later", "pi_order", "succeeded" ); + var pendingRaw = eventJSON( "evt_earlier", "pi_order", "processing" ); + var signature = "t=#nowUnix()#,v1=fake"; + var later = provider.verifyWebhook( succeededRaw, signature ); + var duplicate = provider.verifyWebhook( succeededRaw, signature ); + var earlier = provider.verifyWebhook( pendingRaw, signature ); + expect( later.getPayloadChecksum() ).toBe( duplicate.getPayloadChecksum() ); + expect( earlier.getEventId() ).toBe( "evt_earlier" ); + expect( serializeJSON( later.getMemento() ) ).notToInclude( succeededRaw ); + } ); + + it( "distinguishes partial and full charge refunds", function(){ + var provider = new cbpayments.models.providers.StripeProvider().startup( + "stripe", + { + "client" : new tests.resources.FakeStripeClient(), + "webhookSecrets" : [ "whsec_cbpayments_valid" ] + } + ); + var partialRaw = replace( + eventJSON( "evt_partial", "ch_partial", "succeeded" ), + """type"":""payment_intent.succeeded""", + """type"":""charge.refunded""" + ); + partialRaw = replace( + partialRaw, + """status"":""succeeded""", + """status"":""succeeded"",""refunded"":false" + ); + var fullRaw = replace( + partialRaw, + """refunded"":false", + """refunded"":true" + ); + var signature = "t=#nowUnix()#,v1=fake"; + + expect( provider.verifyWebhook( partialRaw, signature ).getEventType() ).toBe( "payment.partially_refunded" ); + expect( provider.verifyWebhook( fullRaw, signature ).getEventType() ).toBe( "payment.refunded" ); + } ); + + it( "normalizes pending and completed refund resources", function(){ + var provider = new cbpayments.models.providers.StripeProvider().startup( + "stripe", + { + "client" : new tests.resources.FakeStripeClient(), + "webhookSecrets" : [ "whsec_cbpayments_valid" ] + } + ); + var pendingRaw = replace( + eventJSON( "evt_refund_pending", "re_pending", "pending" ), + """type"":""payment_intent.pending""", + """type"":""refund.created""" + ); + var completedRaw = replace( + pendingRaw, + """status"":""pending""", + """status"":""succeeded""" + ); + var signature = "t=#nowUnix()#,v1=fake"; + + expect( provider.verifyWebhook( pendingRaw, signature ).getEventType() ).toBe( "refund.processing" ); + expect( provider.verifyWebhook( completedRaw, signature ).getEventType() ).toBe( "refund.succeeded" ); + } ); + } ); + } + + private numeric function nowUnix(){ + return fix( createObject( "java", "java.lang.System" ).currentTimeMillis() / 1000 ); + } + + private string function eventJSON( + required string eventId, + required string objectId, + required string status + ){ + return serializeJSON( { + "id" : arguments.eventId, + "type" : "payment_intent.#arguments.status#", + "created" : nowUnix(), + "livemode" : false, + "account" : "acct_expected", + "data" : { + "object" : { + "id" : arguments.objectId, + "object" : "payment_intent", + "status" : arguments.status, + "amount" : 2350, + "currency" : "usd" + } + } + } ); + } + +} diff --git a/test-harness/tests/specs/unit/WebhookEventTypesSpec.cfc b/test-harness/tests/specs/unit/WebhookEventTypesSpec.cfc new file mode 100644 index 0000000..a38bfb2 --- /dev/null +++ b/test-harness/tests/specs/unit/WebhookEventTypesSpec.cfc @@ -0,0 +1,17 @@ +component extends="coldbox.system.testing.BaseTestCase" { + + function run(){ + describe( "shared webhook event taxonomy", function(){ + it( "exposes stable provider-neutral event and interception-point mappings", function(){ + var taxonomy = new cbpayments.models.contracts.WebhookEventTypes(); + expect( taxonomy.supports( "PAYMENT.SUCCEEDED" ) ).toBeTrue(); + expect( taxonomy.interceptionPoint( "payment.succeeded" ) ).toBe( "cbpaymentsOnPaymentSucceeded" ); + expect( taxonomy.interceptionPoint( "provider.unknown" ) ).toBeEmpty(); + expect( taxonomy.interceptionPoint( "refund.processing" ) ).toBe( "cbpaymentsOnRefundProcessing" ); + expect( taxonomy.eventTypes() ).toHaveLength( 19 ); + expect( taxonomy.interceptionPoints() ).toHaveLength( 19 ); + } ); + } ); + } + +} diff --git a/test-harness/tests/specs/unit/processor/ProcessorResponseTest.cfc b/test-harness/tests/specs/unit/processor/ProcessorResponseTest.cfc deleted file mode 100644 index 8e70b9e..0000000 --- a/test-harness/tests/specs/unit/processor/ProcessorResponseTest.cfc +++ /dev/null @@ -1,37 +0,0 @@ -/** - * The base orm entity test case will use the 'model' annotation as the instantiation path - * and then create it, prepare it for mocking and then place it in the variables scope as 'model'. It is your - * responsibility to update the model annotation instantiation path and init your model. - */ -component extends="coldbox.system.testing.BaseTestCase" { - - /*********************************** LIFE CYCLE Methods ***********************************/ - - function beforeAll(){ - super.beforeAll(); - variables.model = getWirebox().getInstance( "ProcessorResponse@cbpayments" ); - } - - function afterAll(){ - super.afterAll(); - } - - /*********************************** BDD SUITES ***********************************/ - - function run(){ - describe( "Processor response", function(){ - it( "can be created", function(){ - expect( model ).toBeComponent(); - } ); - - it( "Has consistent repsponse format", function(){ - model.setError( true ); - model.setContent( "This is a test response" ); - var response = model.getMemento(); - expect( response ).toHaveKey( "error" ).toHaveKey( "content" ); - } ); - } ); - } - -} - diff --git a/test-harness/tests/specs/unit/processor/StripeProcessorTest.cfc b/test-harness/tests/specs/unit/processor/StripeProcessorTest.cfc deleted file mode 100644 index 75cfb75..0000000 --- a/test-harness/tests/specs/unit/processor/StripeProcessorTest.cfc +++ /dev/null @@ -1,13 +0,0 @@ -component extends="tests.resources.BaseProcessorTest" { - - variables.processor = "StripeProcessor@cbpayments"; - - /*********************************** BDD SUITES ***********************************/ - - function run(){ - super.run(); - // Customizations below - } - -} - diff --git a/test-harness/views/main/index.cfm b/test-harness/views/main/index.cfm index 992cabe..640c7b9 100644 --- a/test-harness/views/main/index.cfm +++ b/test-harness/views/main/index.cfm @@ -1,3 +1,3 @@ -Module Tester - \ No newline at end of file +cbpayments Test Harness +