From a1362669e8cfa41435a5d8ce16097f4f2ed1c978 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Fri, 4 Sep 2026 21:23:47 -0600 Subject: [PATCH 1/4] feat: add named payment provider architecture --- .github/ISSUE_TEMPLATE/FEATURE_REQUEST.md | 2 +- .github/copilot-instructions.md | 54 + .github/dependabot.yml | 19 + .github/workflows/cron.yml | 93 ++ .github/workflows/live-stripe.yml | 51 + .github/workflows/pr.yml | 62 +- .github/workflows/release.yml | 342 ++++--- .github/workflows/snapshot.yml | 42 +- .github/workflows/tests.yml | 163 ++- .gitignore | 2 + .vscode/settings.json | 16 +- AGENTS.md | 36 + CONTRIBUTING.md | 8 +- LICENSE | 201 ++++ ModuleConfig.cfc | 87 +- box.json | 68 +- build/Build.cfc | 16 +- build/SetupTemplate.cfc | 70 -- build/package-smoke-harness/Application.cfc | 21 + build/package-smoke-harness/box.json | 8 + .../package-smoke-harness/config/Coldbox.cfc | 18 + build/package-smoke-harness/config/Router.cfc | 7 + build/package-smoke-harness/handlers/Main.cfc | 18 + build/package-smoke-harness/index.cfm | 3 + build/package-smoke-harness/server.json | 17 + build/package-smoke.sh | 75 ++ build/release-dry-run.sh | 34 + build/secret-scan.sh | 40 + build/validate-package.sh | 27 + changelog.md | 8 +- docs/cbpayments-architecture-plan.md | 662 ++++++++++++ docs/compatibility.md | 18 + docs/configuration.md | 40 + docs/custom-providers.md | 20 + docs/providers.md | 23 + docs/releasing.md | 9 + docs/security.md | 15 + docs/stripe.md | 11 + docs/testing.md | 9 + docs/webhooks.md | 9 + dsl/cbpaymentsDSL.cfc | 34 + helpers/Mixins.cfm | 15 + models/PaymentService.cfc | 713 +++++++++++++ models/contracts/IPaymentProvider.cfc | 11 + models/contracts/Money.cfc | 40 + .../capabilities/ICaptureProvider.cfc | 5 + .../capabilities/ICustomersProvider.cfc | 8 + .../capabilities/IHostedCheckoutProvider.cfc | 7 + .../capabilities/IPaymentIntentsProvider.cfc | 12 + .../capabilities/IRefundsProvider.cfc | 6 + .../capabilities/ISetupIntentsProvider.cfc | 7 + .../capabilities/IWebhookProvider.cfc | 9 + .../requests/AbstractPaymentRequest.cfc | 35 + models/contracts/requests/CaptureRequest.cfc | 31 + models/contracts/requests/CustomerRequest.cfc | 31 + .../requests/HostedCheckoutRequest.cfc | 43 + .../requests/PaymentIntentRequest.cfc | 39 + models/contracts/requests/RefundRequest.cfc | 38 + .../contracts/requests/SetupIntentRequest.cfc | 31 + models/contracts/results/ClientAction.cfc | 23 + models/contracts/results/PaymentEvent.cfc | 77 ++ models/contracts/results/PaymentFailure.cfc | 49 + models/contracts/results/PaymentResult.cfc | 93 ++ models/providers/AbstractPaymentProvider.cfc | 208 ++++ models/providers/InMemoryProvider.cfc | 386 +++++++ models/providers/NullProvider.cfc | 52 + models/providers/StripeProvider.cfc | 961 ++++++++++++++++++ models/testing/ProviderContract.cfc | 89 ++ models/util/CurrencyMetadata.cfc | 33 + models/util/Redactor.cfc | 116 +++ models/util/SecurityValidator.cfc | 108 ++ readme.md | 153 ++- server-adobe@2018.json | 23 - server-adobe@2021.json | 29 - server-adobe@2023.json | 19 +- server-adobe@2025.json | 30 + server-boxlang-cfml@1.json | 32 + server-boxlang@1.json | 29 + server-lucee@5.json | 23 - server-lucee@6.json | 18 +- server-lucee@7.json | 27 + test-harness/Application.cfc | 19 +- test-harness/box.json | 12 +- test-harness/config/Coldbox.cfc | 21 +- test-harness/index.cfm | 7 - test-harness/layouts/Main.cfm | 2 +- .../cbpayments-fixture/ModuleConfig.cfc | 26 + .../models/FixtureClient.cfc | 12 + .../models/FixtureProvider.cfc | 10 + test-harness/tests/Application.cfc | 85 +- .../tests/resources/CountingProvider.cfc | 25 + .../tests/resources/FakeStripeClient.cfc | 85 ++ .../tests/resources/FakeStripeResource.cfc | 17 + .../tests/resources/FakeStripeWebhooks.cfc | 28 + .../IncompleteCapabilityProvider.cfc | 10 + .../tests/resources/InvalidProvider.cfc | 7 + .../tests/resources/RecordingInterceptor.cfc | 16 + test-harness/tests/resources/coolblog.sql | 473 --------- test-harness/tests/specs/ModuleSpec.cfc | 24 - .../integration/ModuleIntegrationSpec.cfc | 151 +++ .../tests/specs/live/StripeLiveSpec.cfc | 140 +++ .../tests/specs/unit/ContractsSpec.cfc | 115 +++ .../tests/specs/unit/InMemoryProviderSpec.cfc | 129 +++ .../tests/specs/unit/ObservabilitySpec.cfc | 92 ++ .../tests/specs/unit/PaymentServiceSpec.cfc | 206 ++++ .../tests/specs/unit/ProviderContractSpec.cfc | 37 + .../tests/specs/unit/RedactorSpec.cfc | 45 + .../tests/specs/unit/StripeProviderSpec.cfc | 535 ++++++++++ .../tests/specs/unit/StripeWebhookSpec.cfc | 206 ++++ test-harness/views/main/index.cfm | 4 +- 110 files changed, 7453 insertions(+), 1203 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/cron.yml create mode 100644 .github/workflows/live-stripe.yml create mode 100644 AGENTS.md create mode 100644 LICENSE delete mode 100644 build/SetupTemplate.cfc create mode 100644 build/package-smoke-harness/Application.cfc create mode 100644 build/package-smoke-harness/box.json create mode 100644 build/package-smoke-harness/config/Coldbox.cfc create mode 100644 build/package-smoke-harness/config/Router.cfc create mode 100644 build/package-smoke-harness/handlers/Main.cfc create mode 100644 build/package-smoke-harness/index.cfm create mode 100644 build/package-smoke-harness/server.json create mode 100755 build/package-smoke.sh create mode 100755 build/release-dry-run.sh create mode 100755 build/secret-scan.sh create mode 100755 build/validate-package.sh create mode 100644 docs/cbpayments-architecture-plan.md create mode 100644 docs/compatibility.md create mode 100644 docs/configuration.md create mode 100644 docs/custom-providers.md create mode 100644 docs/providers.md create mode 100644 docs/releasing.md create mode 100644 docs/security.md create mode 100644 docs/stripe.md create mode 100644 docs/testing.md create mode 100644 docs/webhooks.md create mode 100644 dsl/cbpaymentsDSL.cfc create mode 100644 helpers/Mixins.cfm create mode 100644 models/PaymentService.cfc create mode 100644 models/contracts/IPaymentProvider.cfc create mode 100644 models/contracts/Money.cfc create mode 100644 models/contracts/capabilities/ICaptureProvider.cfc create mode 100644 models/contracts/capabilities/ICustomersProvider.cfc create mode 100644 models/contracts/capabilities/IHostedCheckoutProvider.cfc create mode 100644 models/contracts/capabilities/IPaymentIntentsProvider.cfc create mode 100644 models/contracts/capabilities/IRefundsProvider.cfc create mode 100644 models/contracts/capabilities/ISetupIntentsProvider.cfc create mode 100644 models/contracts/capabilities/IWebhookProvider.cfc create mode 100644 models/contracts/requests/AbstractPaymentRequest.cfc create mode 100644 models/contracts/requests/CaptureRequest.cfc create mode 100644 models/contracts/requests/CustomerRequest.cfc create mode 100644 models/contracts/requests/HostedCheckoutRequest.cfc create mode 100644 models/contracts/requests/PaymentIntentRequest.cfc create mode 100644 models/contracts/requests/RefundRequest.cfc create mode 100644 models/contracts/requests/SetupIntentRequest.cfc create mode 100644 models/contracts/results/ClientAction.cfc create mode 100644 models/contracts/results/PaymentEvent.cfc create mode 100644 models/contracts/results/PaymentFailure.cfc create mode 100644 models/contracts/results/PaymentResult.cfc create mode 100644 models/providers/AbstractPaymentProvider.cfc create mode 100644 models/providers/InMemoryProvider.cfc create mode 100644 models/providers/NullProvider.cfc create mode 100644 models/providers/StripeProvider.cfc create mode 100644 models/testing/ProviderContract.cfc create mode 100644 models/util/CurrencyMetadata.cfc create mode 100644 models/util/Redactor.cfc create mode 100644 models/util/SecurityValidator.cfc delete mode 100644 server-adobe@2018.json delete mode 100644 server-adobe@2021.json create mode 100644 server-adobe@2025.json create mode 100644 server-boxlang-cfml@1.json create mode 100644 server-boxlang@1.json delete mode 100644 server-lucee@5.json create mode 100644 server-lucee@7.json create mode 100644 test-harness/modules/cbpayments-fixture/ModuleConfig.cfc create mode 100644 test-harness/modules/cbpayments-fixture/models/FixtureClient.cfc create mode 100644 test-harness/modules/cbpayments-fixture/models/FixtureProvider.cfc create mode 100644 test-harness/tests/resources/CountingProvider.cfc create mode 100644 test-harness/tests/resources/FakeStripeClient.cfc create mode 100644 test-harness/tests/resources/FakeStripeResource.cfc create mode 100644 test-harness/tests/resources/FakeStripeWebhooks.cfc create mode 100644 test-harness/tests/resources/IncompleteCapabilityProvider.cfc create mode 100644 test-harness/tests/resources/InvalidProvider.cfc create mode 100644 test-harness/tests/resources/RecordingInterceptor.cfc delete mode 100644 test-harness/tests/resources/coolblog.sql delete mode 100644 test-harness/tests/specs/ModuleSpec.cfc create mode 100644 test-harness/tests/specs/integration/ModuleIntegrationSpec.cfc create mode 100644 test-harness/tests/specs/live/StripeLiveSpec.cfc create mode 100644 test-harness/tests/specs/unit/ContractsSpec.cfc create mode 100644 test-harness/tests/specs/unit/InMemoryProviderSpec.cfc create mode 100644 test-harness/tests/specs/unit/ObservabilitySpec.cfc create mode 100644 test-harness/tests/specs/unit/PaymentServiceSpec.cfc create mode 100644 test-harness/tests/specs/unit/ProviderContractSpec.cfc create mode 100644 test-harness/tests/specs/unit/RedactorSpec.cfc create mode 100644 test-harness/tests/specs/unit/StripeProviderSpec.cfc create mode 100644 test-harness/tests/specs/unit/StripeWebhookSpec.cfc 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..75dd580 --- /dev/null +++ b/.github/workflows/live-stripe.yml @@ -0,0 +1,51 @@ +name: Live Stripe contract + +on: + schedule: + - cron: "0 9 * * 2" + workflow_dispatch: + +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 a4ed296..09f54e4 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1,30 +1,56 @@ -name: Pull Requests +name: Pull requests on: + pull_request: push: branches-ignore: - - "main" - - "master" - - "development" - - "releases/v*" - pull_request: - branches: - - "releases/v*" + - main - development + - "releases/v*" + +permissions: + 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 + quality-and-package: + name: Quality and packaged-consumer gate + runs-on: ubuntu-latest + timeout-minutes: 20 steps: - - name: Checkout Repository - uses: actions/checkout@v4 - - - uses: Ortus-Solutions/commandbox-action@v1.0.2 + - 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' + git diff --check + - name: Build release-shaped artifacts + run: box run-script build:module + - name: Audit and boot the packaged module + run: | + archive=$(find .artifacts/cbpayments -name 'cbpayments-*.zip' ! -name '*-docs-*' -type f | head -1) + build/release-dry-run.sh "$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 35214ae..0c30003 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,175 +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 -env: - MODULE_ID: @MODULE_SLUG@ - SNAPSHOT: ${{ inputs.snapshot || false }} +permissions: + contents: read -jobs: - ########################################################################################## - # Build & Publish - ########################################################################################## - build: - name: Build & Publish - runs-on: ubuntu-20.04 - steps: - - name: Checkout Repository - uses: actions/checkout@v4 +concurrency: + group: publish-${{ github.ref }} + cancel-in-progress: false - - name: Setup CommandBox - uses: Ortus-Solutions/setup-commandbox@v2.0.1 - with: - forgeboxAPIKey: ${{ secrets.FORGEBOX_TOKEN }} +jobs: + 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' + 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 98cef2e..0eeb05f 100644 --- a/.github/workflows/snapshot.yml +++ b/.github/workflows/snapshot.yml @@ -1,48 +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 + 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 bbb8ed5..9f72178 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,129 +1,82 @@ -name: Test Suites +name: Test suites -# We are a reusable Workflow only on: workflow_call: - secrets: - 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: [ "^6.0.0", "^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 - steps: - - name: Checkout Repository - uses: actions/checkout@v4 - - # - name: Setup Database and Fixtures - # run: | - # sudo systemctl start mysql.service - # mysql -u${{ env.DB_USER }} -p${{ env.DB_PASSWORD }} -e 'CREATE DATABASE mementifier;' - # mysql -u${{ env.DB_USER }} -p${{ env.DB_PASSWORD }} < test-harness/tests/resources/coolblog.sql - - 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 - - # Not Needed in this module - #- name: Setup Environment For Testing Process - # run: | - # # Setup .env - # touch .env - # # ENV - # printf "DB_HOST=localhost\n" >> .env - # printf "DB_DATABASE=mydatabase\n" >> .env - # printf "DB_DRIVER=MySQL\n" >> .env - # printf "DB_USER=${{ env.DB_USER }}\n" >> .env - # printf "DB_PASSWORD=${{ env.DB_PASSWORD }}\n" >> .env - # printf "DB_CLASS=com.mysql.cj.jdbc.Driver\n" >> .env - # printf "DB_BUNDLEVERSION=8.0.19\n" >> .env - # printf "DB_BUNDLENAME=com.mysql.cj\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 install cd test-harness - box package set dependencies.coldbox=${{ matrix.coldboxVersion }} + box package set dependencies.coldbox='${{ matrix.coldbox-version }}' box install - - - name: Start ${{ matrix.cfengine }} Server + - 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..3f6865c 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,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..3e62c9b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,36 @@ +# cbpayments development guide + +## Architecture boundaries + +- `PaymentService@cbpayments` owns provider-type registration, named provider definitions, lazy 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/` +- 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/CONTRIBUTING.md b/CONTRIBUTING.md index 46c40a6..f032446 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing Guide -Hola amigo! I'm really excited that you are interested in contributing to @MODULE_NAME@. Before submitting your contribution, please make sure to take a moment and read through the following guidelines: +Hola amigo! I'm really excited that you are interested in contributing to cbpayments. Before submitting your contribution, please make sure to take a moment and read through the following guidelines: - [Code Of Conduct](#code-of-conduct) - [Bug Reporting](#bug-reporting) @@ -98,11 +98,11 @@ You can support ColdBox and all of our Open Source initiatives at Ortus Solution ## Contributors -Thank you to all the people who have already contributed to @MODULE_NAME@! We :heart: :heart: :heart: love you! +Thank you to all the people who have already contributed to cbpayments! We :heart: :heart: :heart: love you! - - + + Made with [contributors-img](https://contrib.rocks) 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 92257e3..34cd6b3 100644 --- a/ModuleConfig.cfc +++ b/ModuleConfig.cfc @@ -1,47 +1,78 @@ /** - * Copyright Since 2005 ColdBox Framework by Luis Majano and Ortus Solutions, Corp - * www.ortussolutions.com - * --- + * cbpayments ColdBox module. */ component { - // Module Properties - this.title = "@MODULE_NAME@"; - this.author = "Ortus Solutions"; - this.webURL = "https://www.ortussolutions.com"; - this.description = "@MODULE_DESCRIPTION@"; - 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 services 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 = "@MODULE_SLUG@"; - - // CF Mapping - this.cfmapping = "@MODULE_SLUG@"; - - // Dependencies - this.dependencies = []; - - /** - * Configure Module - */ function configure(){ settings = { + "defaultProvider" : "default", + "providers" : { "default" : { "provider" : "Null", "properties" : {} } }, + "providerTypes" : {}, + "webhooks" : { "toleranceSeconds" : 300 }, + "logging" : { "includeProviderRequestIds" : true } + }; + interceptorSettings = { + customInterceptionPoints : [ + "cbpaymentsOnProviderStart", + "cbpaymentsOnProviderShutdown", + "cbpaymentsPreOperation", + "cbpaymentsPostOperation", + "cbpaymentsOnOperationFailure", + "cbpaymentsOnWebhookVerified", + "cbpaymentsOnWebhookRejected" + ] }; + + wirebox.registerDSL( "cbpayments", "#moduleMapping#.dsl.cbpaymentsDSL" ); } - /** - * Fired when the module is registered and activated. - */ function onLoad(){ + var paymentService = wirebox.getInstance( "PaymentService@cbpayments" ); + paymentService + .validateSettings() + .registerProviderType( + "InMemory", + "InMemoryProvider@cbpayments", + "cbpayments" + ) + .registerProviderType( + "Null", + "NullProvider@cbpayments", + "cbpayments" + ) + .registerProviderType( + "Stripe", + "StripeProvider@cbpayments", + "cbpayments" + ) + .registerAppProviderTypes() + .registerAppProviders() + .validateDefaultProvider(); + } + function afterAspectsLoad( event, interceptData, rc, prc, buffer ){ + wirebox.getInstance( "PaymentService@cbpayments" ).registerModuleContributions(); } - /** - * Fired when the module is unregistered and unloaded - */ - function onUnload(){ + function onColdBoxShutdown( event, interceptData, rc, prc, buffer ){ + wirebox.getInstance( "PaymentService@cbpayments" ).shutdown(); + } + function onUnload(){ + if ( !isNull( wirebox ) ) { + wirebox.getInstance( "PaymentService@cbpayments" ).reset(); + } } } diff --git a/box.json b/box.json index 47146f3..52c86b5 100644 --- a/box.json +++ b/box.json @@ -1,28 +1,28 @@ { - "name" : "@MODULE_NAME@", + "name" : "cbpayments", "version" : "1.0.0", - "location" : "https://downloads.ortussolutions.com/ortussolutions/coldbox-modules/@MODULE_SLUG@/@build.version@/@MODULE_SLUG@-@build.version@.zip", + "location" : "https://downloads.ortussolutions.com/ortussolutions/coldbox-modules/cbpayments/@build.version@/cbpayments-@build.version@.zip", "author" : "Ortus Solutions ", - "homepage" : "https://github.com/coldbox-modules/@MODULE_SLUG@", - "documentation" : "https://github.com/coldbox-modules/@MODULE_SLUG@", - "repository" : { "type" : "git", "url" : "https://github.com/coldbox-modules/@MODULE_SLUG@" }, - "bugs" : "https://github.com/coldbox-modules/@MODULE_SLUG@", - "shortDescription" : "Description goes here", - "slug" : "@MODULE_SLUG@", + "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 services for ColdBox applications", + "slug" : "cbpayments", "type" : "modules", - "keywords":"", + "keywords":"payments,stripe,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-cfformat":"*", - "commandbox-docbox":"*", - "commandbox-dotenv":"*", - "commandbox-cfconfig":"*" + "commandbox-boxlang":"1.22.0", + "commandbox-cfformat":"0.21.0", + "commandbox-docbox":"2.5.0+5" }, "ignore":[ "**/.*", @@ -30,25 +30,39 @@ "/server*.json" ], "scripts":{ - "setupTemplate": "task run taskFile=build/SetupTemplate.cfc", "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 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" + "format":"cfformat run dsl,helpers,models,test-harness/modules,test-harness/tests,build/package-smoke-harness,ModuleConfig.cfc --overwrite", + "format:watch":"cfformat watch dsl,helpers,models,test-harness/modules,test-harness/tests,build/package-smoke-harness,ModuleConfig.cfc ./.cfformat.json", + "format:check":"cfformat check dsl,helpers,models,test-harness/modules,test-harness/tests,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":{ "runner":"http://localhost:60299/tests/runner.cfm" - } + }, + "installPaths":{ + "stripecfml":"modules/stripecfml/" + } } diff --git a/build/Build.cfc b/build/Build.cfc index b15a671..0708398 100644 --- a/build/Build.cfc +++ b/build/Build.cfc @@ -19,6 +19,7 @@ component { // Source Excludes Not Added to final binary variables.excludes = [ "build", + "modules", "node-modules", "resources", "test-harness", @@ -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 ); @@ -167,7 +173,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(); @@ -236,16 +242,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(); diff --git a/build/SetupTemplate.cfc b/build/SetupTemplate.cfc deleted file mode 100644 index ca18f24..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 = "@MODULE_NAME@", - replacement = moduleName - ) - .run(); - - command( "tokenReplace" ) - .params( - path = "/#variables.cwd#/**", - token = "@MODULE_SLUG@", - replacement = moduleSlug - ) - .run(); - - command( "tokenReplace" ) - .params( - path = "/#variables.cwd#/**", - token = "@MODULE_DESCRIPTION@", - 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-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..3616475 --- /dev/null +++ b/build/package-smoke-harness/config/Coldbox.cfc @@ -0,0 +1,18 @@ +component { + + function configure(){ + coldbox = { + appName : "cbpayments package smoke", + reinitPassword : "", + handlerCaching : false, + eventCaching : false + }; + moduleSettings = { + cbpayments : { + defaultProvider : "memory", + providers : { memory : { provider : "InMemory", properties : {} } } + } + }; + } + +} 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..46f324b --- /dev/null +++ b/build/package-smoke-harness/handlers/Main.cfc @@ -0,0 +1,18 @@ +component { + + property name="paymentService" inject="PaymentService@cbpayments"; + + function index( event, rc, prc ){ + var provider = paymentService.defaultProvider(); + + return event.renderData( + type = "json", + data = { + status : "cbpayments-package-smoke-ok", + providerType : provider.getProviderType(), + capabilities : paymentService.capabilities( "memory" ) + } + ); + } + +} 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..dbc3352 --- /dev/null +++ b/build/package-smoke.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash + +set -euo pipefail + +project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +archive="${1:-}" + +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/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)(/|$)' <<<"${archive_listing}"; then + echo "Package contains a forbidden development directory." >&2 + exit 1 +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 + box server start serverConfigFile=server.json --noSaveSettings >"${smoke_log}" 2>&1 +) +server_started=true + +for _attempt in $(seq 1 60); do + if response="$(curl --fail --silent --show-error 'http://127.0.0.1:60301/?fwreinit=1' 2>>"${smoke_log}")"; then + if grep -Fq 'cbpayments-package-smoke-ok' <<<"${response}"; then + echo "Packaged cbpayments 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..ada567b --- /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,}|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 Stripe-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 Stripe-shaped secret was found in the release archive." >&2 + exit 1 + fi +fi + +echo "No Stripe-shaped secrets found." diff --git a/build/validate-package.sh b/build/validate-package.sh new file mode 100755 index 0000000..773d70f --- /dev/null +++ b/build/validate-package.sh @@ -0,0 +1,27 @@ +#!/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 + +test "$(box package show slug)" = "cbpayments" +test "$(box package show type)" = "modules" + +echo "Package metadata is valid and dependency pins are exact." diff --git a/changelog.md b/changelog.md index 46f833a..a02312b 100644 --- a/changelog.md +++ b/changelog.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [1.0.0] => 2021-JAN-01 +### Added -* First iteration of this module +- Named default and custom payment providers with lazy lifecycle, ownership, module contributions, and WireBox DSL access. +- Normalized Money, request, result, failure, client-action, and webhook-event contracts. +- InMemory, Null, and isolated Stripe providers for Checkout Sessions, Payment Intents, capture, refunds, Setup Intents, customers, and signed webhooks. +- 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, documentation, and release recovery runbook. diff --git a/docs/cbpayments-architecture-plan.md b/docs/cbpayments-architecture-plan.md new file mode 100644 index 0000000..f406564 --- /dev/null +++ b/docs/cbpayments-architecture-plan.md @@ -0,0 +1,662 @@ +# cbpayments architecture and delivery plan + +Status: Phases 0–5 implemented locally; feature PR to the existing `development` branch, live Stripe CI, and Phase 6 publication pending + +Plan date: 2026-09-04 + +Target repository: `coldbox-modules/cbpayments` + +Working branch: `feature/named-payment-providers` + +## 1. Purpose + +`cbpayments` will be a provider-neutral ColdBox payment module. It will give an application one stable service for default and named payment providers while allowing each provider instance to use separate credentials, accounts, API versions, webhook secrets, and environments. Installed ColdBox modules may contribute new provider types without changing or submitting code to the cbpayments repository. + +The intended analogy is the rest of the ColdBox ecosystem: + +- CBFS provides named disks over interchangeable storage providers. +- cbMailServices provides named mailers over interchangeable delivery protocols. +- `cbpayments` provides named payment providers over processor-specific protocol libraries. + +This is not a Stripe-specific Cashier clone. It is infrastructure like mail, cache, or storage. Applications retain their own customers, invoices, balances, subscriptions, entitlements, accounting rules, authorization, and durable payment ledger. + +## 2. Decisions already made + +1. Preserve the existing official `coldbox-modules/cbpayments` repository, ForgeBox slug, and package identity. +2. Build from the clean `main`/current module-template baseline on `feature/named-payment-providers` rather than extending the unreleased 2024 `development` implementation. +3. Use a CBFS-style registry with one default provider and any number of named provider definitions. +4. Permit multiple named instances of the same provider type. Two Stripe definitions must produce isolated Stripe clients and may point to different Stripe accounts or test/live environments. +5. Combine CBFS's lazy registry/lifecycle with cbMailServices' approachable service façade, named protocol configuration, custom protocols, and in-memory/null testing implementations. +6. Use small capability contracts instead of requiring every processor to implement one oversized interface. +7. Define a public provider-type service-provider interface so separately installed first-party or community ColdBox modules can register aliases such as `AuthorizeNet` and be selected from ordinary cbpayments configuration. +8. First-party and third-party providers use the same registration, lifecycle, capability, result, security, and contract-test APIs. The core service contains no provider-name conditionals or private extension hooks. +9. Ship a first-party Stripe provider first, backed by the maintained `stripecfml` package. As of this plan, ForgeBox reports `stripecfml` 4.1.0 as current; implementation must re-verify and pin the current tested release. +10. Prefer a mature, actively maintained API library inside each provider adapter instead of reimplementing that provider's HTTP API in cbpayments. The adapter owns normalization; the library owns protocol transport. +11. Prefer hosted Checkout Sessions for ordinary one-time web payments. Expose Payment Intents for off-session or independently modeled payment state, Setup Intents for saving methods, and Stripe Billing plus Checkout for subscriptions. Do not build new work on Charges, Sources, Tokens, legacy Card Element, or manual recurring-payment loops. +12. Keep raw processor SDKs available only through an explicit provider escape hatch. Normal application behavior uses normalized requests and results. +13. Treat webhook verification as a provider responsibility and durable event storage/business processing as an application responsibility. + +## 3. Source baseline and lessons + +This plan was checked against these source snapshots on 2026-09-04: + +| Source | Revision | Adopt | Deliberate divergence | +| --- | --- | --- | --- | +| `coldbox-modules/cbfs` | `cf9c882` | Named definitions, default lookup, dynamic registration, lazy thread-safe construction, lifecycle shutdown, core/custom provider resolution, optional module-owned registrations, injection DSL | Payment providers expose capabilities rather than one broad storage-style interface | +| `coldbox-modules/cbmailservices` `development` | `037041b` | Default/named protocols, application-friendly service façade, custom protocols, interception points, in-memory/null protocols, current ColdBox 8 engine matrix | Provider instances should be lazy; results and logs must be more strongly normalized and redacted for payment data | +| `coldbox-modules/module-template` | `b610c6f` | Repository layout, test harness, formatting, daily/PR/snapshot/release workflow separation, build task, API-doc and binary publication | Harden action pinning, package smoke tests, stable-release gates, and post-publication verification | +| `cbpayments` `main` | `91c0f8f` | Official repository identity | Replace untouched template content with the current module template before implementation | +| `cbpayments` legacy `development` | `626c436` | Research material and candidate Stripe mapping tests only | Do not inherit the singleton Stripe injection, 19-method processor interface, empty providers, raw `{ error, content }` response, deprecated Charges-centric flow, or unfiltered debug logging | + +The legacy branch is not a compatibility contract because it was never released. Reuse from it requires a current API check and a new contract test. + +## 4. Repository and branch bootstrap + +Phase 0 will make the repository look and behave like a current ColdBox module before adding payment behavior. + +- Apply the current `coldbox-modules/module-template` structure and substitute real cbpayments metadata. +- Keep the Apache 2 license, code of conduct, security policy, contribution guide, issue/PR templates, editor configuration, formatter/linter settings, server descriptors, test harness, build tasks, and standard scripts. +- Use `cbpayments` as the module name, model namespace, CF mapping, ForgeBox slug, artifact basename, API-doc path, and WireBox namespace. +- Keep `main` as the stable-release branch and the existing `development` branch as the snapshot/integration branch. +- Deliver the new implementation through a normal feature PR into `development`; do not archive, recreate, or rewrite `development` history. +- Work and PR checks remain on `feature/named-payment-providers`; no release workflow may publish this branch. +- Add an `AGENTS.md` describing the service/provider boundaries, supported commands, security rules, and files that own each contract. + +Bootstrap acceptance: + +- a clean install starts the test harness on every required engine; +- formatting is idempotent and `git diff --check` passes; +- package metadata validates; +- the built ZIP installs into a separate clean ColdBox harness and loads `PaymentService@cbpayments`; +- no template tokens or unrelated sample code remain; +- no snapshot or stable package is published prematurely. + +## 5. Public vocabulary and configuration + +Use **provider** for a configured payment backend and **provider type** for its implementation. Avoid `processor` in the public API because payment service providers expose more than transaction processing. + +```boxlang +moduleSettings.cbpayments = { + "defaultProvider" : "primaryReceivables", + "providers" : { + "primaryReceivables" : { + "provider" : "Stripe", + "properties" : { + "apiKey" : getSystemSetting( "PRIMARY_STRIPE_API_KEY" ), + "webhookSecrets" : [ getSystemSetting( "PRIMARY_STRIPE_WEBHOOK_SECRET" ) ], + "apiVersion" : "2026-02-25.clover", + "defaultCurrency" : "usd", + "connectAccount" : "" + } + }, + "secondaryReceivables" : { + "provider" : "Stripe", + "properties" : { + "apiKey" : getSystemSetting( "SECONDARY_STRIPE_API_KEY" ), + "webhookSecrets" : [ getSystemSetting( "SECONDARY_STRIPE_WEBHOOK_SECRET" ) ], + "apiVersion" : "2026-02-25.clover", + "defaultCurrency" : "usd" + } + } + }, + "webhooks" : { + "toleranceSeconds" : 300 + }, + "logging" : { + "includeProviderRequestIds" : true + } +}; +``` + +Configuration rules: + +- `defaultProvider` must name a registered definition or module startup fails with a typed configuration exception. +- A provider definition requires `provider` and may contain `properties`; unknown top-level keys fail validation. +- Registered provider aliases such as `Stripe`, `AuthorizeNet`, `InMemory`, and `Null` resolve through the provider-type registry. A fully qualified class or WireBox ID remains an escape hatch for an unaliased custom provider. +- Duplicate names fail by default. Runtime registration requires an explicit `override=true` and shuts down an existing instantiated provider before replacement. +- Provider names are case-insensitive for lookup but preserve their configured display spelling. +- Application provider names are global. A dependent ColdBox module may declare `settings.cbpayments.providers`, which are registered as `name@ModuleName`; any requested `globalProviders` require explicit opt-in and obey duplicate-name failure. +- Secrets are resolved at application startup and passed only to the selected provider. Registry inspection never returns secret-bearing properties. +- The same provider type may be configured repeatedly; instances, credentials, defaults, webhook secrets, and SDK clients may not bleed between names. + +An installable provider module contributes a provider type; the consuming application still supplies its own named instances and credentials. For example, a community `cbpayments-authorizenet` module can declare: + +```boxlang +component { + this.dependencies = [ "cbpayments" ]; + + function configure() { + settings.cbpayments = { + "providerTypes" : { + "AuthorizeNet" : { + "provider" : "AuthorizeNetProvider@cbpayments-authorizenet" + } + } + }; + } +} +``` + +After installing that module, the application uses the alias in normal cbpayments configuration: + +```bash +box install cbpayments-authorizenet +``` + +```boxlang +moduleSettings.cbpayments.providers.authorizeNetReceivables = { + "provider" : "AuthorizeNet", + "properties" : { + "apiLoginId" : getSystemSetting( "AUTHORIZENET_API_LOGIN_ID" ), + "transactionKey" : getSystemSetting( "AUTHORIZENET_TRANSACTION_KEY" ), + "environment" : "production" + } +}; +``` + +## 6. Core architecture + +### 6.1 `PaymentService@cbpayments` + +The singleton, thread-safe service owns two related registries: + +- the **provider-type registry** maps implementation aliases to provider classes or WireBox IDs and records the contributing module; +- the **configured-provider registry** maps application-defined instance names to a provider type, properties, and a lazily created instance. + +Its public registry API follows CBFS naming closely: + +```boxlang +paymentService.defaultProvider() +paymentService.provider( "primaryReceivables" ) +paymentService.register( name, provider, properties = {}, override = false ) +paymentService.unregister( name ) +paymentService.has( name ) +paymentService.missing( name ) +paymentService.names() +paymentService.count() +paymentService.supports( name, capability ) +paymentService.capabilities( name ) +paymentService.registerProviderType( name, provider, owner = "application", override = false ) +paymentService.unregisterProviderType( name, owner ) +paymentService.hasProviderType( name ) +paymentService.providerTypeNames() +paymentService.shutdown() +``` + +`provider(name)` resolves its provider alias through the type registry, lazily constructs once under a name-specific lock, validates the base contract, calls `startup(name, properties)`, caches the instance, and returns it. `unregister()` and module shutdown call the provider's `shutdown()` only when it was instantiated. + +Provider-type records contain only construction metadata: alias, class/WireBox ID, owner module, extension version, and optional declared capabilities. They never contain application credentials. Registration collisions fail with both owners identified; `override=true` is reserved for explicit application/test configuration and may not silently replace an installed provider module. + +The service also offers thin convenience operations that select a provider by name or default and delegate to the corresponding capability. It does not contain processor-specific conditionals. + +### 6.2 WireBox and helper access + +- Canonical service ID: `PaymentService@cbpayments`. +- Custom DSL: `cbpayments:` injects one named provider; `cbpayments` or `cbpayments:default` injects the default provider. +- A minimal application helper may expose `getPaymentProvider(name)` and `getDefaultPaymentProvider()`. Avoid adding global helper methods for every payment operation. +- Providers are constructed as transients owned by the registry, even when the underlying protocol library is installed as a ColdBox module. + +### 6.3 Provider base contract + +Every provider implements a small lifecycle/identity contract: + +```boxlang +interface IPaymentProvider { + any function startup( required string name, struct properties = {} ); + any function shutdown(); + string function getName(); + string function getType(); + array function capabilities(); + boolean function supports( required string capability ); + any function getClient(); +} +``` + +`getClient()` is the documented escape hatch. It returns the underlying SDK/client and is intentionally non-portable. Its use should be isolated behind an application adapter and covered by provider-specific tests. + +An `AbstractPaymentProvider` centralizes lifecycle state, property access, safe configuration validation, capability advertisement, redaction helpers, and normalized error creation. It does not supply fake implementations for unsupported operations. + +### 6.4 Installable provider modules + +An external provider is an ordinary ColdBox module installed beside cbpayments. It must: + +- declare `cbpayments` as a module dependency so load order is deterministic; +- declare one or more provider types in `settings.cbpayments.providerTypes`, or use the equivalent registration API for a genuinely dynamic case; +- expose each provider as a WireBox ID or fully qualified class implementing `IPaymentProvider` and its advertised capability interfaces; +- keep its processor-specific API library in its own `box.json` dependencies; +- unregister its owned provider types during module unload through `PaymentService`, which first shuts down configured instances of those types; +- publish its compatibility range for cbpayments, ColdBox, engines, and its upstream API library; +- run the shared cbpayments provider contract kit before release. + +cbpayments discovers declarative provider-type contributions after application modules load and tracks ownership for unload/reinit. Installing a provider module never creates a configured payment account by itself; the application must opt in by naming the provider alias and supplying properties. + +Recommended package conventions: + +- ForgeBox slug/module name: `cbpayments-`; +- WireBox ID: `Provider@cbpayments-`; +- dependency: a compatible released range of `cbpayments`, plus an exact tested upstream API-library version; +- public docs: installation, capabilities, configuration schema, normalized mappings, provider-specific options, webhook setup, upstream provenance, and support policy. + +Official providers may be bundled with cbpayments or released from a `coldbox-modules/cbpayments-` sibling repository. Either form registers through this same service-provider interface. The first-party Stripe provider may ship in the 1.0 distribution for convenience, but it receives no privileged construction path; extracting it later to an official extension must not require changing `provider = "Stripe"` application configuration. + +Community provider aliases are not an endorsement or sandbox. Installed ColdBox modules execute with application privileges. Documentation distinguishes first-party and community support, and cbpayments never downloads provider code dynamically at runtime. + +## 7. Capability contracts + +Capabilities keep portability honest. A provider advertises only what it implements; requesting an absent capability raises `cbpayments.UnsupportedCapability` before any network call. + +| Capability | Initial normalized operations | Initial delivery | +| --- | --- | --- | +| `hostedCheckout` | create, retrieve, expire a hosted checkout session | Required for Stripe 1.0 | +| `paymentIntents` | create, retrieve, confirm when server-side confirmation is valid, cancel | Required for Stripe 1.0 | +| `capture` | capture a previously authorized payment | Required for Stripe 1.0 | +| `refunds` | create and retrieve a full or partial refund | Required for Stripe 1.0 | +| `setupIntents` | create, retrieve, cancel a setup intent | Required for Stripe 1.0 | +| `customers` | create, retrieve, update, delete provider customers | Planned for Stripe 1.0 if required by Setup Intents; otherwise first compatible minor | +| `billing` | create/retrieve/cancel subscriptions through provider billing primitives; expose provider portal/session links where available | Post-1.0 capability epic | +| `webhooks` | verify signature and normalize an event envelope | Required for Stripe 1.0 | + +Each capability is a separate interface under `models/contracts/capabilities/`. Operation-specific request objects prevent a provider-neutral method from accumulating every provider's optional arguments. + +Provider-specific features remain possible through either: + +- an allow-listed `providerOptions` struct on the relevant request, namespaced by provider type; or +- the explicit `getClient()` escape hatch. + +Portable application code must not depend on either. + +## 8. Request, result, money, and error contracts + +### 8.1 Money + +`Money` contains an integer `amountMinor` and lowercase ISO 4217 `currency`. The module never accepts floating-point major-unit amounts in a provider operation. It validates zero-decimal and special currencies through a tested currency metadata table rather than assuming every currency has two decimals. + +### 8.2 Requests + +Requests are operation-specific, validated value objects or documented structs. Common fields are: + +- `money`, when the operation transfers value; +- `idempotencyKey` for every mutating provider call; +- `description` and allow-listed scalar `metadata`; +- `customerId`, `paymentMethodId`, or prior `externalId` only where relevant; +- `returnUrl`/`cancelUrl` for hosted flows; +- `providerOptions` as the explicit non-portable extension point. + +Mutating operations reject an empty idempotency key by default. A caller may explicitly opt out only for a provider operation proven not to support one, and the result reports that the request was not idempotency-protected. + +### 8.3 Results + +All normal operations return a typed `PaymentResult` with a stable serialized shape: + +```boxlang +{ + "ok" : true, + "operation" : "hostedCheckout.create", + "providerName" : "primaryReceivables", + "providerType" : "Stripe", + "status" : "pending", + "externalId" : "...", + "requestId" : "...", + "idempotencyKey" : "...", + "createdAt" : "...", + "amount" : { "amountMinor" : 10000, "currency" : "usd" }, + "nextAction" : { "type" : "redirect", "redirectUrl" : "..." }, + "failure" : {}, + "providerDetails" : {} +} +``` + +The exact fields vary by operation, but the envelope does not. `providerDetails` is created by an adapter-specific allow-list and never contains an unfiltered provider response. + +Normalized statuses include `requires_action`, `pending`, `authorized`, `succeeded`, `failed`, `cancelled`, `partially_refunded`, and `refunded`. Unknown provider states map to `unknown` while retaining only a safe provider status code. + +### 8.4 Failures and exceptions + +- Invalid configuration, invalid local arguments, unknown providers, unsupported capabilities, and programmer errors throw typed `cbpayments.*` exceptions before a provider call. +- Expected provider outcomes return `ok=false` with a failure category such as `declined`, `validation`, `authentication`, `rate_limited`, `network`, `provider`, or `unknown`. +- Failures include a safe code, safe user-neutral message, `retryable`, and an optional decline category. They never include credentials, signatures, tokens, client secrets, raw bodies, stack traces, or full request/response objects. +- Unexpected adapter defects are wrapped in a scrubbed `cbpayments.ProviderException` whose cause is available to trusted diagnostics without being serialized or logged by default. + +## 9. Provider protocol and API libraries + +### 9.1 Reuse policy + +A provider adapter should wrap an existing API library when that library is suitable. Before adoption, record: + +- upstream repository, license, current release, maintenance activity, and release process; +- supported BoxLang/CFML engines and Java versions; +- coverage of the required modern provider APIs, idempotency headers, request IDs, timeouts, and signed webhooks; +- ability to create isolated clients for multiple named configurations; +- testability through an injectable client or transport; +- response/error behavior and whether secrets or bodies are logged; +- the exact version tested by the provider's compatibility matrix. + +The provider adapter translates between cbpayments contracts and that library. It does not copy the upstream client into cbpayments or leak upstream response shapes into normalized results. Upstream upgrades arrive through reviewed dependency PRs and must rerun the adapter, engine, webhook, redaction, and package suites. + +If no acceptable library exists, the provider module may own a narrow HTTP client for only its supported capabilities. That client remains outside cbpayments core, has injectable transport, finite timeouts, idempotency support, redacted diagnostics, fixtures, and a documented maintenance owner. Vendoring or forking an abandoned client into cbpayments is a last resort requiring an explicit maintenance/security decision. + +### 9.2 First-party Stripe provider + +The Stripe adapter lives below the capability contracts and uses `stripecfml` only as transport/API protocol. + +- Pin the exact `stripecfml` release validated by the adapter suite; begin evaluation with 4.1.0. +- Re-verify the current Stripe API version during implementation and pin it in tests and examples. As of this plan the current version is `2026-02-25.clover`. +- Construct one Stripe client per named cbpayments provider. Do not inject the global `stripe@stripecfml` singleton because that would couple all names to one configuration. +- Pass idempotency keys through on every supported mutating request and preserve Stripe request IDs in safe results/log context. +- Use Checkout Sessions for the ordinary on-session/hosted payment path. +- Use Payment Intents only for off-session or independently modeled payment state. +- Use Setup Intents for saving methods. Do not expose Sources or Tokens as normalized capabilities. +- Use Stripe Billing and Checkout for subscription work; do not implement a local renewal loop or legacy Plan-object abstraction. +- Let Stripe select dynamic payment methods unless a documented business/compliance constraint requires an explicit list. +- Support Stripe Connect scoping through provider definition or explicit request context without pretending connected-account identifiers are portable. +- Keep `convertToCents=false`; cbpayments owns integer minor-unit handling. +- Translate Stripe responses through per-operation allow-lists. The old `{ error, content }` wrapper and Charges-centric methods are not carried forward. +- Register the `Stripe` alias through the same provider-type registry used by external modules. A registry integration spec must prove that a separately packaged fixture can contribute a provider type without changing core service code; the packaging boundary may change without changing application configuration. + +## 10. Webhook boundary + +`WebhookProvider` accepts the exact raw request body and relevant headers, verifies the signature before parsing, and returns a normalized `PaymentEvent` envelope: + +- `eventId`, `eventType`, `occurredAt`, `livemode`; +- `providerName`, `providerType`, safe provider account identifier; +- `objectType`, `objectId`, normalized status and amount when known; +- safe, allow-listed provider details; +- a deterministic payload checksum for application-level duplicate detection. + +Rules: + +- Verification accepts an ordered list of active webhook secrets to allow safe key rotation and records only which key index matched. +- Timestamp tolerance is configurable with a secure default and is tested on both sides of the boundary. +- Invalid signatures, malformed bodies, stale timestamps, and provider/account mismatches return distinct typed failures. +- cbpayments does not define a public route, acknowledge HTTP requests, persist events, choose a tenant, mutate an invoice, dispatch jobs, or retry business processing. +- cbpayments never logs or persists the raw body. An application that retains it owns encryption, access control, retention, and redaction. +- Duplicate and out-of-order events are expected. Applications key their inbox by provider name plus external event ID and reconcile against provider state when sequence alone is insufficient. + +## 11. Application/module ownership boundary + +`cbpayments` owns: + +- provider registration, construction, configuration validation, and lifecycle; +- capability discovery and normalized provider operations; +- protocol translation, signature verification, status/error normalization, and safe diagnostic metadata; +- test doubles and provider contract suites. + +The consuming application owns: + +- users, organizations, merchants, tenants, connected-account onboarding, and authorization; +- carts, bookings, invoices, payable/receivable rules, taxes, discounts, deposits, balances, and accounting; +- durable payment attempts, ledger entries, idempotency-key allocation, webhook inbox/outbox, job retries, and reconciliation; +- mapping a named provider to a tenant or business flow; +- emails, receipts, refunds policy, disputes workflow, subscription entitlements, and customer support tooling. + +The application must not hold a database transaction open across a provider network call. It writes durable pending intent first, calls the provider outside the transaction, then idempotently records/reconciles the result. + +## 12. Security and compliance requirements + +- Default documentation and examples keep card entry on provider-hosted Checkout or provider UI components. cbpayments does not accept PAN, CVV, bank credentials, or raw browser payment form data. +- API keys and webhook secrets come from environment/secret management, never repository defaults. Test fixtures use unmistakably fake keys. +- No configuration dump, exception, diagnostic serialization, event announcement, or debug statement may expose API keys, authorization headers, signatures, client secrets, payment tokens, raw webhook bodies, complete provider objects, or sensitive customer fields. +- A Payment Intent client secret, when required by a supported frontend flow, lives in a sensitive `ClientAction` object excluded from default mementos, logs, and interception data. A consumer must explicitly read it and return it only to the authorized intended client. +- A central redactor handles common secret/token patterns plus provider-specific fields. Redaction tests use nested structs, arrays, exceptions, and malformed responses. +- Return/cancel URLs are caller-controlled but validated as absolute HTTPS URLs outside explicitly enabled local development. +- Metadata is bounded in key count/value size and rejects likely secret/card fields. +- Provider HTTP timeouts are finite. Retry policy distinguishes safe/idempotent operations from unsafe ones and honors provider retry guidance. +- Provider dependencies and GitHub Actions are pinned and updated through reviewed PRs. +- Security issues follow the repository security policy; public examples contain no live account identifiers. + +## 13. Observability and interception points + +Publish low-cardinality interception points without sensitive payloads: + +- `cbpaymentsOnProviderStart` +- `cbpaymentsOnProviderShutdown` +- `cbpaymentsPreOperation` +- `cbpaymentsPostOperation` +- `cbpaymentsOnOperationFailure` +- `cbpaymentsOnWebhookVerified` +- `cbpaymentsOnWebhookRejected` + +Event data contains provider name/type, operation, safe request correlation/idempotency key, provider request ID, duration, normalized status, and safe failure category. It does not contain the request object, SDK client, raw provider response, webhook body, headers, or secrets. + +The module emits useful structured logs at appropriate levels but does not ship metrics storage. Applications may translate interception points into their own metrics/traces. + +## 14. First-party test providers + +Ship two provider types alongside Stripe: + +- `InMemory`: implements the supported core capabilities without network I/O, records sanitized requests, lets tests enqueue deterministic successes/failures/events, and supports reset/assertion helpers. +- `Null`: advertises an explicitly small capability set and returns deterministic no-op outcomes for applications that disable payments in selected environments. + +Neither silently behaves like Stripe. The in-memory provider follows the same normalized contracts and becomes the primary consumer-test story, analogous to cbMailServices' in-memory protocol. + +## 15. Test strategy + +### 15.1 Required local and PR tests + +- Registry: default validation, lookup, enumeration, duplicate handling, override, unregister, shutdown, provider-type aliases, custom class/WireBox resolution, module namespacing, ownership, unload/reinit, collisions, and unknown provider errors. +- Concurrency: exactly one lazy instance under concurrent first access; isolated construction locks per provider name; safe shutdown/reinit. +- Isolation: two named Stripe definitions cannot share credentials, defaults, API versions, webhook secrets, mutable client state, or captured test calls. +- Capabilities: correct advertisement, successful dispatch, and pre-network unsupported-capability failures. +- Requests/results: minor-unit validation, currency behavior, serialization, unknown statuses, provider allow-lists, safe failure mapping, and idempotency propagation. +- Security: recursive redaction, safe exceptions/logs/events, URL and metadata validation, no secret fields in serialized results. +- Webhooks: valid signatures, invalid signatures, timestamp limits, rotating secrets, malformed JSON, wrong account, duplicate fixtures, out-of-order fixtures, and stable normalized envelopes. +- InMemory/Null providers: deterministic behavior and consumer assertion helpers. +- Stripe adapter: mocked `stripecfml` client/transport for every operation, status, HTTP failure, timeout, rate limit, decline, malformed response, idempotency header, and request-ID mapping. Required CI never depends on Stripe network availability. +- Integration: a minimal ColdBox application configures default and multiple named providers, injects via service and DSL, executes an operation, verifies a webhook, reinits, and shuts down. +- Extension integration: a fixture ColdBox module declares a new provider alias, is installed without editing cbpayments, supplies an upstream-client test double, becomes selectable through application configuration, and unregisters cleanly on unload. +- Packaging: build the release ZIP, inspect excludes, install it into a new test harness, start the server, and resolve the public service/provider IDs from the packaged artifact. + +### 15.2 Engine matrix + +Required 1.0 matrix, matching the current cbMailServices/ColdBox 8 generation: + +- ColdBox `^8.0.0` on BoxLang 1 native; +- ColdBox `^8.0.0` on BoxLang 1 CFML compatibility; +- ColdBox `^8.0.0` on Lucee 6 and 7; +- ColdBox `^8.0.0` on Adobe ColdFusion 2023 and 2025. + +ColdBox `be` runs on representative engines as allowed-failure experimental jobs. A support claim is made only for required green matrix rows. ColdBox 7 support may be added only with its own required rows; it is not inferred from template ancestry. + +### 15.3 Live provider verification + +A separate secret-bearing `workflow_dispatch` and scheduled job runs a minimal Stripe test-mode contract on one reference engine. It creates only self-cleaning test resources, uses unique run metadata/idempotency keys, never runs for fork PRs, and uploads scrubbed diagnostics. Live-provider failure blocks a release candidate but does not make ordinary PRs flaky. + +## 16. CI and publishing design + +Use the current module-template workflow separation: reusable tests, pull requests, daily tests, development snapshots, and stable releases. Preserve the shared `build/Build.cfc` and `build/release.boxr` conventions, but do not copy stale action pins or weak release behavior without review. + +### 16.1 Pull requests + +Required checks: + +1. formatting check and `git diff --check`; +2. package metadata validation and dependency installation; +3. required TestBox engine matrix with JUnit publication and failure logs; +4. docs build and Markdown validation; +5. built-ZIP content audit and clean-install smoke test; +6. secret-pattern scan over tracked source and the built archive; +7. test result and coverage artifacts, with an agreed threshold after the baseline suite exists. + +PR workflows receive no ForgeBox, AWS, Stripe, or release credentials. + +### 16.2 Daily/scheduled tests + +- Run the required engine matrix daily against locked dependencies. +- Run a second dependency-freshness lane using allowed upgrades to detect upcoming breaks without changing the lock or falsely passing the release lane. +- Run the live Stripe test-mode contract on a less frequent schedule and on demand. +- Open or update one actionable issue for a persistent scheduled failure rather than sending duplicate noise. + +### 16.3 Development snapshots + +On the reconciled `development` branch: + +- run the complete required test, format-check, docs, package-smoke, and security gates; +- build once and publish the exact tested snapshot artifact using the organization snapshot convention; +- upload snapshot binary/API docs only to snapshot destinations; +- never create or move a stable Git tag; +- record source SHA, build number, dependency lock/checksum, and artifact checksum. + +Auto-formatting, if retained from the template, runs before snapshot publication and the formatted commit must be the exact commit rebuilt/tested. Publication may not race an auto-commit. + +### 16.4 Stable release + +A release PR promotes the tested `development` boundary to `main`. A push to `main` may publish only after all required checks pass for the exact release SHA. + +The stable workflow: + +1. checks out the exact SHA with full tag history; +2. sets up Java and CommandBox/BoxLang with reviewed, pinned actions; +3. resolves the semantic version and finalizes the changelog; +4. installs locked dependencies, runs the required tests, builds docs, and repeats package smoke verification; +5. builds the module once and records SHA-256 checksums; +6. uploads the ZIP to `downloads.ortussolutions.com`; +7. uploads versioned API docs to `apidocs.ortussolutions.com`; +8. publishes that same ZIP/package metadata to ForgeBox; +9. creates the immutable `vX.Y.Z` tag without force-moving an existing tag; +10. creates the GitHub release and attaches the ZIP/checksum; +11. verifies the tag SHA, GitHub release/assets, ForgeBox version/download, binary URL/checksum, and API-doc URL; +12. updates the development changelog/version only after stable publication is verified; +13. sends one final success/failure notification containing version, SHA, and verification status. + +Do not mark GitHub release creation `continue-on-error`. Replace actions referenced by mutable branches such as `@master` with pinned reviewed revisions or first-party CLI commands. Use least-privilege job permissions and environment-scoped publication secrets. + +If the repository later adopts CommandBox Semantic Release, use the proven JGit-compatible release checkout/configuration rather than assuming the newest checkout action works: pin the release checkout to `actions/checkout@v4.2.2` and use `NullArtifactsCommitter@commandbox-semantic-release` unless a completed test release proves the incompatibility has been fixed. + +### 16.5 Partial-release recovery + +Publication is multi-system and cannot be treated as atomic. The runbook records which of S3, API docs, ForgeBox, tag, and GitHub release succeeded. A retry must be idempotent, verify existing artifacts and checksums, and complete missing destinations without moving a tag to different source. ForgeBox presence alone is never release proof. + +### 16.6 Provider-module CI + +First-party provider repositories use the same module-template workflow layout and publication verification as cbpayments. Their required matrix includes: + +- the lowest and highest supported released cbpayments versions; +- the declared stable engine/ColdBox rows; +- the shared provider contract kit for every advertised capability; +- upstream client-library compatibility and redaction tests; +- built-package installation beside a released cbpayments artifact; +- an optional secret-bearing live provider smoke test outside fork PRs. + +Community provider authors can consume the same contract kit and reference workflow, but their releases remain independently owned. Core cbpayments CI includes a fixture extension module so the extension API cannot regress unnoticed; it does not attempt to test or certify every community provider. + +## 17. Documentation deliverables + +- README: purpose, installation, minimum versions, five-minute InMemory example, Stripe hosted-checkout example, and link to full docs. +- Configuration: every module/provider setting, named-provider examples, environment/secrets examples, and multiple Stripe account example. +- Provider guide: service/DSL lookup, normalized requests/results, capabilities, idempotency, retries, and escape hatch. +- Stripe guide: Checkout, Payment Intents, Setup Intents, refunds/capture, webhook verification, API-version policy, Connect scoping, and migration away from legacy Charges/Sources usage. +- Webhook guide: raw-body requirement, signature verification, rotation, application inbox/idempotency, replay/out-of-order behavior, and acknowledgment timing. +- Custom provider author guide: base lifecycle, capability interfaces, validation, status/error mapping, redaction, contract test kit, and packaging. +- Provider ecosystem guide: declarative registration, alias ownership/collisions, module load/unload, first-party versus community support, compatibility ranges, API-library selection, and a complete installable example provider. +- Testing guide: InMemory/Null providers, assertions, mocked adapter tests, and optional live Stripe tests. +- Security guide: PCI boundary, forbidden data, logging/redaction, key rotation, incident reporting, and go-live checklist. +- Compatibility matrix and upgrade/migration guide. +- Generated API docs for public components only. + +Every example must compile/run in the test harness or be extracted from a tested fixture so documentation cannot drift independently. + +## 18. Delivery phases and evidence gates + +### Phase 0: clean module baseline and release skeleton + +- Refresh from the current module template and preserve cbpayments identity. +- Establish metadata, scripts, formatting/linting, AGENTS guidance, test harness, build tasks, and non-publishing PR/daily workflows. +- Add package build/install smoke verification. +- Integrate the existing `development` history into the feature branch and deliver through a reviewed PR without rewriting remote history. + +Exit evidence: clean required-engine boot, format idempotence, metadata validation, built-ZIP audit/install, green PR workflow, and no remote publication. + +### Phase 1: registry, lifecycle, and testing providers + +- Implement `PaymentService`, provider-type and configured-provider registries, declarative module contributions, alias ownership/collisions, default/named lookup, lazy concurrency, lifecycle/unload, DSL, module namespacing, custom providers, capabilities, InMemory, and Null. +- Publish the public configuration and provider-author contracts. + +Exit evidence: registry/concurrency/isolation/DI/integration specs across the required matrix, a consumer-style InMemory example, and a separately installed fixture module that contributes and removes a provider type without cbpayments source changes. + +### Phase 2: normalized operation contracts + +- Implement Money, operation requests, PaymentResult/failure taxonomy, status mapping, idempotency rules, provider options, redaction, and safe interception points. +- Add the hosted-checkout, Payment Intent, capture, refund, Setup Intent, and webhook capability interfaces. + +Exit evidence: contract suite proves serialized shapes, unsupported-capability behavior, redaction, idempotency, retry classification, and backward-compatible public signatures. + +### Phase 3: Stripe protocol adapter + +- Pin and wrap the tested `stripecfml` release with one isolated client per named provider, registering `Stripe` through the public provider-type interface. +- Implement hosted Checkout Sessions first, then Payment Intents, capture, refunds, Setup Intents, and any customer calls required by those workflows. +- Add current Stripe API fixtures and the optional live test-mode workflow. + +Exit evidence: mocked adapter contract is green on the full matrix; two named Stripe clients prove isolation; live test-mode checkout/payment/refund smoke is green on the reference engine; deprecated Charges/Sources methods are absent from the normalized API. + +### Phase 4: signed webhooks and reliability + +- Implement exact-raw-body verification, rotating secrets, timestamp tolerance, provider/account validation, normalized event envelopes, safe webhook events/logging, and duplicate/out-of-order fixtures. +- Publish the application inbox/reconciliation integration guide. + +Exit evidence: signature/adversarial fixture suite, log/result secret scan, replay/out-of-order consumer example, and live Stripe test webhook verification. + +### Phase 5: CI publication and release candidate + +- Merge the reviewed feature branch into the existing `development` integration branch. +- Enable snapshots, stable workflow, package/API-doc/ForgeBox/GitHub/S3 publication, checksums, verification, and recovery runbook. +- Complete docs, changelog, migration notes, compatibility table, and provider-author kit. + +Exit evidence: a non-production dry run proves build-once artifact identity and every verification step; the release candidate installs from its ZIP and snapshot coordinates into a clean consumer. + +### Phase 6: first stable release + +- Cut the first stable version only after Phases 0-5 and the live Stripe gate are green for the exact SHA. +- Verify every publication destination and then exercise the documented quickstart from the published ForgeBox package. + +Exit evidence: immutable tag, GitHub release/asset, ForgeBox version, binary/checksum, API docs, completed workflow, and clean external install all resolve to the same source SHA/version. + +### Post-1.0: billing/subscriptions and more providers + +- Add the billing capability with Stripe Billing, Checkout, and Customer Portal primitives. +- Add additional provider types only when a real consumer supplies use cases and test credentials/fixtures. +- Prefer separate installable provider modules so each upstream dependency and release cadence remains isolated from cbpayments core. +- Each provider must pass the shared contract suite; one provider's terminology or object model may not leak into normalized interfaces. + +## 19. CommuniArts integration checkpoint + +CommuniArts can integrate online receivables only after cbpayments Phase 4 is available from a tested package coordinate. Its application work remains separate: + +1. decide Stripe account ownership/onboarding and map each organization or flow to an allowed named provider; +2. add a durable payment-attempt record and idempotency allocation; +3. create hosted checkout outside database transactions through its local `PaymentGateway` adapter; +4. add a public webhook route that passes the exact body/signature to cbpayments, persists a tenant-scoped inbox record, acknowledges promptly, and queues processing; +5. idempotently translate normalized events into the existing inventory payment/receivable ledger and reconcile uncertain/out-of-order states; +6. prove authorization, duplicate delivery, recovery, ledger balance, audit history, email/receipt, and browser flows. + +The inventory architecture plan references this document for module behavior and keeps only those CommuniArts-owned responsibilities. + +## 20. Non-goals for 1.0 + +- A merchant-of-record service, marketplace onboarding product, accounting system, tax engine, or PCI card vault. +- A universal representation of every provider feature. +- Application invoice/customer/subscription/entitlement models. +- Raw card collection or migration. +- Automatic provider failover for a payment attempt. Retrying against a second provider can double-charge and requires explicit application policy. +- Automatic routing by tenant, currency, price, geography, or cost; the application selects the named provider. +- PayPal or Authorize.NET placeholders without complete implementations and contract tests. +- Bundling community provider code or its API dependencies into cbpayments merely to make the alias available. +- Backward compatibility with the unreleased legacy development branch. + +## 21. Definition of done + +`cbpayments` 1.0 is complete only when: + +- default and multiple named providers work through service and DSL access; +- an independently installed fixture provider module can register an alias, be configured normally, pass the shared contract kit, and unload without a cbpayments source change; +- lazy construction, lifecycle, reinit, concurrency, and instance isolation are proven; +- capability-specific APIs and normalized safe results are documented and stable; +- InMemory, Null, and Stripe providers pass their required contract suites; +- Stripe uses current supported Checkout/Intent primitives and a pinned, tested API/library version; +- webhook signatures, rotation, tolerance, duplicates, and out-of-order events are proven; +- no required test, formatting, docs, package, security, or live-provider gate is failing; +- the tested artifact installs cleanly outside the repository; +- no prohibited secret/payment data appears in logs, serialized results, fixtures, docs, or artifacts; +- the immutable tag, GitHub release, ForgeBox version, binary/checksum, API docs, and completed CI run all identify the same version and source SHA; +- the partial-release recovery procedure has been dry-run; +- at least one consumer integration uses only the public normalized API and can switch between InMemory and Stripe by settings. +- provider-specific API clients remain inside their provider implementations, use documented tested dependencies where suitable, and do not leak their raw response contracts into core APIs. diff --git a/docs/compatibility.md b/docs/compatibility.md new file mode 100644 index 0000000..79325ec --- /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 providers, 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..96c0db8 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,40 @@ +# Configuration + +## Module settings + +```boxlang +moduleSettings.cbpayments = { + defaultProvider : "primaryReceivables", + providers : { + primaryReceivables : { + provider : "Stripe", + properties : { + apiKey : getSystemSetting( "PRIMARY_STRIPE_API_KEY" ), + webhookSecrets : [ getSystemSetting( "PRIMARY_STRIPE_WEBHOOK_SECRET" ) ], + apiVersion : "2026-02-25.clover", + defaultCurrency : "usd", + connectAccount : "" + } + }, + secondaryReceivables : { + provider : "Stripe", + properties : { + apiKey : getSystemSetting( "SECONDARY_STRIPE_API_KEY" ), + webhookSecrets : [ getSystemSetting( "SECONDARY_STRIPE_WEBHOOK_SECRET" ) ], + apiVersion : "2026-02-25.clover", + defaultCurrency : "usd" + } + } + }, + webhooks : { toleranceSeconds : 300 }, + logging : { includeProviderRequestIds : true } +}; +``` + +`defaultProvider` must name a configured provider. Names are case-insensitive at lookup and retain their configured spelling. Definitions accept only `provider` and optional `properties`; unknown keys fail startup. + +Built-in aliases are `InMemory`, `Null`, and `Stripe`. A fully qualified component path or WireBox ID may be used as an unaliased custom provider. Duplicate names and aliases fail unless runtime code passes `override=true`; replacement shuts down an instantiated provider first. + +Stripe properties are `apiKey`, `webhookSecrets`, `apiVersion`, `defaultCurrency`, `connectAccount`, and `toleranceSeconds`. `client` exists only for injecting a test double. Values are resolved during startup and never returned by registry inspection. A provider's diagnostic `getProperties()` result is recursively redacted. + +Dependent modules may contribute `settings.cbpayments.providers`; names become `name@ModuleName`. `globalProviders` are deliberately global and therefore collide normally. Provider modules declare `settings.cbpayments.providerTypes` and remain responsible for unregistering their owned aliases on unload. diff --git a/docs/custom-providers.md b/docs/custom-providers.md new file mode 100644 index 0000000..c726499 --- /dev/null +++ b/docs/custom-providers.md @@ -0,0 +1,20 @@ +# Custom provider author guide + +An installable provider is an ordinary ColdBox module depending on `cbpayments`. Declare aliases in module settings: + +```boxlang +this.dependencies = [ "cbpayments" ]; +settings.cbpayments = { + providerTypes : { + AuthorizeNet : { provider : "AuthorizeNetProvider@cbpayments-authorizenet" } + } +}; +``` + +The provider implements `IPaymentProvider` plus only the capability interfaces it advertises. Extend `AbstractPaymentProvider` for lifecycle, safe results, failure construction, redaction, and interception behavior. Keep the processor SDK in the provider module, create one client per named instance, and normalize every response through explicit allow-lists. + +Aliases record their owner. Collisions identify both owners; an extension must unregister its owned alias during unload, which shuts down configured instances of that type. Installing the module never creates credentials or an account definition. + +Run `ProviderContract@cbpayments` (or instantiate `cbpayments.models.testing.ProviderContract`) against every advertised capability, then add request/result, failure, idempotency, redaction, isolation, lifecycle, webhook, engine, and clean-package tests. Publish compatibility ranges for cbpayments, ColdBox, engines, Java, and the exact tested upstream SDK version. + +Community modules execute with application privileges. cbpayments does not download provider code dynamically or imply endorsement. diff --git a/docs/providers.md b/docs/providers.md new file mode 100644 index 0000000..cbcfd4b --- /dev/null +++ b/docs/providers.md @@ -0,0 +1,23 @@ +# Providers and normalized operations + +`PaymentService@cbpayments` exposes registry methods (`provider`, `defaultProvider`, `register`, `unregister`, `has`, `names`, `supports`, and lifecycle methods) plus thin operation delegates. The `cbpayments` DSL resolves the default provider; `cbpayments:name` resolves a named provider. + +Capabilities are independent contracts: + +| Capability | Operations | +| --- | --- | +| `hostedCheckout` | create, retrieve, expire | +| `paymentIntents` | create, retrieve, confirm, cancel | +| `capture` | capture an authorized Payment Intent | +| `refunds` | create and retrieve full/partial refunds | +| `setupIntents` | create, retrieve, cancel | +| `customers` | create, retrieve, update, delete | +| `webhooks` | verify and normalize signed events | + +Unsupported capabilities throw `cbpayments.UnsupportedCapability` before a network call. + +Every mutating request requires an idempotency key. `Money` accepts only integer minor units and validated lowercase ISO 4217 currency codes. Metadata is scalar, bounded, and rejects secret/card-like keys. Provider-specific options must be nested under the provider type and allow-listed by the adapter. + +`PaymentResult.getMemento()` produces the stable public envelope: outcome, operation, provider identity, normalized status, external/request IDs, idempotency key, timestamp, amount, safe next action, safe failure, and allow-listed provider details. Unknown provider statuses normalize to `unknown`. Client secrets live in a separate `ClientAction` available only from the result object and never in its default memento. + +Expected remote outcomes use `ok=false` and the categories `declined`, `validation`, `authentication`, `rate_limited`, `network`, `provider`, or `unknown`. Configuration/programmer errors throw typed `cbpayments.*` exceptions. diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..a881b4e --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,9 @@ +# Release and partial-recovery runbook + +Stable releases promote a fully tested development boundary to `main`. Build once for the exact release SHA, record SHA-256, and send the identical archive to the download host, ForgeBox, and GitHub release. Publish generated API docs for the same version and source. + +Before publication, verify formatting/diff checks, metadata, locked installs, required engine matrix, docs, package contents, clean external installation, service resolution, secret scan, and the live Stripe test-mode contract. Never release a feature branch or fork PR and never force-move an existing tag. + +Record each destination independently: source SHA/version, test run, archive checksum, download URL/checksum, API-doc URL, ForgeBox version/download, tag SHA, GitHub release asset/checksum, and final external install. ForgeBox presence alone is not completion. + +If a run fails partway, stop and inventory every destination. Verify any existing artifact checksum and tag SHA. Resume only missing destinations with the original build; do not rebuild under the same version or move a tag to new source. If an existing destination differs, fail closed and escalate. The dry-run workflow uploads the build/checksum as CI artifacts without public credentials and exercises this verification logic before a release candidate. diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..7e18196 --- /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 provider call. +- Do not hold a database transaction across a provider 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, named-provider-to-business mapping, Connect 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..36376ae --- /dev/null +++ b/docs/stripe.md @@ -0,0 +1,11 @@ +# Stripe provider + +cbpayments 1.0 pins stripe-cfml 4.1.0 and Stripe API version `2026-02-25.clover`. Each named provider constructs its own client with `convertToCents=false`, so credentials, API versions, currency defaults, Connect accounts, webhook secrets, and mutable state do not bleed between accounts. + +Use hosted Checkout Sessions for ordinary on-session payments. Payment Intents are for off-session or independently modeled payment state; Setup Intents save payment methods. Capture and refund operations act on Payment Intents. Customer operations support Setup Intent ownership. Charges, Sources, Tokens, Card Element, and local recurring loops are deliberately absent. + +Stripe automatically selects payment methods. The adapter does not send `payment_method_types`. Allow-listed `providerOptions.Stripe` fields support `expiresAt`, `applicationFeeAmount`, `transferDestination`, `onBehalfOf`, and `offSession` only where meaningful. Connected-account headers may be fixed per named provider. Choose one Connect charge model in the consuming application and do not silently retry against another provider. + +Stripe response bodies are translated through per-operation allow-lists. Request IDs and other diagnostic scalars are preserved only after secret/payment-data scrubbing; malformed success bodies become normalized provider failures, and unsafe redirect URLs are discarded. Raw response objects, headers, client secrets, payment-method identifiers, and error bodies are not serialized or announced. The raw stripe-cfml client remains available through `getClient()` for non-portable features and must be isolated behind application code with provider-specific tests. + +The pinned stripe-cfml transport applies its finite 50-second request timeout. Transport and timeout failures are returned as retryable normalized network failures; applications should retry only operations protected by an idempotency key. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..b7d4bf8 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,9 @@ +# Testing + +Use `InMemory` 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 `Null` 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 registry validation, case-insensitive lookup, override/unregister, ownership, 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, error classes, request IDs, client-secret isolation, signature rotation/tolerance, malformed bodies, account mismatch, duplicates/out-of-order events, and real stripe-cfml HMAC verification. + +Required CI runs ColdBox 8 on BoxLang 1 native and CFML compatibility, Lucee 6/7, and Adobe ColdFusion 2023/2025. Required PR tests never contact Stripe. The separate secret-bearing workflow supplies `STRIPE_API_KEY`, runs the live test-mode contract, and scrubs artifacts. 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..2907dbb --- /dev/null +++ b/docs/webhooks.md @@ -0,0 +1,9 @@ +# Webhook boundary + +Pass the exact raw request body and Stripe-Signature header to `verifyWebhook`. Verification happens before JSON parsing. The provider tests an ordered list of active secrets for safe rotation, enforces timestamp tolerance in both past and future directions, validates the configured/provider account, and returns a normalized `PaymentEvent`. + +The event contains safe identity, type, time, livemode, provider/account, object identity, normalized status/amount, allow-listed details, a deterministic SHA-256 payload checksum, and only the matched secret index. It never retains the raw body or secret. + +cbpayments does not expose a route, select a tenant, persist an event, acknowledge HTTP, mutate invoices, enqueue jobs, or retry business logic. The application should acknowledge promptly after verification, store an inbox entry keyed by provider name plus external event ID, and process it idempotently. Duplicate and out-of-order delivery is normal; reconcile against current provider state where ordering is insufficient. + +Distinct typed failures cover invalid signatures, malformed signed bodies, stale/future timestamps, missing webhook configuration, and provider-account mismatches. Do not log the raw body or signature when handling them. diff --git a/dsl/cbpaymentsDSL.cfc b/dsl/cbpaymentsDSL.cfc new file mode 100644 index 0000000..c6f7bf2 --- /dev/null +++ b/dsl/cbpaymentsDSL.cfc @@ -0,0 +1,34 @@ +/** + * WireBox DSL for named cbpayments providers. + * + * cbpayments => default provider + * cbpayments:default => default provider + * cbpayments:name => named provider + */ +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.defaultProvider(); + } + if ( arrayLen( segments ) == 2 && len( segments[ 2 ] ) ) { + return service.provider( 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..e849cbc --- /dev/null +++ b/helpers/Mixins.cfm @@ -0,0 +1,15 @@ + +/** + * Return the configured payment provider by name. + */ +function getPaymentProvider( required string name ){ + return wirebox.getInstance( "PaymentService@cbpayments" ).provider( arguments.name ); +} + +/** + * Return the application's default payment provider. + */ +function getDefaultPaymentProvider(){ + return wirebox.getInstance( "PaymentService@cbpayments" ).defaultProvider(); +} + diff --git a/models/PaymentService.cfc b/models/PaymentService.cfc new file mode 100644 index 0000000..2989669 --- /dev/null +++ b/models/PaymentService.cfc @@ -0,0 +1,713 @@ +/** + * Thread-safe registry and facade for configured payment providers. + */ +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.providerTypes = {}; + variables.providers = {}; + variables.providerConstructionLocks = {}; + variables.providerConstructionLocksLock = createObject( + "java", + "java.util.concurrent.locks.ReentrantLock" + ).init(); + return this; + } + + any function registerAppProviderTypes(){ + if ( !variables.moduleSettings.keyExists( "providerTypes" ) ) { + return this; + } + for ( var name in variables.moduleSettings.providerTypes ) { + registerProviderTypeDefinition( + name, + variables.moduleSettings.providerTypes[ name ], + "application" + ); + } + return this; + } + + any function registerAppProviders(){ + registerProviderMap( variables.moduleSettings.providers ); + 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( "providerTypes" ) ) { + for ( var alias in contribution.providerTypes ) { + registerProviderTypeDefinition( + alias, + contribution.providerTypes[ alias ], + moduleName + ); + } + } + if ( contribution.keyExists( "providers" ) ) { + registerProviderMap( contribution.providers, "@#moduleName#" ); + } + if ( contribution.keyExists( "globalProviders" ) ) { + registerProviderMap( contribution.globalProviders ); + } + } + return this; + } + + any function registerProviderType( + required string name, + required string provider, + string owner = "application", + boolean override = false, + string extensionVersion = "", + array declaredCapabilities = [] + ){ + var key = canonical( arguments.name ); + if ( variables.providerTypes.keyExists( key ) && !arguments.override ) { + throw( + type = "cbpayments.DuplicateProviderType", + message = "Provider type [#arguments.name#] from [#arguments.owner#] conflicts with owner [#variables.providerTypes[ key ].owner#]." + ); + } + if ( variables.providerTypes.keyExists( key ) && arguments.override ) { + unregisterProviderType( + arguments.name, + variables.providerTypes[ key ].owner, + true + ); + } + variables.providerTypes[ key ] = { + "name" : arguments.name, + "provider" : arguments.provider, + "owner" : arguments.owner, + "extensionVersion" : arguments.extensionVersion, + "declaredCapabilities" : duplicate( arguments.declaredCapabilities ) + }; + return this; + } + + any function unregisterProviderType( + required string name, + required string owner, + boolean force = false + ){ + var key = canonical( arguments.name ); + if ( !variables.providerTypes.keyExists( key ) ) { + throw( + type = "cbpayments.UnknownProviderType", + message = "Provider type [#arguments.name#] is not registered." + ); + } + if ( !arguments.force && variables.providerTypes[ key ].owner != arguments.owner ) { + throw( + type = "cbpayments.ProviderTypeOwnership", + message = "Owner [#arguments.owner#] cannot unregister provider type [#arguments.name#]." + ); + } + variables.providers.each( function( providerKey, record ){ + if ( canonical( record.provider ) == key ) { + unregister( record.name ); + } + } ); + variables.providerTypes.delete( key ); + return this; + } + + boolean function hasProviderType( required string name ){ + return variables.providerTypes.keyExists( canonical( arguments.name ) ); + } + + array function providerTypeNames(){ + var result = variables.providerTypes + .keyArray() + .map( function( key ){ + return variables.providerTypes[ key ].name; + } ); + result.sort( "textNoCase" ); + return result; + } + + array function providerTypeDescriptors(){ + return variables.providerTypes + .keyArray() + .map( function( key ){ + var item = variables.providerTypes[ key ]; + return { + "name" : item.name, + "provider" : item.provider, + "owner" : item.owner, + "extensionVersion" : item.extensionVersion, + "declaredCapabilities" : duplicate( item.declaredCapabilities ) + }; + } ); + } + + any function register( + required string name, + required string provider, + struct properties = {}, + boolean override = false + ){ + var key = canonical( arguments.name ); + var providerLock = getProviderConstructionLock( key ); + providerLock.lock(); + try { + if ( !len( key ) ) { + throw( type = "cbpayments.InvalidConfiguration", message = "Provider names cannot be empty." ); + } + if ( variables.providers.keyExists( key ) && !arguments.override ) { + throw( + type = "cbpayments.DuplicateProvider", + message = "A payment provider named [#arguments.name#] is already registered." + ); + } + if ( variables.providers.keyExists( key ) && arguments.override ) { + unregister( arguments.name ); + } + variables.providers[ key ] = { + "name" : arguments.name, + "provider" : arguments.provider, + "properties" : arguments.properties, + "registeredOn" : now(), + "createdOn" : "" + }; + } finally { + providerLock.unlock(); + } + return this; + } + + any function unregister( required string name ){ + var key = canonical( arguments.name ); + var providerLock = getProviderConstructionLock( key ); + providerLock.lock(); + try { + var record = getProviderRecord( arguments.name ); + if ( record.keyExists( "instance" ) ) { + record.instance.shutdown(); + } + variables.providers.delete( key ); + } finally { + providerLock.unlock(); + } + return this; + } + + any function provider( required string name ){ + var key = canonical( arguments.name ); + var providerLock = getProviderConstructionLock( key ); + providerLock.lock(); + try { + var record = getProviderRecord( arguments.name ); + if ( !record.keyExists( "instance" ) ) { + var instance = buildProvider( record.provider ); + validateProviderContract( instance, record.provider ); + instance.startup( record.name, record.properties ); + variables.providers[ key ].instance = instance; + variables.providers[ key ].createdOn = now(); + } + return variables.providers[ key ].instance; + } finally { + providerLock.unlock(); + } + } + + any function defaultProvider(){ + validateDefaultProvider(); + return provider( variables.moduleSettings.defaultProvider ); + } + + any function validateDefaultProvider(){ + if ( + !variables.moduleSettings.keyExists( "defaultProvider" ) + || !has( variables.moduleSettings.defaultProvider ) + ) { + throw( + type = "cbpayments.InvalidConfiguration", + message = "defaultProvider must name a registered cbpayments provider definition." + ); + } + return this; + } + + any function validateSettings(){ + var allowed = [ + "defaultProvider", + "providers", + "providerTypes", + "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.providers ) || !isStruct( variables.moduleSettings.providerTypes ) ) { + throw( + type = "cbpayments.InvalidConfiguration", + message = "cbpayments providers and providerTypes settings must be structs." + ); + } + 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 has( required string name ){ + return variables.providers.keyExists( canonical( arguments.name ) ); + } + + boolean function missing( required string name ){ + return !has( arguments.name ); + } + + array function names(){ + var result = variables.providers + .keyArray() + .map( function( key ){ + return variables.providers[ key ].name; + } ); + result.sort( "textNoCase" ); + return result; + } + + numeric function count(){ + return variables.providers.count(); + } + + array function capabilities( required string name ){ + return provider( arguments.name ).capabilities(); + } + + boolean function supports( required string name, required string capability ){ + return provider( arguments.name ).supports( arguments.capability ); + } + + any function shutdown(){ + var registeredNames = names(); + registeredNames.each( function( name ){ + unregister( name ); + } ); + return this; + } + + /** + * Shut down every instance and clear construction metadata during module unload/reinit. + */ + any function reset(){ + shutdown(); + variables.providerTypes = {}; + variables.providerConstructionLocks = {}; + return this; + } + + any function createCheckout( required any request, string providerName = "" ){ + return dispatch( + arguments.providerName, + "hostedCheckout", + "createCheckout", + [ arguments.request ] + ); + } + + any function retrieveCheckout( required string externalId, string providerName = "" ){ + return dispatch( + arguments.providerName, + "hostedCheckout", + "retrieveCheckout", + [ arguments.externalId ] + ); + } + + any function expireCheckout( + required string externalId, + required string idempotencyKey, + string providerName = "" + ){ + return dispatch( + arguments.providerName, + "hostedCheckout", + "expireCheckout", + [ arguments.externalId, arguments.idempotencyKey ] + ); + } + + any function createPaymentIntent( required any request, string providerName = "" ){ + return dispatch( + arguments.providerName, + "paymentIntents", + "createPaymentIntent", + [ arguments.request ] + ); + } + + any function retrievePaymentIntent( required string externalId, string providerName = "" ){ + return dispatch( + arguments.providerName, + "paymentIntents", + "retrievePaymentIntent", + [ arguments.externalId ] + ); + } + + any function confirmPaymentIntent( + required string externalId, + required string idempotencyKey, + struct options = {}, + string providerName = "" + ){ + return dispatch( + arguments.providerName, + "paymentIntents", + "confirmPaymentIntent", + [ + arguments.externalId, + arguments.idempotencyKey, + arguments.options + ] + ); + } + + any function cancelPaymentIntent( + required string externalId, + required string idempotencyKey, + string providerName = "" + ){ + return dispatch( + arguments.providerName, + "paymentIntents", + "cancelPaymentIntent", + [ arguments.externalId, arguments.idempotencyKey ] + ); + } + + any function capturePayment( required any request, string providerName = "" ){ + return dispatch( + arguments.providerName, + "capture", + "capturePayment", + [ arguments.request ] + ); + } + + any function createRefund( required any request, string providerName = "" ){ + return dispatch( + arguments.providerName, + "refunds", + "createRefund", + [ arguments.request ] + ); + } + + any function retrieveRefund( required string externalId, string providerName = "" ){ + return dispatch( + arguments.providerName, + "refunds", + "retrieveRefund", + [ arguments.externalId ] + ); + } + + any function createSetupIntent( required any request, string providerName = "" ){ + return dispatch( + arguments.providerName, + "setupIntents", + "createSetupIntent", + [ arguments.request ] + ); + } + + any function retrieveSetupIntent( required string externalId, string providerName = "" ){ + return dispatch( + arguments.providerName, + "setupIntents", + "retrieveSetupIntent", + [ arguments.externalId ] + ); + } + + any function cancelSetupIntent( + required string externalId, + required string idempotencyKey, + string providerName = "" + ){ + return dispatch( + arguments.providerName, + "setupIntents", + "cancelSetupIntent", + [ arguments.externalId, arguments.idempotencyKey ] + ); + } + + any function createCustomer( required any request, string providerName = "" ){ + return dispatch( + arguments.providerName, + "customers", + "createCustomer", + [ arguments.request ] + ); + } + + any function retrieveCustomer( required string externalId, string providerName = "" ){ + return dispatch( + arguments.providerName, + "customers", + "retrieveCustomer", + [ arguments.externalId ] + ); + } + + any function updateCustomer( + required string externalId, + required any request, + string providerName = "" + ){ + return dispatch( + arguments.providerName, + "customers", + "updateCustomer", + [ arguments.externalId, arguments.request ] + ); + } + + any function deleteCustomer( + required string externalId, + required string idempotencyKey, + string providerName = "" + ){ + return dispatch( + arguments.providerName, + "customers", + "deleteCustomer", + [ arguments.externalId, arguments.idempotencyKey ] + ); + } + + any function verifyWebhook( + required string rawBody, + required string signature, + string accountId = "", + string providerName = "" + ){ + return dispatch( + arguments.providerName, + "webhooks", + "verifyWebhook", + [ + arguments.rawBody, + arguments.signature, + arguments.accountId + ] + ); + } + + private any function dispatch( + required string providerName, + required string capability, + required string method, + required array positionalArguments + ){ + var target = len( arguments.providerName ) ? provider( arguments.providerName ) : defaultProvider(); + if ( !target.supports( arguments.capability ) ) { + throw( + type = "cbpayments.UnsupportedCapability", + message = "Provider [#target.getName()#] does not support [#arguments.capability#]." + ); + } + return invoke( + target, + arguments.method, + arguments.positionalArguments + ); + } + + private any function registerProviderMap( required struct providerMap, string namespace = "" ){ + for ( var name in arguments.providerMap ) { + var definition = arguments.providerMap[ name ]; + validateProviderDefinition( name, definition ); + register( + name = name & namespace, + provider = definition.provider, + properties = definition.keyExists( "properties" ) ? definition.properties : {} + ); + } + return this; + } + + private void function registerProviderTypeDefinition( + required string name, + required any definition, + required string owner + ){ + if ( isSimpleValue( arguments.definition ) ) { + registerProviderType( + arguments.name, + arguments.definition, + arguments.owner + ); + return; + } + if ( !isStruct( arguments.definition ) || !arguments.definition.keyExists( "provider" ) ) { + throw( + type = "cbpayments.InvalidConfiguration", + message = "Provider type [#arguments.name#] requires a provider key." + ); + } + for ( var key in arguments.definition ) { + if ( + !arrayFindNoCase( + [ + "provider", + "extensionVersion", + "declaredCapabilities" + ], + key + ) + ) { + throw( + type = "cbpayments.InvalidConfiguration", + message = "Provider type [#arguments.name#] has unknown key [#key#]." + ); + } + } + if ( + !isSimpleValue( arguments.definition.provider ) + || !len( trim( arguments.definition.provider ) ) + || ( + arguments.definition.keyExists( "declaredCapabilities" ) + && !isArray( arguments.definition.declaredCapabilities ) + ) + ) { + throw( + type = "cbpayments.InvalidConfiguration", + message = "Provider type [#arguments.name#] is invalid." + ); + } + registerProviderType( + name = arguments.name, + provider = arguments.definition.provider, + owner = arguments.owner, + extensionVersion = arguments.definition.keyExists( "extensionVersion" ) ? arguments.definition.extensionVersion : "", + declaredCapabilities = arguments.definition.keyExists( "declaredCapabilities" ) ? arguments.definition.declaredCapabilities : [] + ); + } + + private void function validateProviderDefinition( required string name, required any definition ){ + if ( !isStruct( arguments.definition ) || !arguments.definition.keyExists( "provider" ) ) { + throw( + type = "cbpayments.InvalidConfiguration", + message = "Provider definition [#arguments.name#] requires a provider key." + ); + } + if ( + !isSimpleValue( arguments.definition.provider ) + || !len( trim( arguments.definition.provider ) ) + || ( arguments.definition.keyExists( "properties" ) && !isStruct( arguments.definition.properties ) ) + ) { + throw( + type = "cbpayments.InvalidConfiguration", + message = "Provider definition [#arguments.name#] is invalid." + ); + } + for ( var key in arguments.definition ) { + if ( !arrayFindNoCase( [ "provider", "properties" ], key ) ) { + throw( + type = "cbpayments.InvalidConfiguration", + message = "Provider definition [#arguments.name#] has unknown key [#key#]." + ); + } + } + } + + private struct function getProviderRecord( required string name ){ + var key = canonical( arguments.name ); + if ( !variables.providers.keyExists( key ) ) { + throw( + type = "cbpayments.UnknownProvider", + message = "Payment provider [#arguments.name#] is not registered. Registered providers: #names().toList()#." + ); + } + return variables.providers[ key ]; + } + + private any function buildProvider( required string provider ){ + var key = canonical( arguments.provider ); + var target = variables.providerTypes.keyExists( key ) ? variables.providerTypes[ key ].provider : arguments.provider; + return variables.wirebox.getInstance( target ); + } + + private void function validateProviderContract( required any instance, required string provider ){ + var requiredMethods = [ + "startup", + "shutdown", + "getName", + "getType", + "capabilities", + "supports", + "getClient" + ]; + var candidate = arguments.instance; + var providerName = arguments.provider; + requiredMethods.each( function( method ){ + if ( !structKeyExists( candidate, method ) ) { + throw( + type = "cbpayments.InvalidProviderContract", + message = "Provider [#providerName#] does not implement [#method#]." + ); + } + } ); + } + + private any function getProviderConstructionLock( required string key ){ + variables.providerConstructionLocksLock.lock(); + try { + if ( !variables.providerConstructionLocks.keyExists( arguments.key ) ) { + variables.providerConstructionLocks[ arguments.key ] = createObject( + "java", + "java.util.concurrent.locks.ReentrantLock" + ).init(); + } + return variables.providerConstructionLocks[ arguments.key ]; + } finally { + variables.providerConstructionLocksLock.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..9a9dfe6 --- /dev/null +++ b/models/contracts/IPaymentProvider.cfc @@ -0,0 +1,11 @@ +interface displayname="IPaymentProvider" { + + public any function startup( required string name, struct properties ); + public any function shutdown(); + public string function getName(); + public string function getType(); + 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/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..b408e27 --- /dev/null +++ b/models/contracts/results/PaymentEvent.cfc @@ -0,0 +1,77 @@ +/** + * Verified, normalized webhook event. Raw payloads are never retained. + */ +component accessors="true" { + + property name="eventId" type="string"; + property name="eventType" type="string"; + property name="occurredAt"; + property name="livemode" type="boolean"; + property name="providerName" 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 any occurredAt, + required boolean livemode, + required string providerName, + 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.occurredAt = arguments.occurredAt; + variables.livemode = arguments.livemode; + variables.providerName = arguments.providerName; + 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, + "occurredAt" : variables.occurredAt, + "livemode" : variables.livemode, + "providerName" : variables.providerName, + "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..28e420b --- /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="providerName" 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 providerName, + 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.providerName = arguments.providerName; + 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, + "providerName" : variables.providerName, + "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/providers/AbstractPaymentProvider.cfc b/models/providers/AbstractPaymentProvider.cfc new file mode 100644 index 0000000..5f4bdcc --- /dev/null +++ b/models/providers/AbstractPaymentProvider.cfc @@ -0,0 +1,208 @@ +/** + * Shared provider lifecycle, capability, result, and safe observability behavior. + */ +component accessors="true" implements="cbpayments.models.contracts.IPaymentProvider" { + + property name="name" 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="wirebox" inject="wirebox"; + + function init(){ + variables.identifier = createUUID(); + variables.name = ""; + variables.providerType = "Abstract"; + variables.properties = {}; + variables.started = false; + variables.client = javacast( "null", "" ); + variables.supportedCapabilities = []; + variables.redactor = new cbpayments.models.util.Redactor(); + 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 name, struct properties = {} ){ + variables.name = arguments.name; + variables.properties = arguments.properties; + variables.started = true; + announce( "cbpaymentsOnProviderStart", safeContext() ); + return this; + } + + any function shutdown(){ + if ( variables.started ) { + variables.started = false; + announce( "cbpaymentsOnProviderShutdown", safeContext() ); + } + variables.client = javacast( "null", "" ); + return this; + } + + boolean function hasStarted(){ + return variables.started; + } + + string function getIdentifier(){ + return variables.identifier; + } + + string function getType(){ + 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.name#] 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, + providerName = variables.name, + 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, + providerName = variables.name, + providerType = variables.providerType, + status = arguments.status, + externalId = arguments.externalId, + requestId = arguments.requestId, + idempotencyKey = arguments.idempotencyKey, + failure = failure + ); + } + + struct function safeContext(){ + return { + "providerName" : variables.name, + "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 ) ); + } + } + + 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/InMemoryProvider.cfc b/models/providers/InMemoryProvider.cfc new file mode 100644 index 0000000..8cdccdd --- /dev/null +++ b/models/providers/InMemoryProvider.cfc @@ -0,0 +1,386 @@ +/** + * 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 = "InMemory"; + variables.supportedCapabilities = [ + "hostedCheckout", + "paymentIntents", + "capture", + "refunds", + "setupIntents", + "customers", + "webhooks" + ]; + reset(); + return this; + } + + any function startup( required string name, 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 != "inmemory-test-signature" ) { + throw( type = "cbpayments.InvalidWebhookSignature", message = "The webhook signature is invalid." ); + } + if ( !isJSON( arguments.rawBody ) ) { + throw( type = "cbpayments.MalformedWebhook", message = "The webhook body is not valid JSON." ); + } + var payload = deserializeJSON( arguments.rawBody ); + if ( !payload.keyExists( "id" ) || !payload.keyExists( "type" ) ) { + throw( + type = "cbpayments.MalformedWebhook", + message = "The webhook event is missing required fields." + ); + } + var dataObject = payload.keyExists( "data" ) && payload.data.keyExists( "object" ) ? payload.data.object : {}; + return new cbpayments.models.contracts.results.PaymentEvent( + eventId = payload.id, + eventType = payload.type, + occurredAt = payload.keyExists( "created" ) ? payload.created : 0, + livemode = payload.keyExists( "livemode" ) ? payload.livemode : false, + providerName = variables.name, + 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 + ); + } + + 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 = { "inMemory" : 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 "mem_#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..d937668 --- /dev/null +++ b/models/providers/StripeProvider.cfc @@ -0,0 +1,961 @@ +/** + * 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 name, struct properties = {} ){ + validateProperties( arguments.properties ); + variables.name = arguments.name; + 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.name#] requires apiKey." + ); + } + variables.client = new stripecfml.stripe( + propertyValue( "apiKey", "" ), + { + "apiVersion" : variables.apiVersion, + "defaultCurrency" : variables.defaultCurrency, + "convertToCents" : false + } + ); + } + return super.startup( arguments.name, 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 + ); + announce( + "cbpaymentsOnWebhookVerified", + safeContext().append( { + "eventId" : normalizedEvent.getEventId(), + "eventType" : normalizedEvent.getEventType(), + "matchedSecretIndex" : normalizedEvent.getMatchedSecretIndex() + } ) + ); + return 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 eventArguments = { + "eventId" : safeProviderString( event.id ), + "eventType" : safeProviderString( event.type ), + "occurredAt" : event.keyExists( "created" ) ? event.created : 0, + "livemode" : event.keyExists( "livemode" ) ? event.livemode : false, + "providerName" : variables.name, + "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 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..dc5b0b0 --- /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", + "getName", + "getType", + "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..e6dcea8 --- /dev/null +++ b/models/util/Redactor.cfc @@ -0,0 +1,116 @@ +/** + * 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, + "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/readme.md b/readme.md index 152ac4e..7419617 100644 --- a/readme.md +++ b/readme.md @@ -1,101 +1,96 @@ -

- -
- - - -

+# cbpayments -

- Copyright Since 2005 ColdBox Platform by Luis Majano and Ortus Solutions, Corp -
- www.coldbox.org | - www.ortussolutions.com -

+Provider-neutral named payment services for ColdBox 8 applications. ----- +cbpayments gives an application one stable service for default and named providers while keeping credentials, Stripe accounts, API versions, and webhook secrets isolated per provider. Applications retain ownership of customers, invoices, authorization, accounting, durable idempotency allocation, ledgers, and webhook processing. -# Ortus ColdBox Module Template +## Requirements -This template can be used to create Ortus based ColdBox Modules. To use, just click the `Use this Template` button in the github repository: https://github.com/coldbox-modules/module-template and run the setup task from where you cloned it. +- ColdBox 8 +- BoxLang 1 native or CFML compatibility, Lucee 6/7, or Adobe ColdFusion 2023/2025 +- Java 21 + +## Installation ```bash -box task run taskFile=build/SetupTemplate +box install cbpayments ``` -The `SetupTemplate` task will ask you for your module name, id and description and configure the template for you! Enjoy! - -## Directory Structure - -The root of the module is the root of the repository. Add all the necessary files your module will need. - -* `.github/workflows` - These are the github actions to test and build the module via CI -* `build` - This is the CommandBox task that builds the project. Only modify if needed. Most modules will never modify it. (Modify if needed) -* `test-harness` - This is a ColdBox testing application, where you will add your testing files, specs etc. -* `.cfformat.json` - A CFFormat using the Ortus Standards -* `.cflintrc` - A CFLint configuration file according to Ortus Standards -* `.editorconfig` - Smooth consistency between editors -* `.gitattributes` - Git attributes -* `.gitignore` - Basic ignores. Modify as needed. -* `.markdownlint.json` - A linting file for markdown docs -* `box.json` - The box.json for YOUR module. Modify as needed. -* `changelog.md` - A nice changelog tracking file -* `ModuleConfig.cfc` - Your module's configuration. Modify as needed. -* `readme.md` - Your module's readme. Modify as needed. -* `server-xx@x.json` - A set of json files to configure the major engines your modules supports. - -## Test Harness - -The test harness is created to bootstrap your working module into the application `afterAspectsLoad`. This is done in the `config/ColdBox.cfc`. It includes some key features: - -* `config` - Modify as needed -* `tests` - All your testing specs should go here. Please notice the commented out ORM fixtures. Enable them if your module requires ORM -* `.cfconfig.json` - A prepared cfconfig json file so your engine data is consistent. Modify as needed. -* `.env.sample` - An environment property file sample. Copy and create a `.env` if your app requires it. - +The module pins `stripecfml` 4.1.0. Stripe is accessed only through an isolated client created for each configured provider name. -## API Docs +## Five-minute InMemory example -The build task will take care of building API Docs using DocBox for you but **ONLY** for the `models` folder in your module. If you want to document more then make sure you modify the `build/Build.cfc` task. - -## Github Actions Automation - -The github actions will clone, test, package, deploy your module to ForgeBox and the Ortus S3 accounts for API Docs and Artifacts. So please make sure the following environment variables are set in your repository. ** Please note that most of them are already defined at the org level ** - -* `FORGEBOX_TOKEN` - The Ortus ForgeBox API Token -* `AWS_ACCESS_KEY` - The travis user S3 account -* `AWS_ACCESS_SECRET` - The travis secret S3 +```boxlang +moduleSettings.cbpayments = { + defaultProvider : "payments", + providers : { + payments : { provider : "InMemory", properties : {} } + } +}; +``` -> Please contact the admins in the `#infrastructure` channel for these credentials if needed +```boxlang +money = new cbpayments.models.contracts.Money( 2500, "usd" ); +paymentRequest = new cbpayments.models.contracts.requests.HostedCheckoutRequest( + money = money, + idempotencyKey = "order-42-checkout-v1", + returnUrl = "https://example.test/payments/complete", + cancelUrl = "https://example.test/payments/cancel" +); -## Welcome to ColdBox +result = getInstance( "PaymentService@cbpayments" ).createCheckout( paymentRequest ); +``` -ColdBox *Hierarchical* MVC is the de-facto enterprise-level [HMVC](https://en.wikipedia.org/wiki/Hierarchical_model%E2%80%93view%E2%80%93controller) framework for ColdFusion (CFML) developers. It's professionally backed, conventions-based, modular, highly extensible, and productive. Getting started with ColdBox is quick and painless. ColdBox takes the pain out of development by giving you a standardized methodology for modern ColdFusion (CFML) development with features such as: +The InMemory provider records sanitized requests and supports deterministic queued failures, reset helpers, and webhook fixtures. Switch `provider` to `Stripe` without changing application operation code. + +## Stripe hosted checkout + +```boxlang +moduleSettings.cbpayments = { + defaultProvider : "receivables", + providers : { + receivables : { + provider : "Stripe", + properties : { + apiKey : getSystemSetting( "STRIPE_API_KEY" ), + webhookSecrets : [ getSystemSetting( "STRIPE_WEBHOOK_SECRET" ) ], + apiVersion : "2026-02-25.clover", + defaultCurrency : "usd" + } + } + } +}; +``` -* [Conventions instead of configuration](https://coldbox.ortusbooks.com/getting-started/conventions) -* [Modern URL routing](https://coldbox.ortusbooks.com/the-basics/routing) -* [RESTFul APIs](https://coldbox.ortusbooks.com/the-basics/event-handlers/rendering-data) -* [A hierarchical approach to MVC using ColdBox Modules](https://coldbox.ortusbooks.com/hmvc/modules) -* [Event-driven programming](https://coldbox.ortusbooks.com/digging-deeper/interceptors) -* [Async and Parallel programming constructs](https://coldbox.ortusbooks.com/digging-deeper/promises-async-programming) -* [Integration & Unit Testing](https://coldbox.ortusbooks.com/testing/testing-coldbox-applications) -* [Included dependency injection](https://wirebox.ortusbooks.com) -* [Caching engine and API](https://cachebox.ortusbooks.com) -* [Logging engine](https://logbox.ortusbooks.com) -* [An extensive eco-system](https://forgebox.io) -* Much More +cbpayments uses Checkout Sessions for ordinary web payments, Payment Intents for independently modeled/off-session state, Setup Intents for saving methods, and Payment Intents for capture/refunds. It does not expose Charges, Sources, Tokens, or raw card collection. -## Learning ColdBox +## Access and capabilities -ColdBox is the defacto standard for building modern ColdFusion (CFML) applications. It has the most extensive [documentation](https://coldbox.ortusbooks.com) of all modern web application frameworks. +```boxlang +paymentService = getInstance( "PaymentService@cbpayments" ); +defaultProvider = getInstance( dsl = "cbpayments" ); +namedProvider = getInstance( dsl = "cbpayments:receivables" ); +if ( paymentService.supports( "receivables", "refunds" ) ) { + // Use a normalized RefundRequest. +} +``` -If you don't like reading so much, then you can try our video learning platform: [CFCasts (www.cfcasts.com)](https://www.cfcasts.com) +The raw SDK is available only through a provider's explicit `getClient()` escape hatch. Keep that usage isolated behind an application adapter. -## Ortus Sponsors +## Documentation -ColdBox is a professional open-source project and it is completely funded by the [community](https://patreon.com/ortussolutions) and [Ortus Solutions, Corp](https://www.ortussolutions.com). Ortus Patreons get many benefits like a cfcasts account, a FORGEBOX Pro account and so much more. If you are interested in becoming a sponsor, please visit our patronage page: [https://patreon.com/ortussolutions](https://patreon.com/ortussolutions) +- [Configuration](docs/configuration.md) +- [Provider and operation guide](docs/providers.md) +- [Stripe guide](docs/stripe.md) +- [Webhook boundary](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) +- [Release and recovery runbook](docs/releasing.md) +- [Architecture and delivery plan](docs/cbpayments-architecture-plan.md) -### THE DAILY BREAD +## License - > "I am the way, and the truth, and the life; no one comes to the Father, but by me (JESUS)" Jn 14:1-12 +Apache License 2.0. diff --git a/server-adobe@2018.json b/server-adobe@2018.json deleted file mode 100644 index 8c13686..0000000 --- a/server-adobe@2018.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name":"@MODULE_NAME@-adobe@2018", - "app":{ - "serverHomeDirectory":".engine/adobe2018", - "cfengine":"adobe@2018" - }, - "web":{ - "http":{ - "port":"60299" - }, - "rewrites":{ - "enable":"true" - }, - "webroot": "test-harness", - "aliases":{ - "/moduleroot/@MODULE_NAME@":"../" - } - }, - "openBrowser":"false", - "cfconfig": { - "file" : ".cfconfig.json" - } -} diff --git a/server-adobe@2021.json b/server-adobe@2021.json deleted file mode 100644 index d0630be..0000000 --- a/server-adobe@2021.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name":"@MODULE_NAME@-adobe@2021", - "app":{ - "serverHomeDirectory":".engine/adobe2021", - "cfengine":"adobe@2021" - }, - "web":{ - "http":{ - "port":"60299" - }, - "rewrites":{ - "enable":"true" - }, - "webroot": "test-harness", - "aliases":{ - "/moduleroot/@MODULE_NAME@":"../" - } - }, - "jvm":{ - "heapSize":"1024" - }, - "openBrowser":"false", - "cfconfig": { - "file" : ".cfconfig.json" - }, - "scripts" : { - "onServerInstall":"cfpm install zip,debugger" - } -} diff --git a/server-adobe@2023.json b/server-adobe@2023.json index ef303ae..02576b3 100644 --- a/server-adobe@2023.json +++ b/server-adobe@2023.json @@ -1,5 +1,5 @@ { - "name":"@MODULE_NAME@-adobe@2023", + "name":"cbpayments-adobe@2023", "app":{ "serverHomeDirectory":".engine/adobe2023", "cfengine":"adobe@2023" @@ -11,19 +11,20 @@ "rewrites":{ "enable":"true" }, - "webroot": "test-harness", - "aliases":{ - "/moduleroot/@MODULE_NAME@":"../" + "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@2025.json b/server-adobe@2025.json new file mode 100644 index 0000000..6287962 --- /dev/null +++ b/server-adobe@2025.json @@ -0,0 +1,30 @@ +{ + "name":"cbpayments-adobe@2025", + "app":{ + "serverHomeDirectory":".engine/adobe2025", + "cfengine":"adobe@2025" + }, + "web":{ + "http":{ + "port":"60299" + }, + "rewrites":{ + "enable":"true" + }, + "webroot":"test-harness", + "aliases":{ + "/moduleroot/cbpayments":"../" + } + }, + "jvm":{ + "heapSize":"768", + "javaVersion":"openjdk21_jre" + }, + "openBrowser":"false", + "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 new file mode 100644 index 0000000..843c341 --- /dev/null +++ b/server-boxlang@1.json @@ -0,0 +1,29 @@ +{ + "name":"cbpayments-boxlang@1", + "app":{ + "serverHomeDirectory":".engine/boxlang", + "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":{} +} diff --git a/server-lucee@5.json b/server-lucee@5.json deleted file mode 100644 index 6423ca7..0000000 --- a/server-lucee@5.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name":"@MODULE_NAME@-lucee@5", - "app":{ - "serverHomeDirectory":".engine/lucee5", - "cfengine":"lucee@5" - }, - "web":{ - "http":{ - "port":"60299" - }, - "rewrites":{ - "enable":"true" - }, - "webroot": "test-harness", - "aliases":{ - "/moduleroot/@MODULE_NAME@":"../" - } - }, - "openBrowser":"false", - "cfconfig": { - "file" : ".cfconfig.json" - } -} diff --git a/server-lucee@6.json b/server-lucee@6.json index c3b490e..c7030cc 100644 --- a/server-lucee@6.json +++ b/server-lucee@6.json @@ -1,5 +1,5 @@ { - "name":"@MODULE_NAME@-lucee@6", + "name":"cbpayments-lucee@6", "app":{ "serverHomeDirectory":".engine/lucee6", "cfengine":"lucee@6" @@ -11,13 +11,17 @@ "rewrites":{ "enable":"true" }, - "webroot": "test-harness", - "aliases":{ - "/moduleroot/@MODULE_NAME@":"../" + "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@7.json b/server-lucee@7.json new file mode 100644 index 0000000..54eb2f3 --- /dev/null +++ b/server-lucee@7.json @@ -0,0 +1,27 @@ +{ + "name":"cbpayments-lucee@7", + "app":{ + "serverHomeDirectory":".engine/lucee7", + "cfengine":"lucee@7" + }, + "web":{ + "http":{ + "port":"60299" + }, + "rewrites":{ + "enable":"true" + }, + "webroot":"test-harness", + "aliases":{ + "/moduleroot/cbpayments":"../" + } + }, + "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 0fcffde..802bae8 100644 --- a/test-harness/Application.cfc +++ b/test-harness/Application.cfc @@ -7,7 +7,7 @@ www.ortussolutions.com component{ // UPDATE THE NAME OF THE MODULE IN TESTING BELOW - request.MODULE_NAME = "@MODULE_NAME@"; + request.MODULE_NAME = "cbpayments"; // Application properties this.name = hash( getCurrentTemplatePath() ); @@ -47,23 +47,6 @@ component{ 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 - }; - **/ - // application start public boolean function onApplicationStart(){ application.cbBootstrap = new coldbox.system.Bootstrap( COLDBOX_CONFIG_FILE, COLDBOX_APP_ROOT_PATH, COLDBOX_APP_KEY, COLDBOX_APP_MAPPING ); diff --git a/test-harness/box.json b/test-harness/box.json index e8f47b1..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":"^6.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 d35b065..f82eb0f 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,8 +44,18 @@ 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 = [] + // Fixture is activated explicitly by its integration spec. + exclude = [ "cbpayments-fixture" ] + }; + + moduleSettings = { + cbpayments : { + defaultProvider : "memory", + providers : { + memory : { provider : "InMemory", properties : {} }, + disabled : { provider : "Null", properties : {} } + } + } }; //Register interceptors as an array, we need order @@ -82,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..20f2060 --- /dev/null +++ b/test-harness/modules/cbpayments-fixture/ModuleConfig.cfc @@ -0,0 +1,26 @@ +component { + + this.title = "cbpayments fixture provider"; + this.modelNamespace = "cbpayments-fixture"; + this.cfmapping = "cbpayments-fixture"; + this.dependencies = [ "cbpayments" ]; + + function configure(){ + settings = { + cbpayments : { + providerTypes : { FixturePay : { provider : "FixtureProvider@cbpayments-fixture" } }, + providers : { configured : { provider : "FixturePay", properties : {} } } + } + }; + } + + function onLoad(){ + } + + function onUnload(){ + wirebox + .getInstance( "PaymentService@cbpayments" ) + .unregisterProviderType( "FixturePay", "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..561917b --- /dev/null +++ b/test-harness/modules/cbpayments-fixture/models/FixtureProvider.cfc @@ -0,0 +1,10 @@ +component extends="cbpayments.models.providers.InMemoryProvider" { + + 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/tests/Application.cfc b/test-harness/tests/Application.cfc index 9da19bf..b0f599f 100644 --- a/test-harness/tests/Application.cfc +++ b/test-harness/tests/Application.cfc @@ -1,69 +1,55 @@ /** -* Copyright 2005-2007 ColdBox Framework by Luis Majano and Ortus Solutions, Corp -* www.ortussolutions.com -* --- -*/ -component{ + * Copyright 2005-2007 ColdBox Framework by Luis Majano and Ortus Solutions, Corp + * www.ortussolutions.com + * --- + */ +component { // The name of the module used in cfmappings ,etc - request.MODULE_NAME = "@MODULE_NAME@"; + request.MODULE_NAME = "cbpayments"; // The directory name of the module on disk. Usually, it's the same as the module name - request.MODULE_PATH = "@MODULE_NAME@"; + request.MODULE_PATH = "cbpayments"; // APPLICATION CFC PROPERTIES - this.name = "#request.MODULE_NAME# Testing Suite"; - this.sessionManagement = true; - this.sessionTimeout = createTimeSpan( 0, 0, 15, 0 ); - this.applicationTimeout = createTimeSpan( 0, 0, 15, 0 ); - this.setClientCookies = true; + this.name = "#request.MODULE_NAME# Testing Suite"; + this.sessionManagement = true; + this.sessionTimeout = createTimespan( 0, 0, 15, 0 ); + this.applicationTimeout = createTimespan( 0, 0, 15, 0 ); + this.setClientCookies = true; // Turn on/off white space management this.whiteSpaceManagement = "smart"; - this.enableNullSupport = shouldEnableFullNullSupport(); + this.enableNullSupport = shouldEnableFullNullSupport(); // Create testing mapping this.mappings[ "/tests" ] = getDirectoryFromPath( getCurrentTemplatePath() ); // The application root - rootPath = REReplaceNoCase( this.mappings[ "/tests" ], "tests(\\|/)", "" ); - this.mappings[ "/root" ] = rootPath; + rootPath = reReplaceNoCase( this.mappings[ "/tests" ], "tests(\\|/)", "" ); + this.mappings[ "/root" ] = rootPath; // The module root path - moduleRootPath = REReplaceNoCase( rootPath, "#request.MODULE_PATH#(\\|/)test-harness(\\|/)", "" ); - 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 - }; - **/ + moduleRootPath = reReplaceNoCase( + rootPath, + "#request.MODULE_PATH#(\\|/)test-harness(\\|/)", + "" + ); + this.mappings[ "/moduleroot" ] = moduleRootPath; + this.mappings[ "/#request.MODULE_NAME#" ] = moduleRootPath & "#request.MODULE_PATH#"; function onRequestStart( required targetPage ){ - // Set a high timeout for long running tests - setting requestTimeout="9999"; + setting requestTimeout ="9999"; // New ColdBox Virtual Application Starter - request.coldBoxVirtualApp = new coldbox.system.testing.VirtualApp( appMapping = "/root" ); + request.coldBoxVirtualApp= new coldbox.system.testing.VirtualApp( appMapping = "/root" ); // 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 - if( structKeyExists( url, "fwreinit" ) ){ - if( structKeyExists( server, "lucee" ) ){ + if ( structKeyExists( url, "fwreinit" ) ) { + if ( structKeyExists( server, "lucee" ) ) { pagePoolClear(); } // ormReload(); @@ -73,13 +59,16 @@ component{ return true; } - public void function onRequestEnd( required targetPage ) { - request.coldBoxVirtualApp.shutdown(); + public void function onRequestEnd( required targetPage ){ + if ( request.keyExists( "coldBoxVirtualApp" ) ) { + request.coldBoxVirtualApp.shutdown(); + } + } + + private boolean function shouldEnableFullNullSupport(){ + var system = createObject( "java", "java.lang.System" ); + var value = system.getEnv( "FULL_NULL" ); + return isNull( value ) ? false : !!value; } - private boolean function shouldEnableFullNullSupport() { - var system = createObject( "java", "java.lang.System" ); - var value = system.getEnv( "FULL_NULL" ); - return isNull( value ) ? false : !!value; - } } diff --git a/test-harness/tests/resources/CountingProvider.cfc b/test-harness/tests/resources/CountingProvider.cfc new file mode 100644 index 0000000..3899de6 --- /dev/null +++ b/test-harness/tests/resources/CountingProvider.cfc @@ -0,0 +1,25 @@ +component extends="cbpayments.models.providers.InMemoryProvider" { + + function init(){ + super.init(); + variables.providerType = "Counting"; + return this; + } + + any function startup( required string name, 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/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 4a2dd25..0000000 --- a/test-harness/tests/specs/ModuleSpec.cfc +++ /dev/null @@ -1,24 +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..d81f19c --- /dev/null +++ b/test-harness/tests/specs/integration/ModuleIntegrationSpec.cfc @@ -0,0 +1,151 @@ +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.names() ).toInclude( "memory" ).toInclude( "disabled" ); + } ); + + it( "resolves default and named providers through the DSL", function(){ + expect( getInstance( dsl = "cbpayments" ).getName() ).toBe( "memory" ); + expect( getInstance( dsl = "cbpayments:default" ).getName() ).toBe( "memory" ); + expect( getInstance( dsl = "cbpayments:disabled" ).getType() ).toBe( "Null" ); + } ); + + it( "executes a consumer checkout through the service facade", function(){ + var money = new cbpayments.models.contracts.Money( 2500, "USD" ); + var paymentRequest = new cbpayments.models.contracts.requests.HostedCheckoutRequest( + 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, + "inmemory-test-signature", + "acct_integration", + "memory" + ); + expect( paymentEvent.getEventId() ).toBe( "evt_integration" ); + expect( paymentEvent.getObjectId() ).toBe( "pi_integration" ); + expect( paymentEvent.getProviderAccountId() ).toBe( "acct_integration" ); + } ); + + it( "switches providers 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.register( + "stripe-switch", + "Stripe", + { + "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, "memory" ); + 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.unregister( "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.hasProviderType( "FixturePay" ) ).toBeTrue(); + expect( service.has( "configured@cbpayments-fixture" ) ).toBeTrue(); + var fixtureProvider = service.provider( "configured@cbpayments-fixture" ); + expect( fixtureProvider.getType() ).toBe( "FixturePay" ); + expect( fixtureProvider.getClient().getIdentifier() ).notToBeEmpty(); + moduleService.unload( "cbpayments-fixture" ); + expect( service.hasProviderType( "FixturePay" ) ).toBeFalse(); + expect( service.has( "configured@cbpayments-fixture" ) ).toBeFalse(); + expect( fixtureProvider.hasStarted() ).toBeFalse(); + } ); + + it( "shuts down providers and reloads cleanly", function(){ + var moduleService = getController().getModuleService(); + var oldService = getInstance( "PaymentService@cbpayments" ); + var oldProvider = oldService.provider( "memory" ); + 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.names() ).toInclude( "memory" ).toInclude( "disabled" ); + expect( reloadedService.provider( "memory" ).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..6bcfb9a --- /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", + providerName = "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,providerName,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", + providerName = "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/InMemoryProviderSpec.cfc b/test-harness/tests/specs/unit/InMemoryProviderSpec.cfc new file mode 100644 index 0000000..d9028f8 --- /dev/null +++ b/test-harness/tests/specs/unit/InMemoryProviderSpec.cfc @@ -0,0 +1,129 @@ +component extends="coldbox.system.testing.BaseTestCase" { + + function run(){ + describe( "InMemory and Null providers", function(){ + beforeEach( function(){ + provider = new cbpayments.models.providers.InMemoryProvider().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( "mem_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_intent.succeeded", + "created" : 123, + "livemode" : false, + "data" : { + "object" : { + "id" : "pi_memory", + "object" : "payment_intent", + "status" : "succeeded" + } + } + } ); + var event = provider.verifyWebhook( raw, "inmemory-test-signature" ); + expect( event.getEventId() ).toBe( "evt_memory" ); + 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/ObservabilitySpec.cfc b/test-harness/tests/specs/unit/ObservabilitySpec.cfc new file mode 100644 index 0000000..6c18cb7 --- /dev/null +++ b/test-harness/tests/specs/unit/ObservabilitySpec.cfc @@ -0,0 +1,92 @@ +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.InMemoryProvider(); + 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( "cbpaymentsOnProviderStart" ); + expect( states ).toInclude( "cbpaymentsPreOperation" ); + expect( states ).toInclude( "cbpaymentsPostOperation" ); + expect( states ).toInclude( "cbpaymentsOnProviderShutdown" ); + 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.InMemoryProvider(); + 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( "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..d335e2f --- /dev/null +++ b/test-harness/tests/specs/unit/PaymentServiceSpec.cfc @@ -0,0 +1,206 @@ +component extends="coldbox.system.testing.BaseTestCase" { + + this.loadColdbox = true; + this.unLoadColdBox = false; + + function beforeAll(){ + super.beforeAll(); + setup(); + } + + function run(){ + describe( "PaymentService registry", function(){ + beforeEach( function(){ + service = new cbpayments.models.PaymentService(); + service.setWirebox( getController().getWireBox() ); + service.setModuleSettings( { + "defaultProvider" : "Primary", + "providers" : {}, + "providerTypes" : {} + } ); + service.registerProviderType( + "Memory", + "InMemoryProvider@cbpayments", + "cbpayments" + ); + } ); + + it( "looks up names case-insensitively while preserving display spelling", function(){ + service.register( "Primary", "Memory" ); + expect( service.has( "primary" ) ).toBeTrue(); + expect( service.names() ).toInclude( "Primary" ); + expect( service.provider( "PRIMARY" ).getName() ).toBe( "Primary" ); + expect( service.defaultProvider() ).toBe( service.provider( "primary" ) ); + } ); + + it( "fails duplicate provider names unless override is explicit", function(){ + service.register( "Primary", "Memory" ); + expect( function(){ + service.register( "primary", "Memory" ); + } ).toThrow( "cbpayments.DuplicateProvider" ); + var original = service.provider( "Primary" ); + service.register( "PRIMARY", "Memory", {}, true ); + expect( original.hasStarted() ).toBeFalse(); + expect( service.provider( "primary" ).getIdentifier() ).notToBe( original.getIdentifier() ); + } ); + + it( "validates the configured default and unknown lookups", function(){ + expect( function(){ + service.validateDefaultProvider(); + } ).toThrow( "cbpayments.InvalidConfiguration" ); + expect( function(){ + service.provider( "missing" ); + } ).toThrow( "cbpayments.UnknownProvider" ); + } ); + + it( "validates module settings and provider definition shapes", function(){ + service.setModuleSettings( { + "defaultProvider" : "Primary", + "providers" : {}, + "providerTypes" : {}, + "webhooks" : { "toleranceSeconds" : -1 }, + "logging" : { "includeProviderRequestIds" : true } + } ); + expect( function(){ + service.validateSettings(); + } ).toThrow( "cbpayments.InvalidConfiguration" ); + service.setModuleSettings( { + "defaultProvider" : "Primary", + "providers" : { "Primary" : { "provider" : "Memory", "properties" : "invalid" } }, + "providerTypes" : {}, + "webhooks" : { "toleranceSeconds" : 300 }, + "logging" : { "includeProviderRequestIds" : true } + } ); + expect( function(){ + service.registerAppProviders(); + } ).toThrow( "cbpayments.InvalidConfiguration" ); + } ); + + it( "tracks provider type ownership and reports collisions", function(){ + expect( function(){ + service.registerProviderType( "memory", "OtherProvider", "other-module" ); + } ).toThrow( "cbpayments.DuplicateProviderType" ); + expect( function(){ + service.unregisterProviderType( "Memory", "other-module" ); + } ).toThrow( "cbpayments.ProviderTypeOwnership" ); + expect( service.providerTypeDescriptors()[ 1 ] ).notToHaveKey( "properties" ); + } ); + + it( "shuts down configured instances before removing their provider type", function(){ + service.register( "Primary", "Memory" ); + var provider = service.provider( "Primary" ); + service.unregisterProviderType( "Memory", "cbpayments" ); + expect( provider.hasStarted() ).toBeFalse(); + expect( service.has( "Primary" ) ).toBeFalse(); + } ); + + it( "rejects objects that do not implement the base contract", function(){ + service.register( "Primary", "tests.resources.InvalidProvider" ); + expect( function(){ + service.provider( "Primary" ); + } ).toThrow( "cbpayments.InvalidProviderContract" ); + } ); + + it( "fails unsupported capabilities before invoking a provider method", function(){ + service.registerProviderType( + "Null", + "NullProvider@cbpayments", + "cbpayments" + ); + service.register( "Primary", "Null" ); + var paymentRequest = new cbpayments.models.contracts.requests.RefundRequest( + externalId = "pi_test", + idempotencyKey = "refund-test" + ); + expect( function(){ + service.createRefund( paymentRequest ); + } ).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.registerProviderType( + "Counting", + "tests.resources.CountingProvider", + "tests" + ); + service.register( "Primary", "Counting", { "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 ].provider( "Primary" ); + } + } + thread action="join" name=threadNames.toList(); + } finally { + server.delete( serviceKey ); + } + expect( counter.get() ).toBe( 1 ); + } ); + + it( "constructs different provider 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.registerProviderType( + "Counting", + "tests.resources.CountingProvider", + "tests" + ); + service.register( + "Slow", + "Counting", + { + "counter" : slowCounter, + "startupEnteredLatch" : enteredLatch, + "startupReleaseLatch" : releaseLatch + } + ); + service.register( + "Fast", + "Counting", + { "counter" : fastCounter } + ); + server[ serviceKey ] = service; + try { + thread name=slowThread action="run" serviceKey=serviceKey { + server[ attributes.serviceKey ].provider( "Slow" ); + } + expect( enteredLatch.await( 2, timeUnit ) ).toBeTrue(); + thread name=fastThread action="run" serviceKey=serviceKey { + server[ attributes.serviceKey ].provider( "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 provider", function(){ + service.register( "Primary", "Memory" ); + service.register( "Secondary", "Memory" ); + var first = service.provider( "Primary" ); + var second = service.provider( "Secondary" ); + service.shutdown(); + expect( first.hasStarted() ).toBeFalse(); + expect( second.hasStarted() ).toBeFalse(); + expect( service.count() ).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..d817a18 --- /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.InMemoryProvider().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..02ce679 --- /dev/null +++ b/test-harness/tests/specs/unit/RedactorSpec.cfc @@ -0,0 +1,45 @@ +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 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( "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..98a560b --- /dev/null +++ b/test-harness/tests/specs/unit/StripeWebhookSpec.cfc @@ -0,0 +1,206 @@ +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( 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 ); + } ); + } ); + } + + 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/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 +
From 0ea89d3bd108246d487dbf5fa2efd5ad4978774a Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Fri, 4 Sep 2026 21:35:24 -0600 Subject: [PATCH 2/4] fix(ci): exclude installed modules from format checks --- .github/workflows/pr.yml | 5 ----- box.json | 6 +++--- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 09f54e4..71a9c56 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -2,11 +2,6 @@ name: Pull requests on: pull_request: - push: - branches-ignore: - - main - - development - - "releases/v*" permissions: contents: read diff --git a/box.json b/box.json index 52c86b5..0b45f9e 100644 --- a/box.json +++ b/box.json @@ -35,9 +35,9 @@ "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,test-harness/modules,test-harness/tests,build/package-smoke-harness,ModuleConfig.cfc --overwrite", - "format:watch":"cfformat watch dsl,helpers,models,test-harness/modules,test-harness/tests,build/package-smoke-harness,ModuleConfig.cfc ./.cfformat.json", - "format:check":"cfformat check dsl,helpers,models,test-harness/modules,test-harness/tests,build/package-smoke-harness,ModuleConfig.cfc ./.cfformat.json", + "format":"cfformat run dsl,helpers,models,test-harness/modules/cbpayments-fixture,test-harness/tests,build/package-smoke-harness,ModuleConfig.cfc --overwrite", + "format:watch":"cfformat watch dsl,helpers,models,test-harness/modules/cbpayments-fixture,test-harness/tests,build/package-smoke-harness,ModuleConfig.cfc ./.cfformat.json", + "format:check":"cfformat check dsl,helpers,models,test-harness/modules/cbpayments-fixture,test-harness/tests,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", From 47fa2947775d3a11965d0377dd1c8e7a5bb4f9da Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sat, 5 Sep 2026 06:05:45 -0600 Subject: [PATCH 3/4] ci: run live Stripe contract on trusted PRs --- .github/workflows/live-stripe.yml | 5 +++++ .github/workflows/pr.yml | 7 +++++++ docs/cbpayments-architecture-plan.md | 4 ++-- docs/testing.md | 2 +- 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.github/workflows/live-stripe.yml b/.github/workflows/live-stripe.yml index 75dd580..80d7f4b 100644 --- a/.github/workflows/live-stripe.yml +++ b/.github/workflows/live-stripe.yml @@ -4,6 +4,11 @@ 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 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 71a9c56..27b0be1 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -14,6 +14,13 @@ jobs: tests: uses: ./.github/workflows/tests.yml + 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 }} + quality-and-package: name: Quality and packaged-consumer gate runs-on: ubuntu-latest diff --git a/docs/cbpayments-architecture-plan.md b/docs/cbpayments-architecture-plan.md index f406564..fef607d 100644 --- a/docs/cbpayments-architecture-plan.md +++ b/docs/cbpayments-architecture-plan.md @@ -459,7 +459,7 @@ ColdBox `be` runs on representative engines as allowed-failure experimental jobs ### 15.3 Live provider verification -A separate secret-bearing `workflow_dispatch` and scheduled job runs a minimal Stripe test-mode contract on one reference engine. It creates only self-cleaning test resources, uses unique run metadata/idempotency keys, never runs for fork PRs, and uploads scrubbed diagnostics. Live-provider failure blocks a release candidate but does not make ordinary PRs flaky. +A separate secret-bearing reusable workflow runs a minimal Stripe test-mode contract on trusted same-repository pull requests, on demand, and on a schedule. It creates only self-cleaning test resources, uses unique run metadata/idempotency keys, never runs for fork or Dependabot pull requests, and uploads scrubbed diagnostics. The mocked matrix remains the portable provider contract for every pull request, while a live-provider failure blocks a trusted pull request and any release candidate. ## 16. CI and publishing design @@ -477,7 +477,7 @@ Required checks: 6. secret-pattern scan over tracked source and the built archive; 7. test result and coverage artifacts, with an agreed threshold after the baseline suite exists. -PR workflows receive no ForgeBox, AWS, Stripe, or release credentials. +PR workflows receive no ForgeBox, AWS, or release credentials. A dedicated live job on trusted same-repository pull requests receives only the Stripe test-mode API key; fork and Dependabot pull requests skip that job and cannot receive the secret. ### 16.2 Daily/scheduled tests diff --git a/docs/testing.md b/docs/testing.md index b7d4bf8..9d5ac3b 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -6,4 +6,4 @@ Use `Null` only where payments are intentionally disabled. It advertises hosted The repository suite covers registry validation, case-insensitive lookup, override/unregister, ownership, 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, error classes, request IDs, client-secret isolation, signature rotation/tolerance, malformed bodies, account mismatch, duplicates/out-of-order events, and real stripe-cfml HMAC verification. -Required CI runs ColdBox 8 on BoxLang 1 native and CFML compatibility, Lucee 6/7, and Adobe ColdFusion 2023/2025. Required PR tests never contact Stripe. The separate secret-bearing workflow supplies `STRIPE_API_KEY`, runs the live test-mode contract, and scrubs artifacts. Webhook verification uses a per-run in-memory signing secret and makes no webhook network call. +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. From 27fff8ad4dd481d65e3755e729baca7c54dd2c1a Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Sat, 5 Sep 2026 08:22:15 -0600 Subject: [PATCH 4/4] feat: refine processor API and add MONEI provider --- .github/workflows/pr.yml | 8 +- .github/workflows/release.yml | 2 +- .gitignore | 2 + AGENTS.md | 3 +- ModuleConfig.cfc | 67 +- box.json | 11 +- build/Build.cfc | 54 +- build/package-provider.sh | 43 ++ .../package-smoke-harness/config/Coldbox.cfc | 10 +- build/package-smoke-harness/handlers/Main.cfc | 10 +- build/package-smoke.sh | 33 +- build/secret-scan.sh | 8 +- build/validate-package.sh | 11 +- changelog.md | 7 +- docs/cbpayments-architecture-plan.md | 662 ---------------- docs/compatibility.md | 2 +- docs/configuration.md | 97 ++- docs/contracts.md | 127 ++++ docs/custom-providers.md | 154 +++- docs/monei.md | 97 +++ docs/providers.md | 135 +++- docs/releasing.md | 9 - docs/security.md | 8 +- docs/stripe.md | 179 ++++- docs/testing.md | 6 +- docs/webhooks.md | 128 +++- dsl/cbpaymentsDSL.cfc | 12 +- helpers/Mixins.cfm | 12 +- models/PaymentService.cfc | 435 ++++------- models/contracts/IPaymentProvider.cfc | 6 +- models/contracts/WebhookEventTypes.cfc | 58 ++ models/contracts/results/PaymentEvent.cfc | 16 +- models/contracts/results/PaymentResult.cfc | 8 +- models/providers/AbstractPaymentProvider.cfc | 53 +- ...{InMemoryProvider.cfc => MockProvider.cfc} | 30 +- models/providers/StripeProvider.cfc | 71 +- models/testing/ProviderContract.cfc | 4 +- models/util/Redactor.cfc | 6 + providers/cbpayments-monei/ModuleConfig.cfc | 18 + providers/cbpayments-monei/README.md | 17 + providers/cbpayments-monei/box.json | 29 + .../cbpayments-monei/models/MoneiClient.cfc | 131 ++++ .../cbpayments-monei/models/MoneiProvider.cfc | 719 ++++++++++++++++++ readme.md | 108 +-- test-harness/Application.cfc | 1 + test-harness/config/Coldbox.cfc | 12 +- .../cbpayments-fixture/ModuleConfig.cfc | 12 +- .../models/FixtureProvider.cfc | 2 +- test-harness/monei-http-stub.cfm | 25 + test-harness/tests/Application.cfc | 1 + .../tests/resources/CountingProvider.cfc | 4 +- .../tests/resources/FakeMoneiClient.cfc | 58 ++ .../integration/ModuleIntegrationSpec.cfc | 63 +- .../tests/specs/unit/ContractsSpec.cfc | 14 +- ...yProviderSpec.cfc => MockProviderSpec.cfc} | 12 +- .../tests/specs/unit/MoneiClientSpec.cfc | 48 ++ .../tests/specs/unit/MoneiProviderSpec.cfc | 301 ++++++++ .../tests/specs/unit/ObservabilitySpec.cfc | 9 +- .../tests/specs/unit/PaymentServiceSpec.cfc | 164 ++-- .../tests/specs/unit/ProviderContractSpec.cfc | 2 +- .../tests/specs/unit/RedactorSpec.cfc | 3 +- .../tests/specs/unit/StripeWebhookSpec.cfc | 55 ++ .../specs/unit/WebhookEventTypesSpec.cfc | 17 + 63 files changed, 3010 insertions(+), 1399 deletions(-) create mode 100755 build/package-provider.sh delete mode 100644 docs/cbpayments-architecture-plan.md create mode 100644 docs/contracts.md create mode 100644 docs/monei.md delete mode 100644 docs/releasing.md create mode 100644 models/contracts/WebhookEventTypes.cfc rename models/providers/{InMemoryProvider.cfc => MockProvider.cfc} (90%) create mode 100644 providers/cbpayments-monei/ModuleConfig.cfc create mode 100644 providers/cbpayments-monei/README.md create mode 100644 providers/cbpayments-monei/box.json create mode 100644 providers/cbpayments-monei/models/MoneiClient.cfc create mode 100644 providers/cbpayments-monei/models/MoneiProvider.cfc create mode 100644 test-harness/monei-http-stub.cfm create mode 100644 test-harness/tests/resources/FakeMoneiClient.cfc rename test-harness/tests/specs/unit/{InMemoryProviderSpec.cfc => MockProviderSpec.cfc} (91%) create mode 100644 test-harness/tests/specs/unit/MoneiClientSpec.cfc create mode 100644 test-harness/tests/specs/unit/MoneiProviderSpec.cfc create mode 100644 test-harness/tests/specs/unit/WebhookEventTypesSpec.cfc diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 27b0be1..966eae5 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -43,14 +43,18 @@ jobs: run: | build/validate-package.sh box run-script format:check - npx --yes markdownlint-cli@0.45.0 'readme.md' 'changelog.md' 'docs/**/*.md' + 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 + 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: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0c30003..2426c7b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -107,7 +107,7 @@ jobs: run: | build/validate-package.sh box run-script format:check - npx --yes markdownlint-cli@0.45.0 'readme.md' 'changelog.md' 'docs/**/*.md' + 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 diff --git a/.gitignore b/.gitignore index 3f6865c..b8a3776 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ .artifacts/** .tmp/** .DS_Store +/test-results.json +/test-harness/tests/results/ # Engine + Secrets .env diff --git a/AGENTS.md b/AGENTS.md index 3e62c9b..d865ac7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Architecture boundaries -- `PaymentService@cbpayments` owns provider-type registration, named provider definitions, lazy construction, capability dispatch, and lifecycle. +- `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. @@ -22,6 +22,7 @@ - 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 diff --git a/ModuleConfig.cfc b/ModuleConfig.cfc index 34cd6b3..798df03 100644 --- a/ModuleConfig.cfc +++ b/ModuleConfig.cfc @@ -6,7 +6,7 @@ component { this.title = "cbpayments"; this.author = "Ortus Solutions"; this.webURL = "https://github.com/coldbox-modules/cbpayments"; - this.description = "Provider-neutral named payment services for ColdBox applications"; + this.description = "Provider-neutral named payment Processors for ColdBox applications"; this.version = "@build.version@+@build.number@"; this.modelNamespace = "cbpayments"; this.cfmapping = "cbpayments"; @@ -14,25 +14,38 @@ component { this.applicationHelper = [ "helpers/Mixins.cfm" ]; function configure(){ + var moduleInvocationPath = reReplace( + getMetadata( this ).name, + "[.]ModuleConfig$", + "" + ); + var webhookInterceptionPoints = createObject( + "component", + "#moduleInvocationPath#.models.contracts.WebhookEventTypes" + ).init().interceptionPoints(); settings = { - "defaultProvider" : "default", - "providers" : { "default" : { "provider" : "Null", "properties" : {} } }, - "providerTypes" : {}, - "webhooks" : { "toleranceSeconds" : 300 }, - "logging" : { "includeProviderRequestIds" : true } + "defaultProcessor" : "default", + "processors" : { + "default" : { + "provider" : "NullProvider@cbpayments", + "properties" : {} + } + }, + "webhooks" : { "toleranceSeconds" : 300 }, + "logging" : { "includeProviderRequestIds" : true } }; - interceptorSettings = { - customInterceptionPoints : [ - "cbpaymentsOnProviderStart", - "cbpaymentsOnProviderShutdown", - "cbpaymentsPreOperation", - "cbpaymentsPostOperation", - "cbpaymentsOnOperationFailure", - "cbpaymentsOnWebhookVerified", - "cbpaymentsOnWebhookRejected" - ] - }; + var customInterceptionPoints = [ + "cbpaymentsOnProcessorStart", + "cbpaymentsOnProcessorShutdown", + "cbpaymentsPreOperation", + "cbpaymentsPostOperation", + "cbpaymentsOnOperationFailure", + "cbpaymentsOnWebhookVerified", + "cbpaymentsOnWebhookRejected" + ]; + customInterceptionPoints.append( webhookInterceptionPoints, true ); + interceptorSettings = { customInterceptionPoints : customInterceptionPoints }; wirebox.registerDSL( "cbpayments", "#moduleMapping#.dsl.cbpaymentsDSL" ); } @@ -41,24 +54,8 @@ component { var paymentService = wirebox.getInstance( "PaymentService@cbpayments" ); paymentService .validateSettings() - .registerProviderType( - "InMemory", - "InMemoryProvider@cbpayments", - "cbpayments" - ) - .registerProviderType( - "Null", - "NullProvider@cbpayments", - "cbpayments" - ) - .registerProviderType( - "Stripe", - "StripeProvider@cbpayments", - "cbpayments" - ) - .registerAppProviderTypes() - .registerAppProviders() - .validateDefaultProvider(); + .registerAppProcessors() + .validateDefaultProcessor(); } function afterAspectsLoad( event, interceptData, rc, prc, buffer ){ diff --git a/box.json b/box.json index 0b45f9e..bb8fe9a 100644 --- a/box.json +++ b/box.json @@ -7,10 +7,10 @@ "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 services for ColdBox applications", + "shortDescription" : "Provider-neutral named payment Processors for ColdBox applications", "slug" : "cbpayments", "type" : "modules", - "keywords":"payments,stripe,checkout,payment intents,webhooks,coldbox", + "keywords":"payments,stripe,monei,checkout,payment intents,webhooks,coldbox", "license" : [ { "type" : "Apache2", "url" : "http://www.apache.org/licenses/LICENSE-2.0.html" } ], @@ -27,6 +27,7 @@ "ignore":[ "**/.*", "test-harness", + "/providers", "/server*.json" ], "scripts":{ @@ -35,9 +36,9 @@ "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,test-harness/modules/cbpayments-fixture,test-harness/tests,build/package-smoke-harness,ModuleConfig.cfc --overwrite", - "format:watch":"cfformat watch dsl,helpers,models,test-harness/modules/cbpayments-fixture,test-harness/tests,build/package-smoke-harness,ModuleConfig.cfc ./.cfformat.json", - "format:check":"cfformat check dsl,helpers,models,test-harness/modules/cbpayments-fixture,test-harness/tests,build/package-smoke-harness,ModuleConfig.cfc ./.cfformat.json", + "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", diff --git a/build/Build.cfc b/build/Build.cfc index 0708398..b732a55 100644 --- a/build/Build.cfc +++ b/build/Build.cfc @@ -12,17 +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", @@ -44,10 +47,7 @@ component { } ); // Create Mappings - fileSystemUtil.createMapping( - "coldbox", - variables.cwd & "test-harness/coldbox" - ); + fileSystemUtil.createMapping( "coldbox", variables.cwd & "test-harness/coldbox" ); return this; } @@ -56,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, @@ -100,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(); @@ -117,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, @@ -139,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( @@ -189,10 +182,7 @@ component { ); // Copy box.json for convenience - fileCopy( - "#variables.projectBuildDir#/box.json", - variables.exportsDir - ); + fileCopy( "#variables.projectBuildDir#/box.json", variables.exportsDir ); } /** @@ -301,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/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/config/Coldbox.cfc b/build/package-smoke-harness/config/Coldbox.cfc index 3616475..df67c7c 100644 --- a/build/package-smoke-harness/config/Coldbox.cfc +++ b/build/package-smoke-harness/config/Coldbox.cfc @@ -9,8 +9,14 @@ component { }; moduleSettings = { cbpayments : { - defaultProvider : "memory", - providers : { memory : { provider : "InMemory", properties : {} } } + 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/handlers/Main.cfc b/build/package-smoke-harness/handlers/Main.cfc index 46f324b..d2ce871 100644 --- a/build/package-smoke-harness/handlers/Main.cfc +++ b/build/package-smoke-harness/handlers/Main.cfc @@ -3,14 +3,16 @@ component { property name="paymentService" inject="PaymentService@cbpayments"; function index( event, rc, prc ){ - var provider = paymentService.defaultProvider(); + var processorName = rc.keyExists( "processor" ) ? rc.processor : "mock"; + var processor = paymentService.processor( processorName ); return event.renderData( type = "json", data = { - status : "cbpayments-package-smoke-ok", - providerType : provider.getProviderType(), - capabilities : paymentService.capabilities( "memory" ) + 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.sh b/build/package-smoke.sh index dbc3352..a5981de 100755 --- a/build/package-smoke.sh +++ b/build/package-smoke.sh @@ -4,6 +4,7 @@ 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)" @@ -21,6 +22,8 @@ required_entries=( "box.json" "LICENSE" "models/PaymentService.cfc" + "models/contracts/WebhookEventTypes.cfc" + "models/providers/MockProvider.cfc" "models/providers/StripeProvider.cfc" "docs/security.md" ) @@ -33,11 +36,19 @@ for required_entry in "${required_entries[@]}"; do fi done -if grep -Eq '(^|/)(\.git|\.engine|test-harness|modules|build)(/|$)' <<<"${archive_listing}"; then +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 @@ -56,14 +67,28 @@ 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' 2>>"${smoke_log}")"; then - if grep -Fq 'cbpayments-package-smoke-ok' <<<"${response}"; then - echo "Packaged cbpayments resolved and executed successfully." + 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 diff --git a/build/secret-scan.sh b/build/secret-scan.sh index ada567b..0a2bf20 100755 --- a/build/secret-scan.sh +++ b/build/secret-scan.sh @@ -3,7 +3,7 @@ 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,}|whsec_[A-Za-z0-9]{24,})' +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 @@ -22,7 +22,7 @@ while IFS= read -r -d '' source_file; do done < <(git ls-files --cached --others --exclude-standard -z) if [[ ${found_secret} -eq 1 ]]; then - echo "A Stripe-shaped secret was found in source selected for version control." >&2 + echo "A payment Provider-shaped secret was found in source selected for version control." >&2 exit 1 fi @@ -32,9 +32,9 @@ if [[ -n "${archive}" ]]; then exit 1 fi if unzip -p "${archive}" | LC_ALL=C grep -a -E "${patterns}"; then - echo "A Stripe-shaped secret was found in the release archive." >&2 + echo "A payment Provider-shaped secret was found in the release archive." >&2 exit 1 fi fi -echo "No Stripe-shaped secrets found." +echo "No payment Provider-shaped secrets found." diff --git a/build/validate-package.sh b/build/validate-package.sh index 773d70f..a16c290 100755 --- a/build/validate-package.sh +++ b/build/validate-package.sh @@ -21,7 +21,16 @@ jq -e ' .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 "Package metadata is valid and dependency pins are exact." +echo "Core and Provider package metadata is valid and dependency pins are exact." diff --git a/changelog.md b/changelog.md index a02312b..db586cc 100644 --- a/changelog.md +++ b/changelog.md @@ -11,8 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Named default and custom payment providers with lazy lifecycle, ownership, module contributions, and WireBox DSL access. +- 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. -- InMemory, Null, and isolated Stripe providers for Checkout Sessions, Payment Intents, capture, refunds, Setup Intents, customers, and signed webhooks. +- 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, documentation, and release recovery runbook. +- ColdBox 8 engine matrix, packaging smoke test, live Stripe workflow, and comprehensive Provider/API documentation. diff --git a/docs/cbpayments-architecture-plan.md b/docs/cbpayments-architecture-plan.md deleted file mode 100644 index fef607d..0000000 --- a/docs/cbpayments-architecture-plan.md +++ /dev/null @@ -1,662 +0,0 @@ -# cbpayments architecture and delivery plan - -Status: Phases 0–5 implemented locally; feature PR to the existing `development` branch, live Stripe CI, and Phase 6 publication pending - -Plan date: 2026-09-04 - -Target repository: `coldbox-modules/cbpayments` - -Working branch: `feature/named-payment-providers` - -## 1. Purpose - -`cbpayments` will be a provider-neutral ColdBox payment module. It will give an application one stable service for default and named payment providers while allowing each provider instance to use separate credentials, accounts, API versions, webhook secrets, and environments. Installed ColdBox modules may contribute new provider types without changing or submitting code to the cbpayments repository. - -The intended analogy is the rest of the ColdBox ecosystem: - -- CBFS provides named disks over interchangeable storage providers. -- cbMailServices provides named mailers over interchangeable delivery protocols. -- `cbpayments` provides named payment providers over processor-specific protocol libraries. - -This is not a Stripe-specific Cashier clone. It is infrastructure like mail, cache, or storage. Applications retain their own customers, invoices, balances, subscriptions, entitlements, accounting rules, authorization, and durable payment ledger. - -## 2. Decisions already made - -1. Preserve the existing official `coldbox-modules/cbpayments` repository, ForgeBox slug, and package identity. -2. Build from the clean `main`/current module-template baseline on `feature/named-payment-providers` rather than extending the unreleased 2024 `development` implementation. -3. Use a CBFS-style registry with one default provider and any number of named provider definitions. -4. Permit multiple named instances of the same provider type. Two Stripe definitions must produce isolated Stripe clients and may point to different Stripe accounts or test/live environments. -5. Combine CBFS's lazy registry/lifecycle with cbMailServices' approachable service façade, named protocol configuration, custom protocols, and in-memory/null testing implementations. -6. Use small capability contracts instead of requiring every processor to implement one oversized interface. -7. Define a public provider-type service-provider interface so separately installed first-party or community ColdBox modules can register aliases such as `AuthorizeNet` and be selected from ordinary cbpayments configuration. -8. First-party and third-party providers use the same registration, lifecycle, capability, result, security, and contract-test APIs. The core service contains no provider-name conditionals or private extension hooks. -9. Ship a first-party Stripe provider first, backed by the maintained `stripecfml` package. As of this plan, ForgeBox reports `stripecfml` 4.1.0 as current; implementation must re-verify and pin the current tested release. -10. Prefer a mature, actively maintained API library inside each provider adapter instead of reimplementing that provider's HTTP API in cbpayments. The adapter owns normalization; the library owns protocol transport. -11. Prefer hosted Checkout Sessions for ordinary one-time web payments. Expose Payment Intents for off-session or independently modeled payment state, Setup Intents for saving methods, and Stripe Billing plus Checkout for subscriptions. Do not build new work on Charges, Sources, Tokens, legacy Card Element, or manual recurring-payment loops. -12. Keep raw processor SDKs available only through an explicit provider escape hatch. Normal application behavior uses normalized requests and results. -13. Treat webhook verification as a provider responsibility and durable event storage/business processing as an application responsibility. - -## 3. Source baseline and lessons - -This plan was checked against these source snapshots on 2026-09-04: - -| Source | Revision | Adopt | Deliberate divergence | -| --- | --- | --- | --- | -| `coldbox-modules/cbfs` | `cf9c882` | Named definitions, default lookup, dynamic registration, lazy thread-safe construction, lifecycle shutdown, core/custom provider resolution, optional module-owned registrations, injection DSL | Payment providers expose capabilities rather than one broad storage-style interface | -| `coldbox-modules/cbmailservices` `development` | `037041b` | Default/named protocols, application-friendly service façade, custom protocols, interception points, in-memory/null protocols, current ColdBox 8 engine matrix | Provider instances should be lazy; results and logs must be more strongly normalized and redacted for payment data | -| `coldbox-modules/module-template` | `b610c6f` | Repository layout, test harness, formatting, daily/PR/snapshot/release workflow separation, build task, API-doc and binary publication | Harden action pinning, package smoke tests, stable-release gates, and post-publication verification | -| `cbpayments` `main` | `91c0f8f` | Official repository identity | Replace untouched template content with the current module template before implementation | -| `cbpayments` legacy `development` | `626c436` | Research material and candidate Stripe mapping tests only | Do not inherit the singleton Stripe injection, 19-method processor interface, empty providers, raw `{ error, content }` response, deprecated Charges-centric flow, or unfiltered debug logging | - -The legacy branch is not a compatibility contract because it was never released. Reuse from it requires a current API check and a new contract test. - -## 4. Repository and branch bootstrap - -Phase 0 will make the repository look and behave like a current ColdBox module before adding payment behavior. - -- Apply the current `coldbox-modules/module-template` structure and substitute real cbpayments metadata. -- Keep the Apache 2 license, code of conduct, security policy, contribution guide, issue/PR templates, editor configuration, formatter/linter settings, server descriptors, test harness, build tasks, and standard scripts. -- Use `cbpayments` as the module name, model namespace, CF mapping, ForgeBox slug, artifact basename, API-doc path, and WireBox namespace. -- Keep `main` as the stable-release branch and the existing `development` branch as the snapshot/integration branch. -- Deliver the new implementation through a normal feature PR into `development`; do not archive, recreate, or rewrite `development` history. -- Work and PR checks remain on `feature/named-payment-providers`; no release workflow may publish this branch. -- Add an `AGENTS.md` describing the service/provider boundaries, supported commands, security rules, and files that own each contract. - -Bootstrap acceptance: - -- a clean install starts the test harness on every required engine; -- formatting is idempotent and `git diff --check` passes; -- package metadata validates; -- the built ZIP installs into a separate clean ColdBox harness and loads `PaymentService@cbpayments`; -- no template tokens or unrelated sample code remain; -- no snapshot or stable package is published prematurely. - -## 5. Public vocabulary and configuration - -Use **provider** for a configured payment backend and **provider type** for its implementation. Avoid `processor` in the public API because payment service providers expose more than transaction processing. - -```boxlang -moduleSettings.cbpayments = { - "defaultProvider" : "primaryReceivables", - "providers" : { - "primaryReceivables" : { - "provider" : "Stripe", - "properties" : { - "apiKey" : getSystemSetting( "PRIMARY_STRIPE_API_KEY" ), - "webhookSecrets" : [ getSystemSetting( "PRIMARY_STRIPE_WEBHOOK_SECRET" ) ], - "apiVersion" : "2026-02-25.clover", - "defaultCurrency" : "usd", - "connectAccount" : "" - } - }, - "secondaryReceivables" : { - "provider" : "Stripe", - "properties" : { - "apiKey" : getSystemSetting( "SECONDARY_STRIPE_API_KEY" ), - "webhookSecrets" : [ getSystemSetting( "SECONDARY_STRIPE_WEBHOOK_SECRET" ) ], - "apiVersion" : "2026-02-25.clover", - "defaultCurrency" : "usd" - } - } - }, - "webhooks" : { - "toleranceSeconds" : 300 - }, - "logging" : { - "includeProviderRequestIds" : true - } -}; -``` - -Configuration rules: - -- `defaultProvider` must name a registered definition or module startup fails with a typed configuration exception. -- A provider definition requires `provider` and may contain `properties`; unknown top-level keys fail validation. -- Registered provider aliases such as `Stripe`, `AuthorizeNet`, `InMemory`, and `Null` resolve through the provider-type registry. A fully qualified class or WireBox ID remains an escape hatch for an unaliased custom provider. -- Duplicate names fail by default. Runtime registration requires an explicit `override=true` and shuts down an existing instantiated provider before replacement. -- Provider names are case-insensitive for lookup but preserve their configured display spelling. -- Application provider names are global. A dependent ColdBox module may declare `settings.cbpayments.providers`, which are registered as `name@ModuleName`; any requested `globalProviders` require explicit opt-in and obey duplicate-name failure. -- Secrets are resolved at application startup and passed only to the selected provider. Registry inspection never returns secret-bearing properties. -- The same provider type may be configured repeatedly; instances, credentials, defaults, webhook secrets, and SDK clients may not bleed between names. - -An installable provider module contributes a provider type; the consuming application still supplies its own named instances and credentials. For example, a community `cbpayments-authorizenet` module can declare: - -```boxlang -component { - this.dependencies = [ "cbpayments" ]; - - function configure() { - settings.cbpayments = { - "providerTypes" : { - "AuthorizeNet" : { - "provider" : "AuthorizeNetProvider@cbpayments-authorizenet" - } - } - }; - } -} -``` - -After installing that module, the application uses the alias in normal cbpayments configuration: - -```bash -box install cbpayments-authorizenet -``` - -```boxlang -moduleSettings.cbpayments.providers.authorizeNetReceivables = { - "provider" : "AuthorizeNet", - "properties" : { - "apiLoginId" : getSystemSetting( "AUTHORIZENET_API_LOGIN_ID" ), - "transactionKey" : getSystemSetting( "AUTHORIZENET_TRANSACTION_KEY" ), - "environment" : "production" - } -}; -``` - -## 6. Core architecture - -### 6.1 `PaymentService@cbpayments` - -The singleton, thread-safe service owns two related registries: - -- the **provider-type registry** maps implementation aliases to provider classes or WireBox IDs and records the contributing module; -- the **configured-provider registry** maps application-defined instance names to a provider type, properties, and a lazily created instance. - -Its public registry API follows CBFS naming closely: - -```boxlang -paymentService.defaultProvider() -paymentService.provider( "primaryReceivables" ) -paymentService.register( name, provider, properties = {}, override = false ) -paymentService.unregister( name ) -paymentService.has( name ) -paymentService.missing( name ) -paymentService.names() -paymentService.count() -paymentService.supports( name, capability ) -paymentService.capabilities( name ) -paymentService.registerProviderType( name, provider, owner = "application", override = false ) -paymentService.unregisterProviderType( name, owner ) -paymentService.hasProviderType( name ) -paymentService.providerTypeNames() -paymentService.shutdown() -``` - -`provider(name)` resolves its provider alias through the type registry, lazily constructs once under a name-specific lock, validates the base contract, calls `startup(name, properties)`, caches the instance, and returns it. `unregister()` and module shutdown call the provider's `shutdown()` only when it was instantiated. - -Provider-type records contain only construction metadata: alias, class/WireBox ID, owner module, extension version, and optional declared capabilities. They never contain application credentials. Registration collisions fail with both owners identified; `override=true` is reserved for explicit application/test configuration and may not silently replace an installed provider module. - -The service also offers thin convenience operations that select a provider by name or default and delegate to the corresponding capability. It does not contain processor-specific conditionals. - -### 6.2 WireBox and helper access - -- Canonical service ID: `PaymentService@cbpayments`. -- Custom DSL: `cbpayments:` injects one named provider; `cbpayments` or `cbpayments:default` injects the default provider. -- A minimal application helper may expose `getPaymentProvider(name)` and `getDefaultPaymentProvider()`. Avoid adding global helper methods for every payment operation. -- Providers are constructed as transients owned by the registry, even when the underlying protocol library is installed as a ColdBox module. - -### 6.3 Provider base contract - -Every provider implements a small lifecycle/identity contract: - -```boxlang -interface IPaymentProvider { - any function startup( required string name, struct properties = {} ); - any function shutdown(); - string function getName(); - string function getType(); - array function capabilities(); - boolean function supports( required string capability ); - any function getClient(); -} -``` - -`getClient()` is the documented escape hatch. It returns the underlying SDK/client and is intentionally non-portable. Its use should be isolated behind an application adapter and covered by provider-specific tests. - -An `AbstractPaymentProvider` centralizes lifecycle state, property access, safe configuration validation, capability advertisement, redaction helpers, and normalized error creation. It does not supply fake implementations for unsupported operations. - -### 6.4 Installable provider modules - -An external provider is an ordinary ColdBox module installed beside cbpayments. It must: - -- declare `cbpayments` as a module dependency so load order is deterministic; -- declare one or more provider types in `settings.cbpayments.providerTypes`, or use the equivalent registration API for a genuinely dynamic case; -- expose each provider as a WireBox ID or fully qualified class implementing `IPaymentProvider` and its advertised capability interfaces; -- keep its processor-specific API library in its own `box.json` dependencies; -- unregister its owned provider types during module unload through `PaymentService`, which first shuts down configured instances of those types; -- publish its compatibility range for cbpayments, ColdBox, engines, and its upstream API library; -- run the shared cbpayments provider contract kit before release. - -cbpayments discovers declarative provider-type contributions after application modules load and tracks ownership for unload/reinit. Installing a provider module never creates a configured payment account by itself; the application must opt in by naming the provider alias and supplying properties. - -Recommended package conventions: - -- ForgeBox slug/module name: `cbpayments-`; -- WireBox ID: `Provider@cbpayments-`; -- dependency: a compatible released range of `cbpayments`, plus an exact tested upstream API-library version; -- public docs: installation, capabilities, configuration schema, normalized mappings, provider-specific options, webhook setup, upstream provenance, and support policy. - -Official providers may be bundled with cbpayments or released from a `coldbox-modules/cbpayments-` sibling repository. Either form registers through this same service-provider interface. The first-party Stripe provider may ship in the 1.0 distribution for convenience, but it receives no privileged construction path; extracting it later to an official extension must not require changing `provider = "Stripe"` application configuration. - -Community provider aliases are not an endorsement or sandbox. Installed ColdBox modules execute with application privileges. Documentation distinguishes first-party and community support, and cbpayments never downloads provider code dynamically at runtime. - -## 7. Capability contracts - -Capabilities keep portability honest. A provider advertises only what it implements; requesting an absent capability raises `cbpayments.UnsupportedCapability` before any network call. - -| Capability | Initial normalized operations | Initial delivery | -| --- | --- | --- | -| `hostedCheckout` | create, retrieve, expire a hosted checkout session | Required for Stripe 1.0 | -| `paymentIntents` | create, retrieve, confirm when server-side confirmation is valid, cancel | Required for Stripe 1.0 | -| `capture` | capture a previously authorized payment | Required for Stripe 1.0 | -| `refunds` | create and retrieve a full or partial refund | Required for Stripe 1.0 | -| `setupIntents` | create, retrieve, cancel a setup intent | Required for Stripe 1.0 | -| `customers` | create, retrieve, update, delete provider customers | Planned for Stripe 1.0 if required by Setup Intents; otherwise first compatible minor | -| `billing` | create/retrieve/cancel subscriptions through provider billing primitives; expose provider portal/session links where available | Post-1.0 capability epic | -| `webhooks` | verify signature and normalize an event envelope | Required for Stripe 1.0 | - -Each capability is a separate interface under `models/contracts/capabilities/`. Operation-specific request objects prevent a provider-neutral method from accumulating every provider's optional arguments. - -Provider-specific features remain possible through either: - -- an allow-listed `providerOptions` struct on the relevant request, namespaced by provider type; or -- the explicit `getClient()` escape hatch. - -Portable application code must not depend on either. - -## 8. Request, result, money, and error contracts - -### 8.1 Money - -`Money` contains an integer `amountMinor` and lowercase ISO 4217 `currency`. The module never accepts floating-point major-unit amounts in a provider operation. It validates zero-decimal and special currencies through a tested currency metadata table rather than assuming every currency has two decimals. - -### 8.2 Requests - -Requests are operation-specific, validated value objects or documented structs. Common fields are: - -- `money`, when the operation transfers value; -- `idempotencyKey` for every mutating provider call; -- `description` and allow-listed scalar `metadata`; -- `customerId`, `paymentMethodId`, or prior `externalId` only where relevant; -- `returnUrl`/`cancelUrl` for hosted flows; -- `providerOptions` as the explicit non-portable extension point. - -Mutating operations reject an empty idempotency key by default. A caller may explicitly opt out only for a provider operation proven not to support one, and the result reports that the request was not idempotency-protected. - -### 8.3 Results - -All normal operations return a typed `PaymentResult` with a stable serialized shape: - -```boxlang -{ - "ok" : true, - "operation" : "hostedCheckout.create", - "providerName" : "primaryReceivables", - "providerType" : "Stripe", - "status" : "pending", - "externalId" : "...", - "requestId" : "...", - "idempotencyKey" : "...", - "createdAt" : "...", - "amount" : { "amountMinor" : 10000, "currency" : "usd" }, - "nextAction" : { "type" : "redirect", "redirectUrl" : "..." }, - "failure" : {}, - "providerDetails" : {} -} -``` - -The exact fields vary by operation, but the envelope does not. `providerDetails` is created by an adapter-specific allow-list and never contains an unfiltered provider response. - -Normalized statuses include `requires_action`, `pending`, `authorized`, `succeeded`, `failed`, `cancelled`, `partially_refunded`, and `refunded`. Unknown provider states map to `unknown` while retaining only a safe provider status code. - -### 8.4 Failures and exceptions - -- Invalid configuration, invalid local arguments, unknown providers, unsupported capabilities, and programmer errors throw typed `cbpayments.*` exceptions before a provider call. -- Expected provider outcomes return `ok=false` with a failure category such as `declined`, `validation`, `authentication`, `rate_limited`, `network`, `provider`, or `unknown`. -- Failures include a safe code, safe user-neutral message, `retryable`, and an optional decline category. They never include credentials, signatures, tokens, client secrets, raw bodies, stack traces, or full request/response objects. -- Unexpected adapter defects are wrapped in a scrubbed `cbpayments.ProviderException` whose cause is available to trusted diagnostics without being serialized or logged by default. - -## 9. Provider protocol and API libraries - -### 9.1 Reuse policy - -A provider adapter should wrap an existing API library when that library is suitable. Before adoption, record: - -- upstream repository, license, current release, maintenance activity, and release process; -- supported BoxLang/CFML engines and Java versions; -- coverage of the required modern provider APIs, idempotency headers, request IDs, timeouts, and signed webhooks; -- ability to create isolated clients for multiple named configurations; -- testability through an injectable client or transport; -- response/error behavior and whether secrets or bodies are logged; -- the exact version tested by the provider's compatibility matrix. - -The provider adapter translates between cbpayments contracts and that library. It does not copy the upstream client into cbpayments or leak upstream response shapes into normalized results. Upstream upgrades arrive through reviewed dependency PRs and must rerun the adapter, engine, webhook, redaction, and package suites. - -If no acceptable library exists, the provider module may own a narrow HTTP client for only its supported capabilities. That client remains outside cbpayments core, has injectable transport, finite timeouts, idempotency support, redacted diagnostics, fixtures, and a documented maintenance owner. Vendoring or forking an abandoned client into cbpayments is a last resort requiring an explicit maintenance/security decision. - -### 9.2 First-party Stripe provider - -The Stripe adapter lives below the capability contracts and uses `stripecfml` only as transport/API protocol. - -- Pin the exact `stripecfml` release validated by the adapter suite; begin evaluation with 4.1.0. -- Re-verify the current Stripe API version during implementation and pin it in tests and examples. As of this plan the current version is `2026-02-25.clover`. -- Construct one Stripe client per named cbpayments provider. Do not inject the global `stripe@stripecfml` singleton because that would couple all names to one configuration. -- Pass idempotency keys through on every supported mutating request and preserve Stripe request IDs in safe results/log context. -- Use Checkout Sessions for the ordinary on-session/hosted payment path. -- Use Payment Intents only for off-session or independently modeled payment state. -- Use Setup Intents for saving methods. Do not expose Sources or Tokens as normalized capabilities. -- Use Stripe Billing and Checkout for subscription work; do not implement a local renewal loop or legacy Plan-object abstraction. -- Let Stripe select dynamic payment methods unless a documented business/compliance constraint requires an explicit list. -- Support Stripe Connect scoping through provider definition or explicit request context without pretending connected-account identifiers are portable. -- Keep `convertToCents=false`; cbpayments owns integer minor-unit handling. -- Translate Stripe responses through per-operation allow-lists. The old `{ error, content }` wrapper and Charges-centric methods are not carried forward. -- Register the `Stripe` alias through the same provider-type registry used by external modules. A registry integration spec must prove that a separately packaged fixture can contribute a provider type without changing core service code; the packaging boundary may change without changing application configuration. - -## 10. Webhook boundary - -`WebhookProvider` accepts the exact raw request body and relevant headers, verifies the signature before parsing, and returns a normalized `PaymentEvent` envelope: - -- `eventId`, `eventType`, `occurredAt`, `livemode`; -- `providerName`, `providerType`, safe provider account identifier; -- `objectType`, `objectId`, normalized status and amount when known; -- safe, allow-listed provider details; -- a deterministic payload checksum for application-level duplicate detection. - -Rules: - -- Verification accepts an ordered list of active webhook secrets to allow safe key rotation and records only which key index matched. -- Timestamp tolerance is configurable with a secure default and is tested on both sides of the boundary. -- Invalid signatures, malformed bodies, stale timestamps, and provider/account mismatches return distinct typed failures. -- cbpayments does not define a public route, acknowledge HTTP requests, persist events, choose a tenant, mutate an invoice, dispatch jobs, or retry business processing. -- cbpayments never logs or persists the raw body. An application that retains it owns encryption, access control, retention, and redaction. -- Duplicate and out-of-order events are expected. Applications key their inbox by provider name plus external event ID and reconcile against provider state when sequence alone is insufficient. - -## 11. Application/module ownership boundary - -`cbpayments` owns: - -- provider registration, construction, configuration validation, and lifecycle; -- capability discovery and normalized provider operations; -- protocol translation, signature verification, status/error normalization, and safe diagnostic metadata; -- test doubles and provider contract suites. - -The consuming application owns: - -- users, organizations, merchants, tenants, connected-account onboarding, and authorization; -- carts, bookings, invoices, payable/receivable rules, taxes, discounts, deposits, balances, and accounting; -- durable payment attempts, ledger entries, idempotency-key allocation, webhook inbox/outbox, job retries, and reconciliation; -- mapping a named provider to a tenant or business flow; -- emails, receipts, refunds policy, disputes workflow, subscription entitlements, and customer support tooling. - -The application must not hold a database transaction open across a provider network call. It writes durable pending intent first, calls the provider outside the transaction, then idempotently records/reconciles the result. - -## 12. Security and compliance requirements - -- Default documentation and examples keep card entry on provider-hosted Checkout or provider UI components. cbpayments does not accept PAN, CVV, bank credentials, or raw browser payment form data. -- API keys and webhook secrets come from environment/secret management, never repository defaults. Test fixtures use unmistakably fake keys. -- No configuration dump, exception, diagnostic serialization, event announcement, or debug statement may expose API keys, authorization headers, signatures, client secrets, payment tokens, raw webhook bodies, complete provider objects, or sensitive customer fields. -- A Payment Intent client secret, when required by a supported frontend flow, lives in a sensitive `ClientAction` object excluded from default mementos, logs, and interception data. A consumer must explicitly read it and return it only to the authorized intended client. -- A central redactor handles common secret/token patterns plus provider-specific fields. Redaction tests use nested structs, arrays, exceptions, and malformed responses. -- Return/cancel URLs are caller-controlled but validated as absolute HTTPS URLs outside explicitly enabled local development. -- Metadata is bounded in key count/value size and rejects likely secret/card fields. -- Provider HTTP timeouts are finite. Retry policy distinguishes safe/idempotent operations from unsafe ones and honors provider retry guidance. -- Provider dependencies and GitHub Actions are pinned and updated through reviewed PRs. -- Security issues follow the repository security policy; public examples contain no live account identifiers. - -## 13. Observability and interception points - -Publish low-cardinality interception points without sensitive payloads: - -- `cbpaymentsOnProviderStart` -- `cbpaymentsOnProviderShutdown` -- `cbpaymentsPreOperation` -- `cbpaymentsPostOperation` -- `cbpaymentsOnOperationFailure` -- `cbpaymentsOnWebhookVerified` -- `cbpaymentsOnWebhookRejected` - -Event data contains provider name/type, operation, safe request correlation/idempotency key, provider request ID, duration, normalized status, and safe failure category. It does not contain the request object, SDK client, raw provider response, webhook body, headers, or secrets. - -The module emits useful structured logs at appropriate levels but does not ship metrics storage. Applications may translate interception points into their own metrics/traces. - -## 14. First-party test providers - -Ship two provider types alongside Stripe: - -- `InMemory`: implements the supported core capabilities without network I/O, records sanitized requests, lets tests enqueue deterministic successes/failures/events, and supports reset/assertion helpers. -- `Null`: advertises an explicitly small capability set and returns deterministic no-op outcomes for applications that disable payments in selected environments. - -Neither silently behaves like Stripe. The in-memory provider follows the same normalized contracts and becomes the primary consumer-test story, analogous to cbMailServices' in-memory protocol. - -## 15. Test strategy - -### 15.1 Required local and PR tests - -- Registry: default validation, lookup, enumeration, duplicate handling, override, unregister, shutdown, provider-type aliases, custom class/WireBox resolution, module namespacing, ownership, unload/reinit, collisions, and unknown provider errors. -- Concurrency: exactly one lazy instance under concurrent first access; isolated construction locks per provider name; safe shutdown/reinit. -- Isolation: two named Stripe definitions cannot share credentials, defaults, API versions, webhook secrets, mutable client state, or captured test calls. -- Capabilities: correct advertisement, successful dispatch, and pre-network unsupported-capability failures. -- Requests/results: minor-unit validation, currency behavior, serialization, unknown statuses, provider allow-lists, safe failure mapping, and idempotency propagation. -- Security: recursive redaction, safe exceptions/logs/events, URL and metadata validation, no secret fields in serialized results. -- Webhooks: valid signatures, invalid signatures, timestamp limits, rotating secrets, malformed JSON, wrong account, duplicate fixtures, out-of-order fixtures, and stable normalized envelopes. -- InMemory/Null providers: deterministic behavior and consumer assertion helpers. -- Stripe adapter: mocked `stripecfml` client/transport for every operation, status, HTTP failure, timeout, rate limit, decline, malformed response, idempotency header, and request-ID mapping. Required CI never depends on Stripe network availability. -- Integration: a minimal ColdBox application configures default and multiple named providers, injects via service and DSL, executes an operation, verifies a webhook, reinits, and shuts down. -- Extension integration: a fixture ColdBox module declares a new provider alias, is installed without editing cbpayments, supplies an upstream-client test double, becomes selectable through application configuration, and unregisters cleanly on unload. -- Packaging: build the release ZIP, inspect excludes, install it into a new test harness, start the server, and resolve the public service/provider IDs from the packaged artifact. - -### 15.2 Engine matrix - -Required 1.0 matrix, matching the current cbMailServices/ColdBox 8 generation: - -- ColdBox `^8.0.0` on BoxLang 1 native; -- ColdBox `^8.0.0` on BoxLang 1 CFML compatibility; -- ColdBox `^8.0.0` on Lucee 6 and 7; -- ColdBox `^8.0.0` on Adobe ColdFusion 2023 and 2025. - -ColdBox `be` runs on representative engines as allowed-failure experimental jobs. A support claim is made only for required green matrix rows. ColdBox 7 support may be added only with its own required rows; it is not inferred from template ancestry. - -### 15.3 Live provider verification - -A separate secret-bearing reusable workflow runs a minimal Stripe test-mode contract on trusted same-repository pull requests, on demand, and on a schedule. It creates only self-cleaning test resources, uses unique run metadata/idempotency keys, never runs for fork or Dependabot pull requests, and uploads scrubbed diagnostics. The mocked matrix remains the portable provider contract for every pull request, while a live-provider failure blocks a trusted pull request and any release candidate. - -## 16. CI and publishing design - -Use the current module-template workflow separation: reusable tests, pull requests, daily tests, development snapshots, and stable releases. Preserve the shared `build/Build.cfc` and `build/release.boxr` conventions, but do not copy stale action pins or weak release behavior without review. - -### 16.1 Pull requests - -Required checks: - -1. formatting check and `git diff --check`; -2. package metadata validation and dependency installation; -3. required TestBox engine matrix with JUnit publication and failure logs; -4. docs build and Markdown validation; -5. built-ZIP content audit and clean-install smoke test; -6. secret-pattern scan over tracked source and the built archive; -7. test result and coverage artifacts, with an agreed threshold after the baseline suite exists. - -PR workflows receive no ForgeBox, AWS, or release credentials. A dedicated live job on trusted same-repository pull requests receives only the Stripe test-mode API key; fork and Dependabot pull requests skip that job and cannot receive the secret. - -### 16.2 Daily/scheduled tests - -- Run the required engine matrix daily against locked dependencies. -- Run a second dependency-freshness lane using allowed upgrades to detect upcoming breaks without changing the lock or falsely passing the release lane. -- Run the live Stripe test-mode contract on a less frequent schedule and on demand. -- Open or update one actionable issue for a persistent scheduled failure rather than sending duplicate noise. - -### 16.3 Development snapshots - -On the reconciled `development` branch: - -- run the complete required test, format-check, docs, package-smoke, and security gates; -- build once and publish the exact tested snapshot artifact using the organization snapshot convention; -- upload snapshot binary/API docs only to snapshot destinations; -- never create or move a stable Git tag; -- record source SHA, build number, dependency lock/checksum, and artifact checksum. - -Auto-formatting, if retained from the template, runs before snapshot publication and the formatted commit must be the exact commit rebuilt/tested. Publication may not race an auto-commit. - -### 16.4 Stable release - -A release PR promotes the tested `development` boundary to `main`. A push to `main` may publish only after all required checks pass for the exact release SHA. - -The stable workflow: - -1. checks out the exact SHA with full tag history; -2. sets up Java and CommandBox/BoxLang with reviewed, pinned actions; -3. resolves the semantic version and finalizes the changelog; -4. installs locked dependencies, runs the required tests, builds docs, and repeats package smoke verification; -5. builds the module once and records SHA-256 checksums; -6. uploads the ZIP to `downloads.ortussolutions.com`; -7. uploads versioned API docs to `apidocs.ortussolutions.com`; -8. publishes that same ZIP/package metadata to ForgeBox; -9. creates the immutable `vX.Y.Z` tag without force-moving an existing tag; -10. creates the GitHub release and attaches the ZIP/checksum; -11. verifies the tag SHA, GitHub release/assets, ForgeBox version/download, binary URL/checksum, and API-doc URL; -12. updates the development changelog/version only after stable publication is verified; -13. sends one final success/failure notification containing version, SHA, and verification status. - -Do not mark GitHub release creation `continue-on-error`. Replace actions referenced by mutable branches such as `@master` with pinned reviewed revisions or first-party CLI commands. Use least-privilege job permissions and environment-scoped publication secrets. - -If the repository later adopts CommandBox Semantic Release, use the proven JGit-compatible release checkout/configuration rather than assuming the newest checkout action works: pin the release checkout to `actions/checkout@v4.2.2` and use `NullArtifactsCommitter@commandbox-semantic-release` unless a completed test release proves the incompatibility has been fixed. - -### 16.5 Partial-release recovery - -Publication is multi-system and cannot be treated as atomic. The runbook records which of S3, API docs, ForgeBox, tag, and GitHub release succeeded. A retry must be idempotent, verify existing artifacts and checksums, and complete missing destinations without moving a tag to different source. ForgeBox presence alone is never release proof. - -### 16.6 Provider-module CI - -First-party provider repositories use the same module-template workflow layout and publication verification as cbpayments. Their required matrix includes: - -- the lowest and highest supported released cbpayments versions; -- the declared stable engine/ColdBox rows; -- the shared provider contract kit for every advertised capability; -- upstream client-library compatibility and redaction tests; -- built-package installation beside a released cbpayments artifact; -- an optional secret-bearing live provider smoke test outside fork PRs. - -Community provider authors can consume the same contract kit and reference workflow, but their releases remain independently owned. Core cbpayments CI includes a fixture extension module so the extension API cannot regress unnoticed; it does not attempt to test or certify every community provider. - -## 17. Documentation deliverables - -- README: purpose, installation, minimum versions, five-minute InMemory example, Stripe hosted-checkout example, and link to full docs. -- Configuration: every module/provider setting, named-provider examples, environment/secrets examples, and multiple Stripe account example. -- Provider guide: service/DSL lookup, normalized requests/results, capabilities, idempotency, retries, and escape hatch. -- Stripe guide: Checkout, Payment Intents, Setup Intents, refunds/capture, webhook verification, API-version policy, Connect scoping, and migration away from legacy Charges/Sources usage. -- Webhook guide: raw-body requirement, signature verification, rotation, application inbox/idempotency, replay/out-of-order behavior, and acknowledgment timing. -- Custom provider author guide: base lifecycle, capability interfaces, validation, status/error mapping, redaction, contract test kit, and packaging. -- Provider ecosystem guide: declarative registration, alias ownership/collisions, module load/unload, first-party versus community support, compatibility ranges, API-library selection, and a complete installable example provider. -- Testing guide: InMemory/Null providers, assertions, mocked adapter tests, and optional live Stripe tests. -- Security guide: PCI boundary, forbidden data, logging/redaction, key rotation, incident reporting, and go-live checklist. -- Compatibility matrix and upgrade/migration guide. -- Generated API docs for public components only. - -Every example must compile/run in the test harness or be extracted from a tested fixture so documentation cannot drift independently. - -## 18. Delivery phases and evidence gates - -### Phase 0: clean module baseline and release skeleton - -- Refresh from the current module template and preserve cbpayments identity. -- Establish metadata, scripts, formatting/linting, AGENTS guidance, test harness, build tasks, and non-publishing PR/daily workflows. -- Add package build/install smoke verification. -- Integrate the existing `development` history into the feature branch and deliver through a reviewed PR without rewriting remote history. - -Exit evidence: clean required-engine boot, format idempotence, metadata validation, built-ZIP audit/install, green PR workflow, and no remote publication. - -### Phase 1: registry, lifecycle, and testing providers - -- Implement `PaymentService`, provider-type and configured-provider registries, declarative module contributions, alias ownership/collisions, default/named lookup, lazy concurrency, lifecycle/unload, DSL, module namespacing, custom providers, capabilities, InMemory, and Null. -- Publish the public configuration and provider-author contracts. - -Exit evidence: registry/concurrency/isolation/DI/integration specs across the required matrix, a consumer-style InMemory example, and a separately installed fixture module that contributes and removes a provider type without cbpayments source changes. - -### Phase 2: normalized operation contracts - -- Implement Money, operation requests, PaymentResult/failure taxonomy, status mapping, idempotency rules, provider options, redaction, and safe interception points. -- Add the hosted-checkout, Payment Intent, capture, refund, Setup Intent, and webhook capability interfaces. - -Exit evidence: contract suite proves serialized shapes, unsupported-capability behavior, redaction, idempotency, retry classification, and backward-compatible public signatures. - -### Phase 3: Stripe protocol adapter - -- Pin and wrap the tested `stripecfml` release with one isolated client per named provider, registering `Stripe` through the public provider-type interface. -- Implement hosted Checkout Sessions first, then Payment Intents, capture, refunds, Setup Intents, and any customer calls required by those workflows. -- Add current Stripe API fixtures and the optional live test-mode workflow. - -Exit evidence: mocked adapter contract is green on the full matrix; two named Stripe clients prove isolation; live test-mode checkout/payment/refund smoke is green on the reference engine; deprecated Charges/Sources methods are absent from the normalized API. - -### Phase 4: signed webhooks and reliability - -- Implement exact-raw-body verification, rotating secrets, timestamp tolerance, provider/account validation, normalized event envelopes, safe webhook events/logging, and duplicate/out-of-order fixtures. -- Publish the application inbox/reconciliation integration guide. - -Exit evidence: signature/adversarial fixture suite, log/result secret scan, replay/out-of-order consumer example, and live Stripe test webhook verification. - -### Phase 5: CI publication and release candidate - -- Merge the reviewed feature branch into the existing `development` integration branch. -- Enable snapshots, stable workflow, package/API-doc/ForgeBox/GitHub/S3 publication, checksums, verification, and recovery runbook. -- Complete docs, changelog, migration notes, compatibility table, and provider-author kit. - -Exit evidence: a non-production dry run proves build-once artifact identity and every verification step; the release candidate installs from its ZIP and snapshot coordinates into a clean consumer. - -### Phase 6: first stable release - -- Cut the first stable version only after Phases 0-5 and the live Stripe gate are green for the exact SHA. -- Verify every publication destination and then exercise the documented quickstart from the published ForgeBox package. - -Exit evidence: immutable tag, GitHub release/asset, ForgeBox version, binary/checksum, API docs, completed workflow, and clean external install all resolve to the same source SHA/version. - -### Post-1.0: billing/subscriptions and more providers - -- Add the billing capability with Stripe Billing, Checkout, and Customer Portal primitives. -- Add additional provider types only when a real consumer supplies use cases and test credentials/fixtures. -- Prefer separate installable provider modules so each upstream dependency and release cadence remains isolated from cbpayments core. -- Each provider must pass the shared contract suite; one provider's terminology or object model may not leak into normalized interfaces. - -## 19. CommuniArts integration checkpoint - -CommuniArts can integrate online receivables only after cbpayments Phase 4 is available from a tested package coordinate. Its application work remains separate: - -1. decide Stripe account ownership/onboarding and map each organization or flow to an allowed named provider; -2. add a durable payment-attempt record and idempotency allocation; -3. create hosted checkout outside database transactions through its local `PaymentGateway` adapter; -4. add a public webhook route that passes the exact body/signature to cbpayments, persists a tenant-scoped inbox record, acknowledges promptly, and queues processing; -5. idempotently translate normalized events into the existing inventory payment/receivable ledger and reconcile uncertain/out-of-order states; -6. prove authorization, duplicate delivery, recovery, ledger balance, audit history, email/receipt, and browser flows. - -The inventory architecture plan references this document for module behavior and keeps only those CommuniArts-owned responsibilities. - -## 20. Non-goals for 1.0 - -- A merchant-of-record service, marketplace onboarding product, accounting system, tax engine, or PCI card vault. -- A universal representation of every provider feature. -- Application invoice/customer/subscription/entitlement models. -- Raw card collection or migration. -- Automatic provider failover for a payment attempt. Retrying against a second provider can double-charge and requires explicit application policy. -- Automatic routing by tenant, currency, price, geography, or cost; the application selects the named provider. -- PayPal or Authorize.NET placeholders without complete implementations and contract tests. -- Bundling community provider code or its API dependencies into cbpayments merely to make the alias available. -- Backward compatibility with the unreleased legacy development branch. - -## 21. Definition of done - -`cbpayments` 1.0 is complete only when: - -- default and multiple named providers work through service and DSL access; -- an independently installed fixture provider module can register an alias, be configured normally, pass the shared contract kit, and unload without a cbpayments source change; -- lazy construction, lifecycle, reinit, concurrency, and instance isolation are proven; -- capability-specific APIs and normalized safe results are documented and stable; -- InMemory, Null, and Stripe providers pass their required contract suites; -- Stripe uses current supported Checkout/Intent primitives and a pinned, tested API/library version; -- webhook signatures, rotation, tolerance, duplicates, and out-of-order events are proven; -- no required test, formatting, docs, package, security, or live-provider gate is failing; -- the tested artifact installs cleanly outside the repository; -- no prohibited secret/payment data appears in logs, serialized results, fixtures, docs, or artifacts; -- the immutable tag, GitHub release, ForgeBox version, binary/checksum, API docs, and completed CI run all identify the same version and source SHA; -- the partial-release recovery procedure has been dry-run; -- at least one consumer integration uses only the public normalized API and can switch between InMemory and Stripe by settings. -- provider-specific API clients remain inside their provider implementations, use documented tested dependencies where suitable, and do not leak their raw response contracts into core APIs. diff --git a/docs/compatibility.md b/docs/compatibility.md index 79325ec..a6223e2 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -13,6 +13,6 @@ Java 21 is used by CI. The locked lane currently uses ColdBox 8.1.0+34 and TestB ## 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 providers, capability-specific requests, `PaymentResult`, Checkout/Payment/Setup Intents, and verified normalized webhooks. +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 index 96c0db8..a35b2fe 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,29 +1,34 @@ # Configuration -## Module settings +## 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 = { - defaultProvider : "primaryReceivables", - providers : { - primaryReceivables : { - provider : "Stripe", + defaultProcessor : "receivables", + processors : { + receivables : { + provider : "StripeProvider@cbpayments", properties : { - apiKey : getSystemSetting( "PRIMARY_STRIPE_API_KEY" ), - webhookSecrets : [ getSystemSetting( "PRIMARY_STRIPE_WEBHOOK_SECRET" ) ], + apiKey : getSystemSetting( "STRIPE_API_KEY" ), + webhookSecrets : [ getSystemSetting( "STRIPE_WEBHOOK_SECRET" ) ], apiVersion : "2026-02-25.clover", - defaultCurrency : "usd", - connectAccount : "" + defaultCurrency : "usd" } }, - secondaryReceivables : { - provider : "Stripe", + europe : { + provider : "MoneiProvider@cbpayments-monei", properties : { - apiKey : getSystemSetting( "SECONDARY_STRIPE_API_KEY" ), - webhookSecrets : [ getSystemSetting( "SECONDARY_STRIPE_WEBHOOK_SECRET" ) ], - apiVersion : "2026-02-25.clover", - defaultCurrency : "usd" + apiKey : getSystemSetting( "MONEI_API_KEY" ), + accountId : getSystemSetting( "MONEI_ACCOUNT_ID", "" ) } + }, + preview : { + provider : "MockProvider@cbpayments", + properties : {} } }, webhooks : { toleranceSeconds : 300 }, @@ -31,10 +36,64 @@ moduleSettings.cbpayments = { }; ``` -`defaultProvider` must name a configured provider. Names are case-insensitive at lookup and retain their configured spelling. Definitions accept only `provider` and optional `properties`; unknown keys fail startup. +`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: -Built-in aliases are `InMemory`, `Null`, and `Stripe`. A fully qualified component path or WireBox ID may be used as an unaliased custom provider. Duplicate names and aliases fail unless runtime code passes `override=true`; replacement shuts down an instantiated provider first. +| 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" ) } + } +} +``` -Stripe properties are `apiKey`, `webhookSecrets`, `apiVersion`, `defaultCurrency`, `connectAccount`, and `toleranceSeconds`. `client` exists only for injecting a test double. Values are resolved during startup and never returned by registry inspection. A provider's diagnostic `getProperties()` result is recursively redacted. +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 +); +``` -Dependent modules may contribute `settings.cbpayments.providers`; names become `name@ModuleName`. `globalProviders` are deliberately global and therefore collide normally. Provider modules declare `settings.cbpayments.providerTypes` and remain responsible for unregistering their owned aliases on unload. +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 index c726499..8cd43b7 100644 --- a/docs/custom-providers.md +++ b/docs/custom-providers.md @@ -1,20 +1,156 @@ -# Custom provider author guide +# Custom Provider author guide -An installable provider is an ordinary ColdBox module depending on `cbpayments`. Declare aliases in module settings: +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 -this.dependencies = [ "cbpayments" ]; settings.cbpayments = { - providerTypes : { - AuthorizeNet : { provider : "AuthorizeNetProvider@cbpayments-authorizenet" } + processors : { + sandbox : { + provider : "AcmePayProvider@cbpayments-acmepay", + properties : {} + } } }; ``` -The provider implements `IPaymentProvider` plus only the capability interfaces it advertises. Extend `AbstractPaymentProvider` for lifecycle, safe results, failure construction, redaction, and interception behavior. Keep the processor SDK in the provider module, create one client per named instance, and normalize every response through explicit allow-lists. +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`. -Aliases record their owner. Collisions identify both owners; an extension must unregister its owned alias during unload, which shuts down configured instances of that type. Installing the module never creates credentials or an account definition. +## 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" ] +); +``` -Run `ProviderContract@cbpayments` (or instantiate `cbpayments.models.testing.ProviderContract`) against every advertised capability, then add request/result, failure, idempotency, redaction, isolation, lifecycle, webhook, engine, and clean-package tests. Publish compatibility ranges for cbpayments, ColdBox, engines, Java, and the exact tested upstream SDK version. +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. -Community modules execute with application privileges. cbpayments does not download provider code dynamically or imply endorsement. +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 index cbcfd4b..0f9048c 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -1,23 +1,128 @@ -# Providers and normalized operations +# Providers, Processors, and normalized operations -`PaymentService@cbpayments` exposes registry methods (`provider`, `defaultProvider`, `register`, `unregister`, `has`, `names`, `supports`, and lifecycle methods) plus thin operation delegates. The `cbpayments` DSL resolves the default provider; `cbpayments:name` resolves a named provider. +A **Provider** implements payment behavior. A **Processor** is one named, configured Provider instance. `PaymentService@cbpayments` is the application-facing registry and operation facade. -Capabilities are independent contracts: +## Access through WireBox -| Capability | Operations | +```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` | create, retrieve, expire | -| `paymentIntents` | create, retrieve, confirm, cancel | -| `capture` | capture an authorized Payment Intent | -| `refunds` | create and retrieve full/partial refunds | -| `setupIntents` | create, retrieve, cancel | -| `customers` | create, retrieve, update, delete | -| `webhooks` | verify and normalize signed events | +| `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 -Unsupported capabilities throw `cbpayments.UnsupportedCapability` before a network call. +`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. -Every mutating request requires an idempotency key. `Money` accepts only integer minor units and validated lowercase ISO 4217 currency codes. Metadata is scalar, bounded, and rejects secret/card-like keys. Provider-specific options must be nested under the provider type and allow-listed by the adapter. +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. -`PaymentResult.getMemento()` produces the stable public envelope: outcome, operation, provider identity, normalized status, external/request IDs, idempotency key, timestamp, amount, safe next action, safe failure, and allow-listed provider details. Unknown provider statuses normalize to `unknown`. Client secrets live in a separate `ClientAction` available only from the result object and never in its default memento. +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()`. -Expected remote outcomes use `ok=false` and the categories `declined`, `validation`, `authentication`, `rate_limited`, `network`, `provider`, or `unknown`. Configuration/programmer errors throw typed `cbpayments.*` exceptions. +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/releasing.md b/docs/releasing.md deleted file mode 100644 index a881b4e..0000000 --- a/docs/releasing.md +++ /dev/null @@ -1,9 +0,0 @@ -# Release and partial-recovery runbook - -Stable releases promote a fully tested development boundary to `main`. Build once for the exact release SHA, record SHA-256, and send the identical archive to the download host, ForgeBox, and GitHub release. Publish generated API docs for the same version and source. - -Before publication, verify formatting/diff checks, metadata, locked installs, required engine matrix, docs, package contents, clean external installation, service resolution, secret scan, and the live Stripe test-mode contract. Never release a feature branch or fork PR and never force-move an existing tag. - -Record each destination independently: source SHA/version, test run, archive checksum, download URL/checksum, API-doc URL, ForgeBox version/download, tag SHA, GitHub release asset/checksum, and final external install. ForgeBox presence alone is not completion. - -If a run fails partway, stop and inventory every destination. Verify any existing artifact checksum and tag SHA. Resume only missing destinations with the original build; do not rebuild under the same version or move a tag to new source. If an existing destination differs, fail closed and escalate. The dry-run workflow uploads the build/checksum as CI artifacts without public credentials and exercises this verification logic before a release candidate. diff --git a/docs/security.md b/docs/security.md index 7e18196..debce44 100644 --- a/docs/security.md +++ b/docs/security.md @@ -2,14 +2,14 @@ - 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 provider call. -- Do not hold a database transaction across a provider network call. Persist pending state, call outside the transaction, then reconcile idempotently. +- 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. +- 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, named-provider-to-business mapping, Connect account ownership, refunds/disputes policy, receipts, reconciliation, alerts, secret rotation, and incident contacts before launch. +- 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 index 36376ae..05743c6 100644 --- a/docs/stripe.md +++ b/docs/stripe.md @@ -1,11 +1,178 @@ -# Stripe provider +# Stripe Provider guide -cbpayments 1.0 pins stripe-cfml 4.1.0 and Stripe API version `2026-02-25.clover`. Each named provider constructs its own client with `convertToCents=false`, so credentials, API versions, currency defaults, Connect accounts, webhook secrets, and mutable state do not bleed between accounts. +`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. -Use hosted Checkout Sessions for ordinary on-session payments. Payment Intents are for off-session or independently modeled payment state; Setup Intents save payment methods. Capture and refund operations act on Payment Intents. Customer operations support Setup Intent ownership. Charges, Sources, Tokens, Card Element, and local recurring loops are deliberately absent. +## Configuration -Stripe automatically selects payment methods. The adapter does not send `payment_method_types`. Allow-listed `providerOptions.Stripe` fields support `expiresAt`, `applicationFeeAmount`, `transferDestination`, `onBehalfOf`, and `offSession` only where meaningful. Connected-account headers may be fixed per named provider. Choose one Connect charge model in the consuming application and do not silently retry against another provider. +```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 + } + } + } +}; +``` -Stripe response bodies are translated through per-operation allow-lists. Request IDs and other diagnostic scalars are preserved only after secret/payment-data scrubbing; malformed success bodies become normalized provider failures, and unsafe redirect URLs are discarded. Raw response objects, headers, client secrets, payment-method identifiers, and error bodies are not serialized or announced. The raw stripe-cfml client remains available through `getClient()` for non-portable features and must be isolated behind application code with provider-specific tests. +`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. -The pinned stripe-cfml transport applies its finite 50-second request timeout. Transport and timeout failures are returned as retryable normalized network failures; applications should retry only operations protected by an idempotency key. +`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 index 9d5ac3b..e125cda 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,9 +1,9 @@ # Testing -Use `InMemory` 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 `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 `Null` only where payments are intentionally disabled. It advertises hosted checkout alone and returns deterministic no-op results; it never pretends to be Stripe. +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 registry validation, case-insensitive lookup, override/unregister, ownership, 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, error classes, request IDs, client-secret isolation, signature rotation/tolerance, malformed bodies, account mismatch, duplicates/out-of-order events, and real stripe-cfml HMAC verification. +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 index 2907dbb..e62a16b 100644 --- a/docs/webhooks.md +++ b/docs/webhooks.md @@ -1,9 +1,127 @@ -# Webhook boundary +# Webhook processing -Pass the exact raw request body and Stripe-Signature header to `verifyWebhook`. Verification happens before JSON parsing. The provider tests an ordered list of active secrets for safe rotation, enforces timestamp tolerance in both past and future directions, validates the configured/provider account, and returns a normalized `PaymentEvent`. +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. -The event contains safe identity, type, time, livemode, provider/account, object identity, normalized status/amount, allow-listed details, a deterministic SHA-256 payload checksum, and only the matched secret index. It never retains the raw body or secret. +## Request flow -cbpayments does not expose a route, select a tenant, persist an event, acknowledge HTTP, mutate invoices, enqueue jobs, or retry business logic. The application should acknowledge promptly after verification, store an inbox entry keyed by provider name plus external event ID, and process it idempotently. Duplicate and out-of-order delivery is normal; reconcile against current provider state where ordering is insufficient. +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. -Distinct typed failures cover invalid signatures, malformed signed bodies, stale/future timestamps, missing webhook configuration, and provider-account mismatches. Do not log the raw body or signature when handling them. +## 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 index c6f7bf2..5ad7b27 100644 --- a/dsl/cbpaymentsDSL.cfc +++ b/dsl/cbpaymentsDSL.cfc @@ -1,9 +1,9 @@ /** - * WireBox DSL for named cbpayments providers. + * WireBox DSL for named cbpayments processors. * - * cbpayments => default provider - * cbpayments:default => default provider - * cbpayments:name => named provider + * cbpayments => default Processor + * cbpayments:default => default Processor + * cbpayments:name => named Processor */ component accessors="true" { @@ -19,10 +19,10 @@ component accessors="true" { var service = variables.injector.getInstance( "PaymentService@cbpayments" ); if ( arrayLen( segments ) == 1 || ( arrayLen( segments ) == 2 && segments[ 2 ] == "default" ) ) { - return service.defaultProvider(); + return service.defaultProcessor(); } if ( arrayLen( segments ) == 2 && len( segments[ 2 ] ) ) { - return service.provider( segments[ 2 ] ); + return service.processor( segments[ 2 ] ); } throw( diff --git a/helpers/Mixins.cfm b/helpers/Mixins.cfm index e849cbc..01c5b36 100644 --- a/helpers/Mixins.cfm +++ b/helpers/Mixins.cfm @@ -1,15 +1,15 @@ /** - * Return the configured payment provider by name. + * Return the configured payment processor by name. */ -function getPaymentProvider( required string name ){ - return wirebox.getInstance( "PaymentService@cbpayments" ).provider( arguments.name ); +function getPaymentProcessor( required string name ){ + return wirebox.getInstance( "PaymentService@cbpayments" ).processor( arguments.name ); } /** - * Return the application's default payment provider. + * Return the application's default payment processor. */ -function getDefaultPaymentProvider(){ - return wirebox.getInstance( "PaymentService@cbpayments" ).defaultProvider(); +function getDefaultPaymentProcessor(){ + return wirebox.getInstance( "PaymentService@cbpayments" ).defaultProcessor(); } diff --git a/models/PaymentService.cfc b/models/PaymentService.cfc index 2989669..e89d012 100644 --- a/models/PaymentService.cfc +++ b/models/PaymentService.cfc @@ -1,5 +1,5 @@ /** - * Thread-safe registry and facade for configured payment providers. + * Thread-safe registry and facade for configured payment processors. */ component accessors="true" singleton threadsafe { @@ -9,32 +9,17 @@ component accessors="true" singleton threadsafe { property name="log" inject="logbox:logger:{this}"; function init(){ - variables.providerTypes = {}; - variables.providers = {}; - variables.providerConstructionLocks = {}; - variables.providerConstructionLocksLock = createObject( + variables.processors = {}; + variables.processorConstructionLocks = {}; + variables.processorConstructionLocksLock = createObject( "java", "java.util.concurrent.locks.ReentrantLock" ).init(); return this; } - any function registerAppProviderTypes(){ - if ( !variables.moduleSettings.keyExists( "providerTypes" ) ) { - return this; - } - for ( var name in variables.moduleSettings.providerTypes ) { - registerProviderTypeDefinition( - name, - variables.moduleSettings.providerTypes[ name ], - "application" - ); - } - return this; - } - - any function registerAppProviders(){ - registerProviderMap( variables.moduleSettings.providers ); + any function registerAppProcessors(){ + registerProcessorMap( variables.moduleSettings.processors ); return this; } @@ -48,136 +33,39 @@ component accessors="true" singleton threadsafe { continue; } var contribution = moduleConfig.settings.cbpayments; - if ( contribution.keyExists( "providerTypes" ) ) { - for ( var alias in contribution.providerTypes ) { - registerProviderTypeDefinition( - alias, - contribution.providerTypes[ alias ], - moduleName - ); - } - } - if ( contribution.keyExists( "providers" ) ) { - registerProviderMap( contribution.providers, "@#moduleName#" ); + if ( contribution.keyExists( "processors" ) ) { + registerProcessorMap( contribution.processors, "@#moduleName#" ); } - if ( contribution.keyExists( "globalProviders" ) ) { - registerProviderMap( contribution.globalProviders ); + if ( contribution.keyExists( "globalProcessors" ) ) { + registerProcessorMap( contribution.globalProcessors ); } } return this; } - any function registerProviderType( - required string name, - required string provider, - string owner = "application", - boolean override = false, - string extensionVersion = "", - array declaredCapabilities = [] - ){ - var key = canonical( arguments.name ); - if ( variables.providerTypes.keyExists( key ) && !arguments.override ) { - throw( - type = "cbpayments.DuplicateProviderType", - message = "Provider type [#arguments.name#] from [#arguments.owner#] conflicts with owner [#variables.providerTypes[ key ].owner#]." - ); - } - if ( variables.providerTypes.keyExists( key ) && arguments.override ) { - unregisterProviderType( - arguments.name, - variables.providerTypes[ key ].owner, - true - ); - } - variables.providerTypes[ key ] = { - "name" : arguments.name, - "provider" : arguments.provider, - "owner" : arguments.owner, - "extensionVersion" : arguments.extensionVersion, - "declaredCapabilities" : duplicate( arguments.declaredCapabilities ) - }; - return this; - } - - any function unregisterProviderType( - required string name, - required string owner, - boolean force = false - ){ - var key = canonical( arguments.name ); - if ( !variables.providerTypes.keyExists( key ) ) { - throw( - type = "cbpayments.UnknownProviderType", - message = "Provider type [#arguments.name#] is not registered." - ); - } - if ( !arguments.force && variables.providerTypes[ key ].owner != arguments.owner ) { - throw( - type = "cbpayments.ProviderTypeOwnership", - message = "Owner [#arguments.owner#] cannot unregister provider type [#arguments.name#]." - ); - } - variables.providers.each( function( providerKey, record ){ - if ( canonical( record.provider ) == key ) { - unregister( record.name ); - } - } ); - variables.providerTypes.delete( key ); - return this; - } - - boolean function hasProviderType( required string name ){ - return variables.providerTypes.keyExists( canonical( arguments.name ) ); - } - - array function providerTypeNames(){ - var result = variables.providerTypes - .keyArray() - .map( function( key ){ - return variables.providerTypes[ key ].name; - } ); - result.sort( "textNoCase" ); - return result; - } - - array function providerTypeDescriptors(){ - return variables.providerTypes - .keyArray() - .map( function( key ){ - var item = variables.providerTypes[ key ]; - return { - "name" : item.name, - "provider" : item.provider, - "owner" : item.owner, - "extensionVersion" : item.extensionVersion, - "declaredCapabilities" : duplicate( item.declaredCapabilities ) - }; - } ); - } - - any function register( + any function registerProcessor( required string name, required string provider, struct properties = {}, boolean override = false ){ - var key = canonical( arguments.name ); - var providerLock = getProviderConstructionLock( key ); - providerLock.lock(); + var key = canonical( arguments.name ); + var processorLock = getProcessorConstructionLock( key ); + processorLock.lock(); try { if ( !len( key ) ) { - throw( type = "cbpayments.InvalidConfiguration", message = "Provider names cannot be empty." ); + throw( type = "cbpayments.InvalidConfiguration", message = "Processor names cannot be empty." ); } - if ( variables.providers.keyExists( key ) && !arguments.override ) { + if ( variables.processors.keyExists( key ) && !arguments.override ) { throw( - type = "cbpayments.DuplicateProvider", - message = "A payment provider named [#arguments.name#] is already registered." + type = "cbpayments.DuplicateProcessor", + message = "A payment processor named [#arguments.name#] is already registered." ); } - if ( variables.providers.keyExists( key ) && arguments.override ) { - unregister( arguments.name ); + if ( variables.processors.keyExists( key ) && arguments.override ) { + unregisterProcessor( arguments.name ); } - variables.providers[ key ] = { + variables.processors[ key ] = { "name" : arguments.name, "provider" : arguments.provider, "properties" : arguments.properties, @@ -185,59 +73,59 @@ component accessors="true" singleton threadsafe { "createdOn" : "" }; } finally { - providerLock.unlock(); + processorLock.unlock(); } return this; } - any function unregister( required string name ){ - var key = canonical( arguments.name ); - var providerLock = getProviderConstructionLock( key ); - providerLock.lock(); + any function unregisterProcessor( required string name ){ + var key = canonical( arguments.name ); + var processorLock = getProcessorConstructionLock( key ); + processorLock.lock(); try { - var record = getProviderRecord( arguments.name ); + var record = getProcessorRecord( arguments.name ); if ( record.keyExists( "instance" ) ) { record.instance.shutdown(); } - variables.providers.delete( key ); + variables.processors.delete( key ); } finally { - providerLock.unlock(); + processorLock.unlock(); } return this; } - any function provider( required string name ){ - var key = canonical( arguments.name ); - var providerLock = getProviderConstructionLock( key ); - providerLock.lock(); + any function processor( required string name ){ + var key = canonical( arguments.name ); + var processorLock = getProcessorConstructionLock( key ); + processorLock.lock(); try { - var record = getProviderRecord( arguments.name ); + 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.providers[ key ].instance = instance; - variables.providers[ key ].createdOn = now(); + variables.processors[ key ].instance = instance; + variables.processors[ key ].createdOn = now(); } - return variables.providers[ key ].instance; + return variables.processors[ key ].instance; } finally { - providerLock.unlock(); + processorLock.unlock(); } } - any function defaultProvider(){ - validateDefaultProvider(); - return provider( variables.moduleSettings.defaultProvider ); + any function defaultProcessor(){ + validateDefaultProcessor(); + return processor( variables.moduleSettings.defaultProcessor ); } - any function validateDefaultProvider(){ + any function validateDefaultProcessor(){ if ( - !variables.moduleSettings.keyExists( "defaultProvider" ) - || !has( variables.moduleSettings.defaultProvider ) + !variables.moduleSettings.keyExists( "defaultProcessor" ) + || !hasProcessor( variables.moduleSettings.defaultProcessor ) ) { throw( type = "cbpayments.InvalidConfiguration", - message = "defaultProvider must name a registered cbpayments provider definition." + message = "defaultProcessor must name a registered cbpayments processor definition." ); } return this; @@ -245,9 +133,8 @@ component accessors="true" singleton threadsafe { any function validateSettings(){ var allowed = [ - "defaultProvider", - "providers", - "providerTypes", + "defaultProcessor", + "processors", "webhooks", "logging" ]; @@ -256,10 +143,10 @@ component accessors="true" singleton threadsafe { throw( type = "cbpayments.InvalidConfiguration", message = "Unknown cbpayments setting [#key#]." ); } } - if ( !isStruct( variables.moduleSettings.providers ) || !isStruct( variables.moduleSettings.providerTypes ) ) { + if ( !isStruct( variables.moduleSettings.processors ) ) { throw( type = "cbpayments.InvalidConfiguration", - message = "cbpayments providers and providerTypes settings must be structs." + message = "cbpayments processors setting must be a struct." ); } if ( @@ -286,40 +173,40 @@ component accessors="true" singleton threadsafe { return this; } - boolean function has( required string name ){ - return variables.providers.keyExists( canonical( arguments.name ) ); + boolean function hasProcessor( required string name ){ + return variables.processors.keyExists( canonical( arguments.name ) ); } - boolean function missing( required string name ){ - return !has( arguments.name ); + boolean function missingProcessor( required string name ){ + return !hasProcessor( arguments.name ); } - array function names(){ - var result = variables.providers + array function processorNames(){ + var result = variables.processors .keyArray() .map( function( key ){ - return variables.providers[ key ].name; + return variables.processors[ key ].name; } ); result.sort( "textNoCase" ); return result; } - numeric function count(){ - return variables.providers.count(); + numeric function processorCount(){ + return variables.processors.count(); } array function capabilities( required string name ){ - return provider( arguments.name ).capabilities(); + return processor( arguments.name ).capabilities(); } boolean function supports( required string name, required string capability ){ - return provider( arguments.name ).supports( arguments.capability ); + return processor( arguments.name ).supports( arguments.capability ); } any function shutdown(){ - var registeredNames = names(); + var registeredNames = processorNames(); registeredNames.each( function( name ){ - unregister( name ); + unregisterProcessor( name ); } ); return this; } @@ -329,23 +216,22 @@ component accessors="true" singleton threadsafe { */ any function reset(){ shutdown(); - variables.providerTypes = {}; - variables.providerConstructionLocks = {}; + variables.processorConstructionLocks = {}; return this; } - any function createCheckout( required any request, string providerName = "" ){ + any function createCheckout( required any request, string processorName = "" ){ return dispatch( - arguments.providerName, + arguments.processorName, "hostedCheckout", "createCheckout", [ arguments.request ] ); } - any function retrieveCheckout( required string externalId, string providerName = "" ){ + any function retrieveCheckout( required string externalId, string processorName = "" ){ return dispatch( - arguments.providerName, + arguments.processorName, "hostedCheckout", "retrieveCheckout", [ arguments.externalId ] @@ -355,28 +241,28 @@ component accessors="true" singleton threadsafe { any function expireCheckout( required string externalId, required string idempotencyKey, - string providerName = "" + string processorName = "" ){ return dispatch( - arguments.providerName, + arguments.processorName, "hostedCheckout", "expireCheckout", [ arguments.externalId, arguments.idempotencyKey ] ); } - any function createPaymentIntent( required any request, string providerName = "" ){ + any function createPaymentIntent( required any request, string processorName = "" ){ return dispatch( - arguments.providerName, + arguments.processorName, "paymentIntents", "createPaymentIntent", [ arguments.request ] ); } - any function retrievePaymentIntent( required string externalId, string providerName = "" ){ + any function retrievePaymentIntent( required string externalId, string processorName = "" ){ return dispatch( - arguments.providerName, + arguments.processorName, "paymentIntents", "retrievePaymentIntent", [ arguments.externalId ] @@ -386,11 +272,11 @@ component accessors="true" singleton threadsafe { any function confirmPaymentIntent( required string externalId, required string idempotencyKey, - struct options = {}, - string providerName = "" + struct options = {}, + string processorName = "" ){ return dispatch( - arguments.providerName, + arguments.processorName, "paymentIntents", "confirmPaymentIntent", [ @@ -404,55 +290,55 @@ component accessors="true" singleton threadsafe { any function cancelPaymentIntent( required string externalId, required string idempotencyKey, - string providerName = "" + string processorName = "" ){ return dispatch( - arguments.providerName, + arguments.processorName, "paymentIntents", "cancelPaymentIntent", [ arguments.externalId, arguments.idempotencyKey ] ); } - any function capturePayment( required any request, string providerName = "" ){ + any function capturePayment( required any request, string processorName = "" ){ return dispatch( - arguments.providerName, + arguments.processorName, "capture", "capturePayment", [ arguments.request ] ); } - any function createRefund( required any request, string providerName = "" ){ + any function createRefund( required any request, string processorName = "" ){ return dispatch( - arguments.providerName, + arguments.processorName, "refunds", "createRefund", [ arguments.request ] ); } - any function retrieveRefund( required string externalId, string providerName = "" ){ + any function retrieveRefund( required string externalId, string processorName = "" ){ return dispatch( - arguments.providerName, + arguments.processorName, "refunds", "retrieveRefund", [ arguments.externalId ] ); } - any function createSetupIntent( required any request, string providerName = "" ){ + any function createSetupIntent( required any request, string processorName = "" ){ return dispatch( - arguments.providerName, + arguments.processorName, "setupIntents", "createSetupIntent", [ arguments.request ] ); } - any function retrieveSetupIntent( required string externalId, string providerName = "" ){ + any function retrieveSetupIntent( required string externalId, string processorName = "" ){ return dispatch( - arguments.providerName, + arguments.processorName, "setupIntents", "retrieveSetupIntent", [ arguments.externalId ] @@ -462,28 +348,28 @@ component accessors="true" singleton threadsafe { any function cancelSetupIntent( required string externalId, required string idempotencyKey, - string providerName = "" + string processorName = "" ){ return dispatch( - arguments.providerName, + arguments.processorName, "setupIntents", "cancelSetupIntent", [ arguments.externalId, arguments.idempotencyKey ] ); } - any function createCustomer( required any request, string providerName = "" ){ + any function createCustomer( required any request, string processorName = "" ){ return dispatch( - arguments.providerName, + arguments.processorName, "customers", "createCustomer", [ arguments.request ] ); } - any function retrieveCustomer( required string externalId, string providerName = "" ){ + any function retrieveCustomer( required string externalId, string processorName = "" ){ return dispatch( - arguments.providerName, + arguments.processorName, "customers", "retrieveCustomer", [ arguments.externalId ] @@ -493,10 +379,10 @@ component accessors="true" singleton threadsafe { any function updateCustomer( required string externalId, required any request, - string providerName = "" + string processorName = "" ){ return dispatch( - arguments.providerName, + arguments.processorName, "customers", "updateCustomer", [ arguments.externalId, arguments.request ] @@ -506,10 +392,10 @@ component accessors="true" singleton threadsafe { any function deleteCustomer( required string externalId, required string idempotencyKey, - string providerName = "" + string processorName = "" ){ return dispatch( - arguments.providerName, + arguments.processorName, "customers", "deleteCustomer", [ arguments.externalId, arguments.idempotencyKey ] @@ -519,11 +405,11 @@ component accessors="true" singleton threadsafe { any function verifyWebhook( required string rawBody, required string signature, - string accountId = "", - string providerName = "" + string accountId = "", + string processorName = "" ){ return dispatch( - arguments.providerName, + arguments.processorName, "webhooks", "verifyWebhook", [ @@ -535,16 +421,16 @@ component accessors="true" singleton threadsafe { } private any function dispatch( - required string providerName, + required string processorName, required string capability, required string method, required array positionalArguments ){ - var target = len( arguments.providerName ) ? provider( arguments.providerName ) : defaultProvider(); + var target = len( arguments.processorName ) ? processor( arguments.processorName ) : defaultProcessor(); if ( !target.supports( arguments.capability ) ) { throw( type = "cbpayments.UnsupportedCapability", - message = "Provider [#target.getName()#] does not support [#arguments.capability#]." + message = "Processor [#target.getProcessorName()#] does not support [#arguments.capability#]." ); } return invoke( @@ -554,11 +440,11 @@ component accessors="true" singleton threadsafe { ); } - private any function registerProviderMap( required struct providerMap, string namespace = "" ){ - for ( var name in arguments.providerMap ) { - var definition = arguments.providerMap[ name ]; - validateProviderDefinition( name, definition ); - register( + 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 : {} @@ -567,142 +453,83 @@ component accessors="true" singleton threadsafe { return this; } - private void function registerProviderTypeDefinition( - required string name, - required any definition, - required string owner - ){ - if ( isSimpleValue( arguments.definition ) ) { - registerProviderType( - arguments.name, - arguments.definition, - arguments.owner - ); - return; - } - if ( !isStruct( arguments.definition ) || !arguments.definition.keyExists( "provider" ) ) { - throw( - type = "cbpayments.InvalidConfiguration", - message = "Provider type [#arguments.name#] requires a provider key." - ); - } - for ( var key in arguments.definition ) { - if ( - !arrayFindNoCase( - [ - "provider", - "extensionVersion", - "declaredCapabilities" - ], - key - ) - ) { - throw( - type = "cbpayments.InvalidConfiguration", - message = "Provider type [#arguments.name#] has unknown key [#key#]." - ); - } - } - if ( - !isSimpleValue( arguments.definition.provider ) - || !len( trim( arguments.definition.provider ) ) - || ( - arguments.definition.keyExists( "declaredCapabilities" ) - && !isArray( arguments.definition.declaredCapabilities ) - ) - ) { - throw( - type = "cbpayments.InvalidConfiguration", - message = "Provider type [#arguments.name#] is invalid." - ); - } - registerProviderType( - name = arguments.name, - provider = arguments.definition.provider, - owner = arguments.owner, - extensionVersion = arguments.definition.keyExists( "extensionVersion" ) ? arguments.definition.extensionVersion : "", - declaredCapabilities = arguments.definition.keyExists( "declaredCapabilities" ) ? arguments.definition.declaredCapabilities : [] - ); - } - - private void function validateProviderDefinition( required string name, required any definition ){ + private void function validateProcessorDefinition( required string name, required any definition ){ if ( !isStruct( arguments.definition ) || !arguments.definition.keyExists( "provider" ) ) { throw( type = "cbpayments.InvalidConfiguration", - message = "Provider definition [#arguments.name#] requires a provider key." + 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 = "Provider definition [#arguments.name#] is invalid." + 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 = "Provider definition [#arguments.name#] has unknown key [#key#]." + message = "Processor definition [#arguments.name#] has unknown key [#key#]." ); } } } - private struct function getProviderRecord( required string name ){ + private struct function getProcessorRecord( required string name ){ var key = canonical( arguments.name ); - if ( !variables.providers.keyExists( key ) ) { + if ( !variables.processors.keyExists( key ) ) { throw( - type = "cbpayments.UnknownProvider", - message = "Payment provider [#arguments.name#] is not registered. Registered providers: #names().toList()#." + type = "cbpayments.UnknownProcessor", + message = "Payment processor [#arguments.name#] is not registered. Registered processors: #processorNames().toList()#." ); } - return variables.providers[ key ]; + return variables.processors[ key ]; } private any function buildProvider( required string provider ){ - var key = canonical( arguments.provider ); - var target = variables.providerTypes.keyExists( key ) ? variables.providerTypes[ key ].provider : arguments.provider; - return variables.wirebox.getInstance( target ); + return variables.wirebox.getInstance( arguments.provider ); } private void function validateProviderContract( required any instance, required string provider ){ var requiredMethods = [ "startup", "shutdown", - "getName", - "getType", + "getProcessorName", + "getProviderType", "capabilities", "supports", "getClient" ]; - var candidate = arguments.instance; - var providerName = arguments.provider; + var candidate = arguments.instance; + var providerID = arguments.provider; requiredMethods.each( function( method ){ if ( !structKeyExists( candidate, method ) ) { throw( type = "cbpayments.InvalidProviderContract", - message = "Provider [#providerName#] does not implement [#method#]." + message = "Provider [#providerID#] does not implement [#method#]." ); } } ); } - private any function getProviderConstructionLock( required string key ){ - variables.providerConstructionLocksLock.lock(); + private any function getProcessorConstructionLock( required string key ){ + variables.processorConstructionLocksLock.lock(); try { - if ( !variables.providerConstructionLocks.keyExists( arguments.key ) ) { - variables.providerConstructionLocks[ arguments.key ] = createObject( + if ( !variables.processorConstructionLocks.keyExists( arguments.key ) ) { + variables.processorConstructionLocks[ arguments.key ] = createObject( "java", "java.util.concurrent.locks.ReentrantLock" ).init(); } - return variables.providerConstructionLocks[ arguments.key ]; + return variables.processorConstructionLocks[ arguments.key ]; } finally { - variables.providerConstructionLocksLock.unlock(); + variables.processorConstructionLocksLock.unlock(); } } diff --git a/models/contracts/IPaymentProvider.cfc b/models/contracts/IPaymentProvider.cfc index 9a9dfe6..d8010d9 100644 --- a/models/contracts/IPaymentProvider.cfc +++ b/models/contracts/IPaymentProvider.cfc @@ -1,9 +1,9 @@ interface displayname="IPaymentProvider" { - public any function startup( required string name, struct properties ); + public any function startup( required string processorName, struct properties ); public any function shutdown(); - public string function getName(); - public string function getType(); + 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/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/results/PaymentEvent.cfc b/models/contracts/results/PaymentEvent.cfc index b408e27..e0fadfd 100644 --- a/models/contracts/results/PaymentEvent.cfc +++ b/models/contracts/results/PaymentEvent.cfc @@ -3,11 +3,12 @@ */ component accessors="true" { - property name="eventId" type="string"; - property name="eventType" type="string"; + 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="providerName" type="string"; + property name="processorName" type="string"; property name="providerType" type="string"; property name="providerAccountId" type="string"; property name="objectType" type="string"; @@ -21,9 +22,10 @@ component accessors="true" { function init( required string eventId, required string eventType, + required string providerEventType, required any occurredAt, required boolean livemode, - required string providerName, + required string processorName, required string providerType, string providerAccountId = "", string objectType = "", @@ -36,9 +38,10 @@ component accessors="true" { ){ variables.eventId = arguments.eventId; variables.eventType = arguments.eventType; + variables.providerEventType = arguments.providerEventType; variables.occurredAt = arguments.occurredAt; variables.livemode = arguments.livemode; - variables.providerName = arguments.providerName; + variables.processorName = arguments.processorName; variables.providerType = arguments.providerType; variables.providerAccountId = arguments.providerAccountId; variables.objectType = arguments.objectType; @@ -55,9 +58,10 @@ component accessors="true" { var result = { "eventId" : variables.eventId, "eventType" : variables.eventType, + "providerEventType" : variables.providerEventType, "occurredAt" : variables.occurredAt, "livemode" : variables.livemode, - "providerName" : variables.providerName, + "processorName" : variables.processorName, "providerType" : variables.providerType, "providerAccountId" : variables.providerAccountId, "objectType" : variables.objectType, diff --git a/models/contracts/results/PaymentResult.cfc b/models/contracts/results/PaymentResult.cfc index 28e420b..29cd936 100644 --- a/models/contracts/results/PaymentResult.cfc +++ b/models/contracts/results/PaymentResult.cfc @@ -5,7 +5,7 @@ component accessors="true" { property name="ok" type="boolean"; property name="operation" type="string"; - property name="providerName" type="string"; + property name="processorName" type="string"; property name="providerType" type="string"; property name="status" type="string"; property name="externalId" type="string"; @@ -21,7 +21,7 @@ component accessors="true" { function init( required boolean ok, required string operation, - required string providerName, + required string processorName, required string providerType, string status = "unknown", string externalId = "", @@ -35,7 +35,7 @@ component accessors="true" { ){ variables.ok = arguments.ok; variables.operation = arguments.operation; - variables.providerName = arguments.providerName; + variables.processorName = arguments.processorName; variables.providerType = arguments.providerType; variables.status = normalizeStatus( arguments.status ); variables.externalId = arguments.externalId; @@ -54,7 +54,7 @@ component accessors="true" { var result = { "ok" : variables.ok, "operation" : variables.operation, - "providerName" : variables.providerName, + "processorName" : variables.processorName, "providerType" : variables.providerType, "status" : variables.status, "externalId" : variables.externalId, diff --git a/models/providers/AbstractPaymentProvider.cfc b/models/providers/AbstractPaymentProvider.cfc index 5f4bdcc..b0a85f3 100644 --- a/models/providers/AbstractPaymentProvider.cfc +++ b/models/providers/AbstractPaymentProvider.cfc @@ -3,27 +3,29 @@ */ component accessors="true" implements="cbpayments.models.contracts.IPaymentProvider" { - property name="name" type="string"; - property name="identifier" type="string"; - property name="providerType" type="string"; - property name="properties" type="struct"; - property name="started" type="boolean"; + 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.name = ""; + 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; } @@ -40,18 +42,18 @@ component accessors="true" implements="cbpayments.models.contracts.IPaymentProvi return variables.securityValidator.requireIdempotencyKey( arguments.idempotencyKey ); } - any function startup( required string name, struct properties = {} ){ - variables.name = arguments.name; - variables.properties = arguments.properties; - variables.started = true; - announce( "cbpaymentsOnProviderStart", safeContext() ); + 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( "cbpaymentsOnProviderShutdown", safeContext() ); + announce( "cbpaymentsOnProcessorShutdown", safeContext() ); } variables.client = javacast( "null", "" ); return this; @@ -65,7 +67,7 @@ component accessors="true" implements="cbpayments.models.contracts.IPaymentProvi return variables.identifier; } - string function getType(){ + string function getProviderType(){ return variables.providerType; } @@ -120,7 +122,7 @@ component accessors="true" implements="cbpayments.models.contracts.IPaymentProvi } throw( type = "cbpayments.ProviderException", - message = "The [#variables.name#] payment provider failed during [#arguments.operation#].", + message = "The [#variables.processorName#] payment provider failed during [#arguments.operation#].", detail = variables.redactor.redactString( exception.message ) ); } @@ -140,7 +142,7 @@ component accessors="true" implements="cbpayments.models.contracts.IPaymentProvi return new cbpayments.models.contracts.results.PaymentResult( ok = true, operation = arguments.operation, - providerName = variables.name, + processorName = variables.processorName, providerType = variables.providerType, status = arguments.status, externalId = arguments.externalId, @@ -175,7 +177,7 @@ component accessors="true" implements="cbpayments.models.contracts.IPaymentProvi return new cbpayments.models.contracts.results.PaymentResult( ok = false, operation = arguments.operation, - providerName = variables.name, + processorName = variables.processorName, providerType = variables.providerType, status = arguments.status, externalId = arguments.externalId, @@ -187,8 +189,8 @@ component accessors="true" implements="cbpayments.models.contracts.IPaymentProvi struct function safeContext(){ return { - "providerName" : variables.name, - "providerType" : variables.providerType + "processorName" : variables.processorName, + "providerType" : variables.providerType }; } @@ -198,6 +200,21 @@ component accessors="true" implements="cbpayments.models.contracts.IPaymentProvi } } + 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" ) diff --git a/models/providers/InMemoryProvider.cfc b/models/providers/MockProvider.cfc similarity index 90% rename from models/providers/InMemoryProvider.cfc rename to models/providers/MockProvider.cfc index 8cdccdd..7eee437 100644 --- a/models/providers/InMemoryProvider.cfc +++ b/models/providers/MockProvider.cfc @@ -8,7 +8,7 @@ component function init(){ super.init(); - variables.providerType = "InMemory"; + variables.providerType = "Mock"; variables.supportedCapabilities = [ "hostedCheckout", "paymentIntents", @@ -22,7 +22,7 @@ component return this; } - any function startup( required string name, struct properties = {} ){ + any function startup( required string processorName, struct properties = {} ){ super.startup( argumentCollection = arguments ); reset(); return this; @@ -211,26 +211,39 @@ component required string signature, string accountId = "" ){ - if ( arguments.signature != "inmemory-test-signature" ) { + 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 : {}; - return new cbpayments.models.contracts.results.PaymentEvent( + 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, - providerName = variables.name, + processorName = variables.processorName, providerType = variables.providerType, providerAccountId = arguments.accountId, objectType = dataObject.keyExists( "object" ) ? dataObject.object : "", @@ -240,6 +253,7 @@ component payloadChecksum = hash( arguments.rawBody, "SHA-256" ), matchedSecretIndex = 1 ); + return announceVerifiedWebhook( paymentEvent ); } private any function performCreate( @@ -343,7 +357,7 @@ component idempotencyKey = arguments.idempotencyKey, amount = isNull( arguments.amount ) ? javacast( "null", "" ) : arguments.amount, nextAction = arguments.nextAction, - providerDetails = { "inMemory" : true } + providerDetails = { "mock" : true } ); } @@ -363,7 +377,7 @@ component private string function nextId( required string prefix ){ variables.counter++; - return "mem_#arguments.prefix#_#variables.counter#"; + return "mock_#arguments.prefix#_#variables.counter#"; } private string function normalizeStatus( required string status ){ diff --git a/models/providers/StripeProvider.cfc b/models/providers/StripeProvider.cfc index d937668..7e262e5 100644 --- a/models/providers/StripeProvider.cfc +++ b/models/providers/StripeProvider.cfc @@ -26,9 +26,9 @@ component return this; } - any function startup( required string name, struct properties = {} ){ + any function startup( required string processorName, struct properties = {} ){ validateProperties( arguments.properties ); - variables.name = arguments.name; + variables.processorName = arguments.processorName; variables.properties = arguments.properties; variables.apiVersion = propertyValue( "apiVersion", "2026-02-25.clover" ); variables.defaultCurrency = lCase( propertyValue( "defaultCurrency", "usd" ) ); @@ -42,7 +42,7 @@ component if ( !len( propertyValue( "apiKey", "" ) ) ) { throw( type = "cbpayments.InvalidConfiguration", - message = "Stripe provider [#arguments.name#] requires apiKey." + message = "Stripe provider [#arguments.processorName#] requires apiKey." ); } variables.client = new stripecfml.stripe( @@ -54,7 +54,7 @@ component } ); } - return super.startup( arguments.name, arguments.properties ); + return super.startup( arguments.processorName, arguments.properties ); } any function createCheckout( required any paymentRequest ){ @@ -477,15 +477,7 @@ component matchedIndex, actualAccount ); - announce( - "cbpaymentsOnWebhookVerified", - safeContext().append( { - "eventId" : normalizedEvent.getEventId(), - "eventType" : normalizedEvent.getEventType(), - "matchedSecretIndex" : normalizedEvent.getMatchedSecretIndex() - } ) - ); - return normalizedEvent; + return announceVerifiedWebhook( normalizedEvent ); } private any function stripeOperation( @@ -816,13 +808,15 @@ component ) { rejectWebhook( "cbpayments.MalformedWebhook", "The signed webhook envelope is malformed." ); } - var object = event.keyExists( "data" ) && event.data.keyExists( "object" ) ? event.data.object : {}; - var eventArguments = { + var object = event.keyExists( "data" ) && event.data.keyExists( "object" ) ? event.data.object : {}; + var providerEventType = safeProviderString( event.type ); + var eventArguments = { "eventId" : safeProviderString( event.id ), - "eventType" : safeProviderString( event.type ), + "eventType" : normalizeWebhookEventType( providerEventType, object ), + "providerEventType" : providerEventType, "occurredAt" : event.keyExists( "created" ) ? event.created : 0, "livemode" : event.keyExists( "livemode" ) ? event.livemode : false, - "providerName" : variables.name, + "processorName" : variables.processorName, "providerType" : variables.providerType, "providerAccountId" : safeProviderString( arguments.actualAccount ), "objectType" : object.keyExists( "object" ) ? safeProviderString( object.object ) : "", @@ -840,6 +834,49 @@ component 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; diff --git a/models/testing/ProviderContract.cfc b/models/testing/ProviderContract.cfc index dc5b0b0..e826016 100644 --- a/models/testing/ProviderContract.cfc +++ b/models/testing/ProviderContract.cfc @@ -8,8 +8,8 @@ component { var requiredMethods = [ "startup", "shutdown", - "getName", - "getType", + "getProcessorName", + "getProviderType", "capabilities", "supports", "getClient" diff --git a/models/util/Redactor.cfc b/models/util/Redactor.cfc index e6dcea8..46d2979 100644 --- a/models/util/Redactor.cfc +++ b/models/util/Redactor.cfc @@ -69,6 +69,12 @@ component singleton { "[REDACTED]", "all" ); + safe = reReplaceNoCase( + safe, + "pk_(live|test)_[A-Za-z0-9_-]+", + "[REDACTED]", + "all" + ); safe = reReplaceNoCase( safe, "whsec_[A-Za-z0-9_-]+", 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 7419617..3535a93 100644 --- a/readme.md +++ b/readme.md @@ -1,8 +1,8 @@ # cbpayments -Provider-neutral named payment services for ColdBox 8 applications. +Provider-neutral payment processors for ColdBox 8 applications. -cbpayments gives an application one stable service for default and named providers while keeping credentials, Stripe accounts, API versions, and webhook secrets isolated per provider. Applications retain ownership of customers, invoices, authorization, accounting, durable idempotency allocation, ledgers, and webhook processing. +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 @@ -16,80 +16,90 @@ cbpayments gives an application one stable service for default and named provide box install cbpayments ``` -The module pins `stripecfml` 4.1.0. Stripe is accessed only through an isolated client created for each configured provider name. +## Terminology -## Five-minute InMemory example +- 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 = { - defaultProvider : "payments", - providers : { - payments : { provider : "InMemory", properties : {} } + defaultProcessor : "payments", + processors : { + payments : { + provider : "MockProvider@cbpayments", + properties : {} + } } }; ``` -```boxlang -money = new cbpayments.models.contracts.Money( 2500, "usd" ); -paymentRequest = new cbpayments.models.contracts.requests.HostedCheckoutRequest( - money = money, - idempotencyKey = "order-42-checkout-v1", - returnUrl = "https://example.test/payments/complete", - cancelUrl = "https://example.test/payments/cancel" -); - -result = getInstance( "PaymentService@cbpayments" ).createCheckout( paymentRequest ); -``` - -The InMemory provider records sanitized requests and supports deterministic queued failures, reset helpers, and webhook fixtures. Switch `provider` to `Stripe` without changing application operation code. - -## Stripe hosted checkout +Use WireBox to inject both the service and request objects: ```boxlang -moduleSettings.cbpayments = { - defaultProvider : "receivables", - providers : { - receivables : { - provider : "Stripe", - properties : { - apiKey : getSystemSetting( "STRIPE_API_KEY" ), - webhookSecrets : [ getSystemSetting( "STRIPE_WEBHOOK_SECRET" ) ], - apiVersion : "2026-02-25.clover", - defaultCurrency : "usd" +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 ); } -}; + +} ``` -cbpayments uses Checkout Sessions for ordinary web payments, Payment Intents for independently modeled/off-session state, Setup Intents for saving methods, and Payment Intents for capture/refunds. It does not expose Charges, Sources, Tokens, or raw card collection. +Switch the Processor to `StripeProvider@cbpayments` or `MoneiProvider@cbpayments-monei` without changing the operation code. -## Access and capabilities +## Capabilities in use -```boxlang -paymentService = getInstance( "PaymentService@cbpayments" ); -defaultProvider = getInstance( dsl = "cbpayments" ); -namedProvider = getInstance( dsl = "cbpayments:receivables" ); +Capability checks should guard real operations, not stand alone as dead code: -if ( paymentService.supports( "receivables", "refunds" ) ) { - // Use a normalized RefundRequest. +```boxlang +if ( !paymentService.supports( "receivables", "refunds" ) ) { + throw( type = "App.RefundsUnavailable" ); } -``` -The raw SDK is available only through a provider's explicit `getClient()` escape hatch. Keep that usage isolated behind an application adapter. +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) -- [Provider and operation guide](docs/providers.md) +- [Providers, Processors, and PaymentService API](docs/providers.md) +- [Contracts and capabilities](docs/contracts.md) - [Stripe guide](docs/stripe.md) -- [Webhook boundary](docs/webhooks.md) -- [Custom provider author guide](docs/custom-providers.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) -- [Release and recovery runbook](docs/releasing.md) -- [Architecture and delivery plan](docs/cbpayments-architecture-plan.md) ## License diff --git a/test-harness/Application.cfc b/test-harness/Application.cfc index 802bae8..b10f9ef 100644 --- a/test-harness/Application.cfc +++ b/test-harness/Application.cfc @@ -46,6 +46,7 @@ component{ // Module Root + Path Mappings this.mappings[ "/moduleroot" ] = moduleRootPath; this.mappings[ "/#request.MODULE_NAME#" ] = modulePath; + this.mappings[ "/cbpaymentsproviders" ] = modulePath & "providers/"; // application start public boolean function onApplicationStart(){ diff --git a/test-harness/config/Coldbox.cfc b/test-harness/config/Coldbox.cfc index f82eb0f..bc32823 100644 --- a/test-harness/config/Coldbox.cfc +++ b/test-harness/config/Coldbox.cfc @@ -44,16 +44,16 @@ modules = { // An array of modules names to load, empty means all of them include = [], - // Fixture is activated explicitly by its integration spec. - exclude = [ "cbpayments-fixture" ] + // Provider modules are activated explicitly by their integration specs. + exclude = [ "cbpayments-fixture", "cbpayments-monei" ] }; moduleSettings = { cbpayments : { - defaultProvider : "memory", - providers : { - memory : { provider : "InMemory", properties : {} }, - disabled : { provider : "Null", properties : {} } + defaultProcessor : "mock", + processors : { + mock : { provider : "MockProvider@cbpayments", properties : {} }, + disabled : { provider : "NullProvider@cbpayments", properties : {} } } } }; diff --git a/test-harness/modules/cbpayments-fixture/ModuleConfig.cfc b/test-harness/modules/cbpayments-fixture/ModuleConfig.cfc index 20f2060..6d9ceab 100644 --- a/test-harness/modules/cbpayments-fixture/ModuleConfig.cfc +++ b/test-harness/modules/cbpayments-fixture/ModuleConfig.cfc @@ -8,8 +8,12 @@ component { function configure(){ settings = { cbpayments : { - providerTypes : { FixturePay : { provider : "FixtureProvider@cbpayments-fixture" } }, - providers : { configured : { provider : "FixturePay", properties : {} } } + processors : { + configured : { + provider : "FixtureProvider@cbpayments-fixture", + properties : {} + } + } } }; } @@ -18,9 +22,7 @@ component { } function onUnload(){ - wirebox - .getInstance( "PaymentService@cbpayments" ) - .unregisterProviderType( "FixturePay", "cbpayments-fixture" ); + wirebox.getInstance( "PaymentService@cbpayments" ).unregisterProcessor( "configured@cbpayments-fixture" ); } } diff --git a/test-harness/modules/cbpayments-fixture/models/FixtureProvider.cfc b/test-harness/modules/cbpayments-fixture/models/FixtureProvider.cfc index 561917b..6b639c8 100644 --- a/test-harness/modules/cbpayments-fixture/models/FixtureProvider.cfc +++ b/test-harness/modules/cbpayments-fixture/models/FixtureProvider.cfc @@ -1,4 +1,4 @@ -component extends="cbpayments.models.providers.InMemoryProvider" { +component extends="cbpayments.models.providers.MockProvider" { function init(){ super.init(); 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 b0f599f..839007a 100644 --- a/test-harness/tests/Application.cfc +++ b/test-harness/tests/Application.cfc @@ -35,6 +35,7 @@ component { ); this.mappings[ "/moduleroot" ] = moduleRootPath; this.mappings[ "/#request.MODULE_NAME#" ] = moduleRootPath & "#request.MODULE_PATH#"; + this.mappings[ "/cbpaymentsproviders" ] = moduleRootPath & request.MODULE_PATH & "/providers/"; function onRequestStart( required targetPage ){ // Set a high timeout for long running tests diff --git a/test-harness/tests/resources/CountingProvider.cfc b/test-harness/tests/resources/CountingProvider.cfc index 3899de6..fb4f59b 100644 --- a/test-harness/tests/resources/CountingProvider.cfc +++ b/test-harness/tests/resources/CountingProvider.cfc @@ -1,4 +1,4 @@ -component extends="cbpayments.models.providers.InMemoryProvider" { +component extends="cbpayments.models.providers.MockProvider" { function init(){ super.init(); @@ -6,7 +6,7 @@ component extends="cbpayments.models.providers.InMemoryProvider" { return this; } - any function startup( required string name, struct properties = {} ){ + any function startup( required string processorName, struct properties = {} ){ if ( arguments.properties.keyExists( "counter" ) ) { arguments.properties.counter.incrementAndGet(); } 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/specs/integration/ModuleIntegrationSpec.cfc b/test-harness/tests/specs/integration/ModuleIntegrationSpec.cfc index d81f19c..65e7669 100644 --- a/test-harness/tests/specs/integration/ModuleIntegrationSpec.cfc +++ b/test-harness/tests/specs/integration/ModuleIntegrationSpec.cfc @@ -14,22 +14,29 @@ component extends="coldbox.system.testing.BaseTestCase" { expect( getController().getModuleService().isModuleActive( "cbpayments" ) ).toBeTrue(); var service = getInstance( "PaymentService@cbpayments" ); expect( service ).toBeComponent(); - expect( service.names() ).toInclude( "memory" ).toInclude( "disabled" ); + expect( service.processorNames() ).toInclude( "mock" ).toInclude( "disabled" ); } ); - it( "resolves default and named providers through the DSL", function(){ - expect( getInstance( dsl = "cbpayments" ).getName() ).toBe( "memory" ); - expect( getInstance( dsl = "cbpayments:default" ).getName() ).toBe( "memory" ); - expect( getInstance( dsl = "cbpayments:disabled" ).getType() ).toBe( "Null" ); + 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( "executes a consumer checkout through the service facade", function(){ - var money = new cbpayments.models.contracts.Money( 2500, "USD" ); - var paymentRequest = new cbpayments.models.contracts.requests.HostedCheckoutRequest( - money = money, - idempotencyKey = "integration-checkout-1", - returnUrl = "https://example.test/complete", - cancelUrl = "https://example.test/cancel" + 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(); @@ -53,16 +60,16 @@ component extends="coldbox.system.testing.BaseTestCase" { } ); var paymentEvent = getInstance( "PaymentService@cbpayments" ).verifyWebhook( rawBody, - "inmemory-test-signature", + "mock-test-signature", "acct_integration", - "memory" + "mock" ); expect( paymentEvent.getEventId() ).toBe( "evt_integration" ); expect( paymentEvent.getObjectId() ).toBe( "pi_integration" ); expect( paymentEvent.getProviderAccountId() ).toBe( "acct_integration" ); } ); - it( "switches providers without changing the consumer API", function(){ + it( "switches processors without changing the consumer API", function(){ var service = getInstance( "PaymentService@cbpayments" ); var stripeClient = new tests.resources.FakeStripeClient().enqueue( "checkout.sessions", @@ -81,9 +88,9 @@ component extends="coldbox.system.testing.BaseTestCase" { } } ); - service.register( + service.registerProcessor( "stripe-switch", - "Stripe", + "StripeProvider@cbpayments", { "client" : stripeClient, "webhookSecrets" : [ "whsec_provider_switch" ] @@ -96,7 +103,7 @@ component extends="coldbox.system.testing.BaseTestCase" { returnUrl = "https://example.test/complete", cancelUrl = "https://example.test/cancel" ); - var memoryResult = service.createCheckout( paymentRequest, "memory" ); + var memoryResult = service.createCheckout( paymentRequest, "mock" ); var stripeResult = service.createCheckout( paymentRequest, "stripe-switch" ); expect( memoryResult.getOk() ).toBeTrue(); expect( stripeResult.getOk() ).toBeTrue(); @@ -104,7 +111,7 @@ component extends="coldbox.system.testing.BaseTestCase" { expect( memoryResult.getNextAction().type ).toBe( "redirect" ); expect( stripeResult.getNextAction().type ).toBe( "redirect" ); } finally { - service.unregister( "stripe-switch" ); + service.unregisterProcessor( "stripe-switch" ); } } ); @@ -118,21 +125,19 @@ component extends="coldbox.system.testing.BaseTestCase" { ); moduleService.activateModule( "cbpayments-fixture" ); service.registerModuleContributions(); - expect( service.hasProviderType( "FixturePay" ) ).toBeTrue(); - expect( service.has( "configured@cbpayments-fixture" ) ).toBeTrue(); - var fixtureProvider = service.provider( "configured@cbpayments-fixture" ); - expect( fixtureProvider.getType() ).toBe( "FixturePay" ); + 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.hasProviderType( "FixturePay" ) ).toBeFalse(); - expect( service.has( "configured@cbpayments-fixture" ) ).toBeFalse(); + expect( service.hasProcessor( "configured@cbpayments-fixture" ) ).toBeFalse(); expect( fixtureProvider.hasStarted() ).toBeFalse(); } ); - it( "shuts down providers and reloads cleanly", function(){ + it( "shuts down processors and reloads cleanly", function(){ var moduleService = getController().getModuleService(); var oldService = getInstance( "PaymentService@cbpayments" ); - var oldProvider = oldService.provider( "memory" ); + var oldProvider = oldService.processor( "mock" ); moduleService.unload( "cbpayments" ); expect( oldProvider.hasStarted() ).toBeFalse(); moduleService.registerModule( @@ -142,8 +147,8 @@ component extends="coldbox.system.testing.BaseTestCase" { ); moduleService.activateModule( "cbpayments" ); var reloadedService = getInstance( "PaymentService@cbpayments" ); - expect( reloadedService.names() ).toInclude( "memory" ).toInclude( "disabled" ); - expect( reloadedService.provider( "memory" ).hasStarted() ).toBeTrue(); + expect( reloadedService.processorNames() ).toInclude( "mock" ).toInclude( "disabled" ); + expect( reloadedService.processor( "mock" ).hasStarted() ).toBeTrue(); } ); } ); } diff --git a/test-harness/tests/specs/unit/ContractsSpec.cfc b/test-harness/tests/specs/unit/ContractsSpec.cfc index 6bcfb9a..7886ff6 100644 --- a/test-harness/tests/specs/unit/ContractsSpec.cfc +++ b/test-harness/tests/specs/unit/ContractsSpec.cfc @@ -78,7 +78,7 @@ component extends="coldbox.system.testing.BaseTestCase" { var result = new cbpayments.models.contracts.results.PaymentResult( ok = true, operation = "paymentIntents.create", - providerName = "primary", + processorName = "primary", providerType = "Test", status = "future_provider_status", externalId = "external-1", @@ -88,7 +88,7 @@ component extends="coldbox.system.testing.BaseTestCase" { ); var serialized = result.getMemento(); expect( serialized ).toHaveKey( - "ok,operation,providerName,providerType,status,externalId,requestId,idempotencyKey,createdAt,amount,nextAction,failure,providerDetails" + "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" ); @@ -100,11 +100,11 @@ component extends="coldbox.system.testing.BaseTestCase" { "pi_cbpayments_secret_value" ); var result = new cbpayments.models.contracts.results.PaymentResult( - ok = true, - operation = "paymentIntents.create", - providerName = "primary", - providerType = "Stripe", - clientAction = action + 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/InMemoryProviderSpec.cfc b/test-harness/tests/specs/unit/MockProviderSpec.cfc similarity index 91% rename from test-harness/tests/specs/unit/InMemoryProviderSpec.cfc rename to test-harness/tests/specs/unit/MockProviderSpec.cfc index d9028f8..244842a 100644 --- a/test-harness/tests/specs/unit/InMemoryProviderSpec.cfc +++ b/test-harness/tests/specs/unit/MockProviderSpec.cfc @@ -1,9 +1,9 @@ component extends="coldbox.system.testing.BaseTestCase" { function run(){ - describe( "InMemory and Null providers", function(){ + describe( "Mock and Null providers", function(){ beforeEach( function(){ - provider = new cbpayments.models.providers.InMemoryProvider().startup( "memory" ); + provider = new cbpayments.models.providers.MockProvider().startup( "memory" ); money = new cbpayments.models.contracts.Money( 4200, "usd" ); } ); @@ -27,7 +27,7 @@ component extends="coldbox.system.testing.BaseTestCase" { ); var result = provider.createCheckout( paymentRequest ); expect( result.getStatus() ).toBe( "pending" ); - expect( result.getExternalId() ).toStartWith( "mem_hostedCheckout_" ); + 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" ); @@ -98,7 +98,7 @@ component extends="coldbox.system.testing.BaseTestCase" { it( "verifies test webhooks without retaining the raw body", function(){ var raw = serializeJSON( { "id" : "evt_memory", - "type" : "payment_intent.succeeded", + "type" : "payment.succeeded", "created" : 123, "livemode" : false, "data" : { @@ -109,8 +109,10 @@ component extends="coldbox.system.testing.BaseTestCase" { } } } ); - var event = provider.verifyWebhook( raw, "inmemory-test-signature" ); + 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(){ 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 index 6c18cb7..436732c 100644 --- a/test-harness/tests/specs/unit/ObservabilitySpec.cfc +++ b/test-harness/tests/specs/unit/ObservabilitySpec.cfc @@ -4,7 +4,7 @@ component extends="coldbox.system.testing.BaseTestCase" { 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.InMemoryProvider(); + var provider = new cbpayments.models.providers.MockProvider(); provider.setInterceptorService( recorder ); provider.startup( "memory", { "apiKey" : "sk_test_cbpayments_observability" } ); provider.createCheckout( @@ -22,17 +22,17 @@ component extends="coldbox.system.testing.BaseTestCase" { return event.state; } ); var serialized = serializeJSON( recorder.getEvents() ); - expect( states ).toInclude( "cbpaymentsOnProviderStart" ); + expect( states ).toInclude( "cbpaymentsOnProcessorStart" ); expect( states ).toInclude( "cbpaymentsPreOperation" ); expect( states ).toInclude( "cbpaymentsPostOperation" ); - expect( states ).toInclude( "cbpaymentsOnProviderShutdown" ); + 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.InMemoryProvider(); + var provider = new cbpayments.models.providers.MockProvider(); provider.setInterceptorService( recorder ); provider.setModuleSettings( { "logging" : { "includeProviderRequestIds" : false } } ); provider.startup( "memory" ); @@ -77,6 +77,7 @@ component extends="coldbox.system.testing.BaseTestCase" { } ).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" ); diff --git a/test-harness/tests/specs/unit/PaymentServiceSpec.cfc b/test-harness/tests/specs/unit/PaymentServiceSpec.cfc index d335e2f..f597a60 100644 --- a/test-harness/tests/specs/unit/PaymentServiceSpec.cfc +++ b/test-harness/tests/specs/unit/PaymentServiceSpec.cfc @@ -9,111 +9,107 @@ component extends="coldbox.system.testing.BaseTestCase" { } function run(){ - describe( "PaymentService registry", function(){ + describe( "PaymentService processor registry", function(){ beforeEach( function(){ service = new cbpayments.models.PaymentService(); service.setWirebox( getController().getWireBox() ); service.setModuleSettings( { - "defaultProvider" : "Primary", - "providers" : {}, - "providerTypes" : {} + "defaultProcessor" : "Primary", + "processors" : {}, + "webhooks" : { "toleranceSeconds" : 300 }, + "logging" : { "includeProviderRequestIds" : true } } ); - service.registerProviderType( - "Memory", - "InMemoryProvider@cbpayments", - "cbpayments" - ); } ); - it( "looks up names case-insensitively while preserving display spelling", function(){ - service.register( "Primary", "Memory" ); - expect( service.has( "primary" ) ).toBeTrue(); - expect( service.names() ).toInclude( "Primary" ); - expect( service.provider( "PRIMARY" ).getName() ).toBe( "Primary" ); - expect( service.defaultProvider() ).toBe( service.provider( "primary" ) ); + 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 provider names unless override is explicit", function(){ - service.register( "Primary", "Memory" ); + it( "fails duplicate processor names unless override is explicit", function(){ + service.registerProcessor( "Primary", "MockProvider@cbpayments" ); expect( function(){ - service.register( "primary", "Memory" ); - } ).toThrow( "cbpayments.DuplicateProvider" ); - var original = service.provider( "Primary" ); - service.register( "PRIMARY", "Memory", {}, true ); + 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.provider( "primary" ).getIdentifier() ).notToBe( original.getIdentifier() ); + expect( service.processor( "primary" ).getIdentifier() ).notToBe( original.getIdentifier() ); } ); it( "validates the configured default and unknown lookups", function(){ expect( function(){ - service.validateDefaultProvider(); + service.validateDefaultProcessor(); } ).toThrow( "cbpayments.InvalidConfiguration" ); expect( function(){ - service.provider( "missing" ); - } ).toThrow( "cbpayments.UnknownProvider" ); + service.processor( "missing" ); + } ).toThrow( "cbpayments.UnknownProcessor" ); } ); - it( "validates module settings and provider definition shapes", function(){ + it( "validates settings and processor definition shapes", function(){ service.setModuleSettings( { - "defaultProvider" : "Primary", - "providers" : {}, - "providerTypes" : {}, - "webhooks" : { "toleranceSeconds" : -1 }, - "logging" : { "includeProviderRequestIds" : true } + "defaultProcessor" : "Primary", + "processors" : {}, + "webhooks" : { "toleranceSeconds" : -1 }, + "logging" : { "includeProviderRequestIds" : true } } ); expect( function(){ service.validateSettings(); } ).toThrow( "cbpayments.InvalidConfiguration" ); service.setModuleSettings( { - "defaultProvider" : "Primary", - "providers" : { "Primary" : { "provider" : "Memory", "properties" : "invalid" } }, - "providerTypes" : {}, - "webhooks" : { "toleranceSeconds" : 300 }, - "logging" : { "includeProviderRequestIds" : true } + "defaultProcessor" : "Primary", + "processors" : { + "Primary" : { + "provider" : "MockProvider@cbpayments", + "properties" : "invalid" + } + }, + "webhooks" : { "toleranceSeconds" : 300 }, + "logging" : { "includeProviderRequestIds" : true } } ); expect( function(){ - service.registerAppProviders(); + service.registerAppProcessors(); } ).toThrow( "cbpayments.InvalidConfiguration" ); - } ); - - it( "tracks provider type ownership and reports collisions", function(){ - expect( function(){ - service.registerProviderType( "memory", "OtherProvider", "other-module" ); - } ).toThrow( "cbpayments.DuplicateProviderType" ); + service.setModuleSettings( { + "defaultProcessor" : "Primary", + "processors" : { "Primary" : { "provider" : "MockProvider" } }, + "webhooks" : { "toleranceSeconds" : 300 }, + "logging" : { "includeProviderRequestIds" : true } + } ); expect( function(){ - service.unregisterProviderType( "Memory", "other-module" ); - } ).toThrow( "cbpayments.ProviderTypeOwnership" ); - expect( service.providerTypeDescriptors()[ 1 ] ).notToHaveKey( "properties" ); + service.registerAppProcessors(); + } ).toThrow( "cbpayments.InvalidConfiguration" ); } ); - it( "shuts down configured instances before removing their provider type", function(){ - service.register( "Primary", "Memory" ); - var provider = service.provider( "Primary" ); - service.unregisterProviderType( "Memory", "cbpayments" ); - expect( provider.hasStarted() ).toBeFalse(); - expect( service.has( "Primary" ) ).toBeFalse(); + 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 base contract", function(){ - service.register( "Primary", "tests.resources.InvalidProvider" ); + it( "rejects objects that do not implement the provider contract", function(){ + service.registerProcessor( "Primary", "tests.resources.InvalidProvider" ); expect( function(){ - service.provider( "Primary" ); + service.processor( "Primary" ); } ).toThrow( "cbpayments.InvalidProviderContract" ); } ); - it( "fails unsupported capabilities before invoking a provider method", function(){ - service.registerProviderType( - "Null", - "NullProvider@cbpayments", - "cbpayments" - ); - service.register( "Primary", "Null" ); - var paymentRequest = new cbpayments.models.contracts.requests.RefundRequest( + 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( paymentRequest ); + service.createRefund( request ); } ).toThrow( "cbpayments.UnsupportedCapability" ); } ); @@ -121,19 +117,18 @@ component extends="coldbox.system.testing.BaseTestCase" { var counter = createObject( "java", "java.util.concurrent.atomic.AtomicInteger" ).init( 0 ); var threadNames = []; var serviceKey = "cbpaymentsConcurrency#replace( createUUID(), "-", "", "all" )#"; - service.registerProviderType( - "Counting", + service.registerProcessor( + "Primary", "tests.resources.CountingProvider", - "tests" + { "counter" : counter } ); - service.register( "Primary", "Counting", { "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 ].provider( "Primary" ); + server[ attributes.serviceKey ].processor( "Primary" ); } } thread action="join" name=threadNames.toList(); @@ -143,7 +138,7 @@ component extends="coldbox.system.testing.BaseTestCase" { expect( counter.get() ).toBe( 1 ); } ); - it( "constructs different provider names under independent locks", function(){ + 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 ); @@ -152,33 +147,28 @@ component extends="coldbox.system.testing.BaseTestCase" { var serviceKey = "cbpaymentsIndependentLocks#replace( createUUID(), "-", "", "all" )#"; var slowThread = "cbpayments-slow-#createUUID()#"; var fastThread = "cbpayments-fast-#createUUID()#"; - service.registerProviderType( - "Counting", - "tests.resources.CountingProvider", - "tests" - ); - service.register( + service.registerProcessor( "Slow", - "Counting", + "tests.resources.CountingProvider", { "counter" : slowCounter, "startupEnteredLatch" : enteredLatch, "startupReleaseLatch" : releaseLatch } ); - service.register( + service.registerProcessor( "Fast", - "Counting", + "tests.resources.CountingProvider", { "counter" : fastCounter } ); server[ serviceKey ] = service; try { thread name=slowThread action="run" serviceKey=serviceKey { - server[ attributes.serviceKey ].provider( "Slow" ); + server[ attributes.serviceKey ].processor( "Slow" ); } expect( enteredLatch.await( 2, timeUnit ) ).toBeTrue(); thread name=fastThread action="run" serviceKey=serviceKey { - server[ attributes.serviceKey ].provider( "Fast" ); + server[ attributes.serviceKey ].processor( "Fast" ); } thread action="join" name=fastThread timeout=2000; expect( fastCounter.get() ).toBe( 1 ); @@ -190,15 +180,15 @@ component extends="coldbox.system.testing.BaseTestCase" { expect( slowCounter.get() ).toBe( 1 ); } ); - it( "shuts down every instantiated provider", function(){ - service.register( "Primary", "Memory" ); - service.register( "Secondary", "Memory" ); - var first = service.provider( "Primary" ); - var second = service.provider( "Secondary" ); + 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.count() ).toBe( 0 ); + expect( service.processorCount() ).toBe( 0 ); } ); } ); } diff --git a/test-harness/tests/specs/unit/ProviderContractSpec.cfc b/test-harness/tests/specs/unit/ProviderContractSpec.cfc index d817a18..5027fcb 100644 --- a/test-harness/tests/specs/unit/ProviderContractSpec.cfc +++ b/test-harness/tests/specs/unit/ProviderContractSpec.cfc @@ -6,7 +6,7 @@ component extends="coldbox.system.testing.BaseTestCase" { var contract = new cbpayments.models.testing.ProviderContract(); expect( contract.verify( - new cbpayments.models.providers.InMemoryProvider().startup( "memory" ), + new cbpayments.models.providers.MockProvider().startup( "memory" ), [ "hostedCheckout", "webhooks" ] ).ok ).toBeTrue(); diff --git a/test-harness/tests/specs/unit/RedactorSpec.cfc b/test-harness/tests/specs/unit/RedactorSpec.cfc index 02ce679..f057431 100644 --- a/test-harness/tests/specs/unit/RedactorSpec.cfc +++ b/test-harness/tests/specs/unit/RedactorSpec.cfc @@ -25,10 +25,11 @@ component extends="coldbox.system.testing.BaseTestCase" { it( "scrubs recognizable secrets embedded in messages", function(){ var safe = redactor.redactString( - "key sk_test_short restricted rk_test_short secret whsec_short Bearer abc.def seti_123_secret_abc pm_123 card 4242 4242 4242 4242" + "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" ); diff --git a/test-harness/tests/specs/unit/StripeWebhookSpec.cfc b/test-harness/tests/specs/unit/StripeWebhookSpec.cfc index 98a560b..b5e03c3 100644 --- a/test-harness/tests/specs/unit/StripeWebhookSpec.cfc +++ b/test-harness/tests/specs/unit/StripeWebhookSpec.cfc @@ -18,6 +18,8 @@ component extends="coldbox.system.testing.BaseTestCase" { 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" ); } ); @@ -173,6 +175,59 @@ component extends="coldbox.system.testing.BaseTestCase" { 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" ); + } ); } ); } 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 ); + } ); + } ); + } + +}