diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..c2f9bb7 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + - package-ecosystem: maven + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e01a16e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + ci: + uses: killbill/gh-actions-shared/.github/workflows/ci.yml@main + with: + test-profile-matrix: '[ "travis" ]' + maven_args: >- + -Dmaven.test.redirectTestOutputToFile=true + -Dcheck.skip-dependency=true + -Dcheck.skip-dependency-scope=true + -Dcheck.skip-dependency-versions=true + -Dcheck.skip-duplicate-finder=true + -Dcheck.skip-enforcer=true + -Dcheck.skip-rat=true diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..76f0f7c --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,28 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: "0 8 * * 1" + +jobs: + analyze: + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "11" + - uses: github/codeql-action/init@v3 + with: + languages: java + - run: mvn -B -DskipTests package + - uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..474941f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,36 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "11" + - name: Build plugin + run: mvn -B clean verify + - name: Upload release asset + env: + GH_TOKEN: ${{ github.token }} + run: | + VERSION="${GITHUB_REF_NAME#v}" + JAR="target/kintsugi-plugin-${VERSION}.jar" + if [[ ! -f "${JAR}" ]]; then + echo "Expected ${JAR} not found" >&2 + ls -la target/ >&2 + exit 1 + fi + if ! gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1; then + gh release create "${GITHUB_REF_NAME}" --generate-notes + fi + gh release upload "${GITHUB_REF_NAME}" "${JAR}" --clobber diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba6a3fb --- /dev/null +++ b/.gitignore @@ -0,0 +1,87 @@ +# +# JetBrains IDEs +# +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf +.idea/**/contentModel.xml +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml +.idea/**/gradle.xml +.idea/**/libraries +.idea/artifacts +.idea/compiler.xml +.idea/jarRepositories.xml +.idea/modules.xml +.idea/*.iml +.idea/modules +*.iml +*.ipr +out/ + +# +# Eclipse +# +.metadata +bin/ +tmp/ +*.tmp +*.bak +*.swp +*~.nib +local.properties +.settings/ +.loadpath +.recommenders +.classpath +.externalToolBuilders/ +*.launch +.factorypath +.buildpath +.target +.project + +# +# Maven +# +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +.mvn/wrapper/maven-wrapper.jar + +# +# Java +# +*.class +*.log +*.ctxt +.mtj.tmp/ +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar +hs_err_pid* + +# +# macOS +# +.DS_Store +.AppleDouble +.LSOverride +docker/.env diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2f12573 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,39 @@ +# Contributing + +Thanks for your interest in the Kill Bill Kintsugi tax plugin. + +## Development setup + +- **JDK 11** (required for `mvn verify` — matches CI; newer JDKs may break Mockito/OSGi test mocks) +- Maven 3.9+ +- Optional: Docker for local Kill Bill testing (see [docker/README.md](docker/README.md)) + +## Build and test + +```bash +mvn clean verify +``` + +Unit tests use **TestNG** with a `fast` group (same pattern as other Kill Bill invoice plugins). CI uses the shared [killbill/gh-actions-shared](https://github.com/killbill/gh-actions-shared) workflow on Java 11. + +**Docker E2E** (recommended): see [docker/README.md](docker/README.md). + +```bash +cp docker/.env.example docker/.env +./docker/run-e2e.sh +``` + +## Pull requests + +1. Fork the repository and create a feature branch. +2. Keep changes focused; include tests for behavior changes. +3. Ensure `mvn verify` passes locally. +4. Open a PR with a clear summary and test notes. + +## Code style + +Match the existing code in this repository: Apache 2.0 headers on new Java files, minimal comments, and Kill Bill plugin conventions from [killbill-hello-world-java-plugin](https://github.com/killbill/killbill-hello-world-java-plugin). + +## Releases + +Maintainers cut releases from `main` using git tags (`v*`). The release workflow attaches the built JAR to the GitHub release. diff --git a/LICENSE b/LICENSE index 261eeb9..7a4a3ea 100644 --- a/LICENSE +++ b/LICENSE @@ -1,3 +1,4 @@ + Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ @@ -198,4 +199,4 @@ 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. + limitations under the License. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..4117c7e --- /dev/null +++ b/README.md @@ -0,0 +1,173 @@ +# Kill Bill Kintsugi Tax Plugin + +Kill Bill **invoice plugin** that delegates sales tax to [Kintsugi](https://trykintsugi.com) during invoice generation. + +## What it does + +On each invoice (dry-run or commit), the plugin: + +1. Maps Kill Bill invoice line items and account ship-to address to a tax estimate request +2. Calls `POST /killbill/tax/estimate` or `/commit` on your Kintsugi API +3. Maps tax lines to Kill Bill `TAX` invoice items linked to taxable lines + +Authentication on every call: + +| Header | Value | +|--------|--------| +| `X-Killbill-ApiKey` | Kill Bill tenant API key (must match your Kintsugi Kill Bill connection) | +| `X-Killbill-Kintsugi-Signature` | HMAC-SHA256 hex digest of the **raw JSON body** | + +The HMAC secret must match on both the Kill Bill plugin config and your Kintsugi Kill Bill connection. + +## Prerequisites + +- JDK 11+ +- Maven 3.9+ (build from source) or a release JAR +- Kill Bill **0.24.x** with invoice plugin support +- A Kintsugi account with Kill Bill connected and the tax engine enabled + +### Kill Bill compatibility + +| Plugin version | Kill Bill | +|----------------|-----------| +| 0.1.x | 0.24.x | + +## Build and test + +```bash +mvn clean verify +``` + +**Docker E2E** (recommended for maintainers): see [docker/README.md](docker/README.md). + +Artifact: `target/kintsugi-plugin-0.1.0.jar` + +## Install on Kill Bill + +### Option A: KPM (recommended when a release is published) + +```bash +kpm install_java_plugin kintsugi --from-source-file=target/kintsugi-plugin-0.1.0.jar +``` + +Or install a published artifact from Maven Central once the Kill Bill project publishes +`org.kill-bill.billing.plugin.java:kintsugi-plugin` — see [Kill Bill KPM](https://docs.killbill.io/latest/kpm). +Until a release is published on this repository, build from source or use a maintainer-built JAR from [GitHub releases](https://github.com/killbill/killbill-kintsugi-plugin/releases). + +### Option B: Manual copy + +1. Copy the JAR into Kill Bill's Java plugin layout, e.g. + `/var/lib/killbill/bundles/plugins/java/killbill-kintsugi/0.1.0/kintsugi-plugin-0.1.0.jar` +2. Set the default version symlink if your deployment uses one. +3. Restart Kill Bill and confirm the plugin is `RUNNING` with `InvoicePluginApi` in `GET /1.0/kb/pluginsInfo`. + +See [Kill Bill plugin installation](https://docs.killbill.io/latest/plugin_installation). + +## Tenant configuration + +### 1. Enable the invoice plugin + +Kill Bill 0.24+ expects JSON for per-tenant config: + +```bash +curl -u ':' \ + -H 'X-Killbill-ApiKey: ' \ + -H 'X-Killbill-ApiSecret: ' \ + -H 'Content-Type: text/plain' \ + -H 'X-Killbill-CreatedBy: setup' \ + -d '{"org.killbill.invoice.plugin":"killbill-kintsugi"}' \ + 'https:///1.0/kb/tenants/uploadPerTenantConfig' +``` + +### 2. Upload plugin config + +```bash +curl -u ':' \ + -H 'X-Killbill-ApiKey: ' \ + -H 'X-Killbill-ApiSecret: ' \ + -H 'Content-Type: text/plain' \ + -H 'X-Killbill-CreatedBy: setup' \ + -d 'kintsugiUrl: https://api.trykintsugi.com +hmacSecret: ' \ + 'https:///1.0/kb/tenants/uploadPluginConfig/killbill-kintsugi' +``` + +YAML POJO form is also supported — see `KintsugiConfigurationHandler` in this repo. + +| Config key | Required | Description | +|------------|----------|-------------| +| `kintsugiUrl` | Yes | Kintsugi API base URL (no trailing slash), e.g. `https://api.trykintsugi.com` | +| `hmacSecret` | Yes | Shared secret; must match the HMAC secret on your Kintsugi Kill Bill connection | +| `killbillUrl` | No | Kill Bill base URL for optional Aviate billing-account lookup (default `http://127.0.0.1:8080`) | +| `aviateIdToken` | No | Aviate JWT ([Aviate auth](https://docs.killbill.io/latest/aviate-authentication)). When set, the plugin reads [billing accounts](https://docs.killbill.io/latest/aviate-billing-account) before falling back to custom fields. Omit for non-Aviate deployments. | + +`kintsugiUrl` must be reachable from the Kill Bill JVM (network/firewall/DNS). + +## Kintsugi setup + +In your Kintsugi account: + +1. Connect Kill Bill (base URL, tenant API key/secret, admin credentials). +2. Set the connection HMAC secret to the same value as plugin `hmacSecret`. +3. Enable the tax engine on the connection. + +See [Kintsugi documentation](https://trykintsugi.com/docs) for the full setup guide. + +## Verify + +After configuration, generate or dry-run an invoice for an account with taxable line items. The invoice should include `TAX` rows linked to taxable `EXTERNAL_CHARGE` or subscription lines when Kintsugi returns non-zero tax. + +Check Kill Bill logs for `Kintsugi returned N tax line(s)` from `KintsugiInvoicePluginApi`. + +### Healthcheck + +```bash +curl -u ':' \ + -H 'X-Killbill-ApiKey: ' \ + -H 'X-Killbill-ApiSecret: ' \ + 'https:///plugins/killbill-kintsugi/healthcheck' +``` + +Returns healthy when the plugin is loaded and tenant config is present. + +## Local development + +Maintainers: use the self-contained docker toolkit (no Kintsugi platform repo required): + +```bash +cp docker/.env.example docker/.env +./docker/run-e2e.sh +``` + +See [docker/README.md](docker/README.md) for step-by-step scripts, configuration, and troubleshooting. + +## Troubleshooting + +| Symptom | Likely cause | +|---------|----------------| +| No `TAX` lines on invoice | Tax engine not enabled in Kintsugi, or ship-to address has no tax obligation | +| `401` / `403` from Kintsugi | HMAC mismatch — verify the same secret on plugin config and Kintsugi connection | +| `Kintsugi plugin not configured` in logs | Missing `uploadPluginConfig/killbill-kintsugi` for the tenant | +| Connection timeout | Kill Bill cannot reach `kintsugiUrl` (DNS, firewall, or Docker networking) | +| Healthcheck unhealthy | Plugin config missing `kintsugiUrl` or `hmacSecret` for the tenant | + +## Behavior notes + +- **Dual deployment**: Aviate tenants — the Aviate plugin will pass [plugin properties](docs/aviate-integration.md#plugin-property-contract) on invoice generation; optional `aviateIdToken` fills gaps via billing-account HTTP. Non-Aviate tenants use custom fields only. +- **HTTP/1.1**: outbound calls use HTTP/1.1 so request bodies match HMAC signatures reliably. +- **External charges**: lines without a plan name use a default product category. +- **Retries**: transient failures raise `InvoicePluginApiRetryException` (1m / 5m / 15m backoff). +- **Zero tax**: `$0` tax lines are not added to the invoice. + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md). Security reports: [SECURITY.md](SECURITY.md). + +Maven Central publishing is handled by the Kill Bill project after upstream transfer; +see [docs/maven-central-handoff.md](docs/maven-central-handoff.md). + +Local testing: [docker/README.md](docker/README.md). + +## License + +Apache License 2.0 — see [LICENSE](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..2b32190 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| ------- | --------- | +| 0.1.x | Yes | + +## Reporting a Vulnerability + +Please report security issues privately to **security@trykintsugi.com**. + +Include: + +- A description of the issue and potential impact +- Steps to reproduce +- Affected plugin version and Kill Bill version + +We aim to acknowledge reports within two business days. + +Please do not open public GitHub issues for security vulnerabilities. diff --git a/docker/.env.example b/docker/.env.example new file mode 100644 index 0000000..6db29bd --- /dev/null +++ b/docker/.env.example @@ -0,0 +1,31 @@ +# Copy to docker/.env and adjust for your environment. +# cp docker/.env.example docker/.env +# +# Kintsugi test org setup (one-time, in Kintsugi UI): +# 1. Create a Kill Bill connection on your test org +# 2. Set the connection HMAC secret → copy to KINTSUGI_HMAC_SECRET below +# 3. Note the Kill Bill tenant API key on the connection (external_id) +# → must equal KILLBILL_API_KEY below +# +# The plugin does NOT use Kintsugi organization_id or Kintsugi API keys. +# Kintsugi resolves your org from X-Killbill-ApiKey (the Kill Bill tenant key). + +# Kill Bill (docker compose stack) +KILLBILL_URL=http://127.0.0.1:8080 +KILLBILL_ADMIN_USER=admin +KILLBILL_ADMIN_PASSWORD=password + +# Kill Bill tenant credentials — create in Kaui with this API key, or use defaults bob/lazar +# MUST match the Kintsugi Kill Bill connection external_id (tenant API key) +KILLBILL_API_KEY=bob +KILLBILL_API_SECRET=lazar + +# Kintsugi API (reachable from the Kill Bill Docker container) +KINTSUGI_URL=https://api.trykintsugi.com +# KINTSUGI_URL=https://api.sandbox.trykintsugi.com + +# Must match HMAC secret on the Kintsugi Kill Bill connection (not the org API key) +KINTSUGI_HMAC_SECRET=change-me-to-match-kintsugi-connection + +# smoke-invoice.sh ship-to state (needs active tax registration in your test org) +SMOKE_TAX_STATE=TX diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000..c0b5bbc --- /dev/null +++ b/docker/README.md @@ -0,0 +1,101 @@ +# Docker testing (Kill Bill maintainers) + +Self-contained scripts to build, install, and test the Kintsugi invoice plugin +against a local Kill Bill stack. No dependency on the Kintsugi platform repo. + +## Prerequisites + +- Docker + Docker Compose +- JDK 11+ and Maven 3.9+ +- `curl`, `python3` +- A Kintsugi test account with Kill Bill connected and tax engine enabled (for full smoke only) + +## Quick start + +```bash +cp docker/.env.example docker/.env +# Edit KINTSUGI_HMAC_SECRET (must match Kintsugi Kill Bill connection) +# Edit KILLBILL_API_KEY / KILLBILL_API_SECRET for your tenant + +docker compose -f docker/docker-compose.yml up -d +./docker/run-e2e.sh +``` + +Or step by step: + +```bash +./docker/install-plugin.sh # build JAR, install, verify pluginsInfo +./docker/setup-tenant.sh # uploadPluginConfig + enable invoice plugin +./docker/verify-plugin.sh # pluginsInfo + healthcheck (no Kintsugi API call) +./docker/smoke-invoice.sh # external charge → expect TAX lines +``` + +## Services + +| URL | Service | +|-----|---------| +| http://localhost:8080 | Kill Bill | +| http://localhost:9090 | Kaui | + +Default admin: `admin` / `password` + +## Scripts + +| Script | Purpose | +|--------|---------| +| `install-plugin.sh` | `mvn package`, copy JAR, `SET_DEFAULT` symlink, restart, verify `pluginsInfo` | +| `setup-tenant.sh` | Upload `kintsugiUrl` + `hmacSecret`, enable `killbill-kintsugi` invoice plugin | +| `verify-plugin.sh` | Confirm plugin is `RUNNING` with `InvoicePluginApi` (no Kintsugi API) | +| `smoke-invoice.sh` | Create account, post external charge, assert `TAX` invoice items | +| `run-e2e.sh` | Runs the full sequence above | +| `lib.sh` | Shared helpers (do not run directly) | + +## Configuration (`docker/.env`) + +Kintsugi provisions a **test org** for Kill Bill maintainers. You do not put `organization_id` or a Kintsugi API key in these env vars — the plugin identifies your org via the Kill Bill tenant API key on each tax call. + +| Variable | Description | +|----------|-------------| +| `KILLBILL_API_KEY` / `KILLBILL_API_SECRET` | Kill Bill tenant credentials (create in Kaui). **Must equal** the Kintsugi Kill Bill connection `external_id`. | +| `KINTSUGI_URL` | Kintsugi API base URL reachable **from the Kill Bill container** | +| `KINTSUGI_HMAC_SECRET` | HMAC secret from the Kintsugi Kill Bill connection (not an API key) | +| `SMOKE_TAX_STATE` | US state for smoke ship-to (default `TX`; needs tax registration in the test org) | + +**Test org checklist (Kintsugi side):** + +1. Kill Bill connection created with tax engine enabled +2. Connection HMAC secret → `KINTSUGI_HMAC_SECRET` +3. Connection tenant API key → `KILLBILL_API_KEY` (and matching Kill Bill tenant in Kaui) + +For a Kintsugi API running on the host machine: + +```bash +KINTSUGI_URL=http://host.docker.internal:8000 +``` + +## Plugin layout (manual reference) + +Kill Bill docker images use `/var/lib/killbill/bundles` as the plugin root: + +``` +plugins/java/killbill-kintsugi/ + 0.1.0/kintsugi-plugin-0.1.0.jar + SET_DEFAULT -> 0.1.0 +``` + +`install-plugin.sh` creates this layout automatically. Version and artifact name are read from `pom.xml`. + +## Troubleshooting + +| Symptom | Fix | +|---------|-----| +| Plugin not in `pluginsInfo` | Re-run `install-plugin.sh`; check `docker logs` for OSGi errors | +| Healthcheck unhealthy | Run `setup-tenant.sh`; verify `kintsugiUrl` and `hmacSecret` | +| No `TAX` lines on invoice | Confirm Kintsugi tax engine + state registration; check Kill Bill logs for `Kintsugi tax estimate failed` | +| Connection timeout to Kintsugi | Use `host.docker.internal` or a public API URL reachable from Docker | + +## Tear down + +```bash +docker compose -f docker/docker-compose.yml down -v +``` diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..d9e7d8a --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,39 @@ +# Local Kill Bill + Kaui stack for plugin development. + +services: + killbill: + image: killbill/killbill:0.24.9 + ports: + - "8080:8080" + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - killbill-plugins:/var/lib/killbill/bundles/plugins/java + environment: + - KILLBILL_DAO_URL=jdbc:mysql://db:3306/killbill + - KILLBILL_DAO_USER=killbill + - KILLBILL_DAO_PASSWORD=killbill + depends_on: + - db + db: + image: killbill/mariadb:0.24 + environment: + - MYSQL_ROOT_PASSWORD=root + - MYSQL_DATABASE=killbill + - MYSQL_USER=killbill + - MYSQL_PASSWORD=killbill + kaui: + image: killbill/kaui:3.0.9 + ports: + - "9090:8080" + environment: + - KAUI_CONFIG_DAO_URL=jdbc:mysql://db:3306/kaui + - KAUI_CONFIG_DAO_USER=kaui + - KAUI_CONFIG_DAO_PASSWORD=kaui + - KAUI_KILLBILL_URL=http://killbill:8080 + depends_on: + - db + - killbill + +volumes: + killbill-plugins: diff --git a/docker/install-plugin.sh b/docker/install-plugin.sh new file mode 100755 index 0000000..0031f4a --- /dev/null +++ b/docker/install-plugin.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Build the plugin JAR and install it into the local Kill Bill docker container. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +source "${SCRIPT_DIR}/lib.sh" + +require_cmd docker curl mvn + +if [[ -n "${JAVA_HOME:-}" ]]; then + export PATH="${JAVA_HOME}/bin:${PATH}" +elif [[ -d /opt/homebrew/opt/openjdk@11 ]]; then + export JAVA_HOME="/opt/homebrew/opt/openjdk@11" + export PATH="${JAVA_HOME}/bin:${PATH}" +fi + +CONTAINER="$(killbill_container_id)" +if [[ -z "${CONTAINER}" ]]; then + echo "Start the stack first: docker compose -f docker/docker-compose.yml up -d" >&2 + exit 1 +fi + +JAR_NAME="$(plugin_jar_name)" +PLUGIN_PATH="$(plugin_install_path)" +VERSION_DIR="$(plugin_container_path)" + +echo "==> mvn package in ${REPO_ROOT}" +(cd "${REPO_ROOT}" && mvn -q package -DskipTests) + +echo "==> Install ${JAR_NAME} → ${PLUGIN_PATH}" +docker exec "${CONTAINER}" mkdir -p "${VERSION_DIR}" +docker cp "${REPO_ROOT}/target/${JAR_NAME}" "${CONTAINER}:${PLUGIN_PATH}" + +echo "==> Set default plugin version (SET_DEFAULT → ${VERSION_DIR##*/})" +docker exec "${CONTAINER}" ln -sfn "${VERSION_DIR##*/}" \ + "/var/lib/killbill/bundles/plugins/java/${PLUGIN_NAME}/SET_DEFAULT" + +echo "==> Restart Kill Bill" +docker compose -f "${COMPOSE_FILE}" restart killbill +wait_for_killbill + +echo "==> Verify plugin in pluginsInfo" +PLUGINS_FILE="$(mktemp)" +trap 'rm -f "${PLUGINS_FILE}"' EXIT +curl -sf "${kb_auth[@]}" "${KILLBILL_URL}/1.0/kb/pluginsInfo" > "${PLUGINS_FILE}" +python3 - "${PLUGIN_NAME}" "${PLUGINS_FILE}" <<'PY' +import json, sys +plugin_name, path = sys.argv[1], sys.argv[2] +with open(path, encoding="utf-8") as fh: + plugins = json.load(fh) +match = [p for p in plugins if p.get("pluginName") == plugin_name] +if not match: + print(f"FAIL: plugin {plugin_name} not found in pluginsInfo", file=sys.stderr) + sys.exit(1) +p = match[0] +state = p.get("pluginState") +services = p.get("services") or [] +print(f"pluginName={p.get('pluginName')} state={state}") +print("services:", ", ".join(services) or "(none)") +if state != "RUNNING": + print("WARN: plugin is not RUNNING — check Kill Bill logs", file=sys.stderr) +if "InvoicePluginApi" not in services: + print("FAIL: InvoicePluginApi not registered", file=sys.stderr) + sys.exit(1) +print("OK: plugin installed") +PY + +echo +echo "Next: ./docker/setup-tenant.sh # configure tenant + plugin" +echo " ./docker/verify-plugin.sh # healthcheck (no Kintsugi API needed)" +echo " ./docker/smoke-invoice.sh # full invoice tax test (needs Kintsugi)" diff --git a/docker/lib.sh b/docker/lib.sh new file mode 100755 index 0000000..589c66d --- /dev/null +++ b/docker/lib.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Shared helpers for Kill Bill + Kintsugi plugin docker scripts. +set -euo pipefail + +DOCKER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${DOCKER_DIR}/.." && pwd)" +COMPOSE_FILE="${DOCKER_DIR}/docker-compose.yml" +ENV_FILE="${KILLBILL_ENV_FILE:-${DOCKER_DIR}/.env}" + +# OSGi plugin name (must match KintsugiActivator.PLUGIN_NAME). +export PLUGIN_NAME="${PLUGIN_NAME:-killbill-kintsugi}" + +if [[ -f "${ENV_FILE}" ]]; then + # shellcheck disable=SC1090 + set -a && source "${ENV_FILE}" && set +a +fi + +export KILLBILL_URL="${KILLBILL_URL:-http://127.0.0.1:8080}" +export KILLBILL_API_KEY="${KILLBILL_API_KEY:-bob}" +export KILLBILL_API_SECRET="${KILLBILL_API_SECRET:-lazar}" +export KILLBILL_ADMIN_USER="${KILLBILL_ADMIN_USER:-admin}" +export KILLBILL_ADMIN_PASSWORD="${KILLBILL_ADMIN_PASSWORD:-password}" +export KINTSUGI_URL="${KINTSUGI_URL:-https://api.trykintsugi.com}" +export SMOKE_TAX_STATE="${SMOKE_TAX_STATE:-TX}" + +require_kintsugi_hmac_secret() { + if [[ -z "${KINTSUGI_HMAC_SECRET:-}" ]]; then + echo "Set KINTSUGI_HMAC_SECRET in docker/.env (must match your Kintsugi Kill Bill connection)" >&2 + exit 1 + fi + case "${KINTSUGI_HMAC_SECRET}" in + change-me-to-match-kintsugi-connection|local-dev-hmac-secret) + echo "Set KINTSUGI_HMAC_SECRET in docker/.env to your real connection secret" >&2 + exit 1 + ;; + esac +} + +kb_auth=(-u "${KILLBILL_ADMIN_USER}:${KILLBILL_ADMIN_PASSWORD}") +kb_tenant=(-H "X-Killbill-ApiKey: ${KILLBILL_API_KEY}" -H "X-Killbill-ApiSecret: ${KILLBILL_API_SECRET}") + +require_cmd() { + for cmd in "$@"; do + command -v "${cmd}" >/dev/null 2>&1 || { + echo "Missing required command: ${cmd}" >&2 + exit 1 + } + done +} + +maven_project_prop() { + local expression="$1" + (cd "${REPO_ROOT}" && mvn -q help:evaluate -Dexpression="${expression}" -DforceStdout) +} + +plugin_jar_name() { + local version artifact + version="$(maven_project_prop project.version)" + artifact="$(maven_project_prop project.artifactId)" + echo "${artifact}-${version}.jar" +} + +plugin_install_path() { + local version jar + version="$(maven_project_prop project.version)" + jar="$(plugin_jar_name)" + echo "/var/lib/killbill/bundles/plugins/java/${PLUGIN_NAME}/${version}/${jar}" +} + +killbill_container_id() { + docker compose -f "${COMPOSE_FILE}" ps -q killbill 2>/dev/null || true +} + +wait_for_killbill() { + local attempts="${1:-60}" + local i + echo "==> Waiting for Kill Bill at ${KILLBILL_URL}" + for ((i = 1; i <= attempts; i++)); do + if curl -sf "${KILLBILL_URL}/1.0/healthcheck" >/dev/null 2>&1; then + echo "Kill Bill is up" + return 0 + fi + sleep 5 + done + echo "Kill Bill did not become healthy in time" >&2 + return 1 +} + +require_killbill() { + curl -sf "${KILLBILL_URL}/1.0/healthcheck" >/dev/null || { + echo "Kill Bill not healthy at ${KILLBILL_URL} — run: docker compose -f docker/docker-compose.yml up -d" >&2 + exit 1 + } +} + +plugin_container_path() { + local version + version="$(maven_project_prop project.version)" + echo "/var/lib/killbill/bundles/plugins/java/${PLUGIN_NAME}/${version}" +} diff --git a/docker/run-e2e.sh b/docker/run-e2e.sh new file mode 100755 index 0000000..149cd4f --- /dev/null +++ b/docker/run-e2e.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# One-command local workflow for Kill Bill maintainers. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [[ ! -f "${SCRIPT_DIR}/.env" ]]; then + echo "Create docker/.env first:" >&2 + echo " cp docker/.env.example docker/.env" >&2 + echo " # edit KINTSUGI_HMAC_SECRET and tenant credentials" >&2 + exit 1 +fi + +# shellcheck source=lib.sh +source "${SCRIPT_DIR}/lib.sh" + +require_cmd docker curl + +echo "==> Start Kill Bill stack" +docker compose -f "${COMPOSE_FILE}" up -d +wait_for_killbill + +"${SCRIPT_DIR}/install-plugin.sh" +"${SCRIPT_DIR}/setup-tenant.sh" +"${SCRIPT_DIR}/verify-plugin.sh" +"${SCRIPT_DIR}/smoke-invoice.sh" + +echo +echo "E2E smoke passed." diff --git a/docker/setup-tenant.sh b/docker/setup-tenant.sh new file mode 100755 index 0000000..d31afd2 --- /dev/null +++ b/docker/setup-tenant.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Enable the Kintsugi invoice plugin and upload per-tenant config. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +source "${SCRIPT_DIR}/lib.sh" + +require_cmd curl +require_killbill + +require_kintsugi_hmac_secret + +echo "==> Upload plugin config (kintsugiUrl + hmacSecret)" +curl -sf "${kb_auth[@]}" "${kb_tenant[@]}" \ + -H "Content-Type: text/plain" \ + -H "X-Killbill-CreatedBy: kintsugi-docker-setup" \ + -d "kintsugiUrl: ${KINTSUGI_URL} +hmacSecret: ${KINTSUGI_HMAC_SECRET}" \ + "${KILLBILL_URL}/1.0/kb/tenants/uploadPluginConfig/${PLUGIN_NAME}" + +echo +echo "==> Enable invoice plugin on tenant" +curl -sf "${kb_auth[@]}" "${kb_tenant[@]}" \ + -H "Content-Type: text/plain" \ + -H "X-Killbill-CreatedBy: kintsugi-docker-setup" \ + -d "{\"org.killbill.invoice.plugin\":\"${PLUGIN_NAME}\"}" \ + "${KILLBILL_URL}/1.0/kb/tenants/uploadPerTenantConfig" + +echo +echo "==> Plugin healthcheck" +HEALTH="$(curl -sf "${kb_auth[@]}" "${kb_tenant[@]}" \ + "${KILLBILL_URL}/plugins/${PLUGIN_NAME}/healthcheck")" +echo "${HEALTH}" | python3 -m json.tool 2>/dev/null || echo "${HEALTH}" + +echo +echo "Tenant setup complete." +echo " kintsugiUrl=${KINTSUGI_URL}" +echo " tenant apiKey=${KILLBILL_API_KEY}" diff --git a/docker/smoke-invoice.sh b/docker/smoke-invoice.sh new file mode 100755 index 0000000..c23db7d --- /dev/null +++ b/docker/smoke-invoice.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# Post an external charge and check whether the plugin adds TAX invoice items. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +source "${SCRIPT_DIR}/lib.sh" + +require_cmd curl python3 +require_killbill + +echo "==> Create account (${SMOKE_TAX_STATE} ship-to)" +ACCOUNT_HEADERS="$(mktemp)" +trap 'rm -f "${ACCOUNT_HEADERS}"' EXIT +curl -sf -D "${ACCOUNT_HEADERS}" -o /dev/null "${kb_auth[@]}" "${kb_tenant[@]}" \ + -H "Content-Type: application/json" \ + -H "X-Killbill-CreatedBy: kintsugi-smoke-invoice" \ + -d "$(SMOKE_TAX_STATE="${SMOKE_TAX_STATE}" python3 <<'PY' +import json, os, time +state = (os.environ.get("SMOKE_TAX_STATE") or "TX").strip().upper() +print(json.dumps({ + "name": "Kintsugi Plugin Smoke", + "email": f"smoke-{int(time.time())}@example.com", + "currency": "USD", + "country": "US", + "state": state, + "city": "Austin", + "postalCode": "78701", + "address1": "1 Congress Ave", +})) +PY +)" \ + "${KILLBILL_URL}/1.0/kb/accounts" +ACCOUNT_ID="$(grep -i '^Location:' "${ACCOUNT_HEADERS}" | sed -E 's|.*/accounts/||' | tr -d '\r')" +echo "accountId=${ACCOUNT_ID}" + +echo "==> Add external charge \$100" +curl -sf "${kb_auth[@]}" "${kb_tenant[@]}" \ + -H "Content-Type: application/json" \ + -H "X-Killbill-CreatedBy: kintsugi-smoke-invoice" \ + -d '[{ + "amount": 100.00, + "currency": "USD", + "description": "Smoke test taxable charge", + "itemType": "EXTERNAL_CHARGE" + }]' \ + "${KILLBILL_URL}/1.0/kb/invoices/charges/${ACCOUNT_ID}" >/dev/null + +echo "==> Fetch invoice lines" +INVOICES="$(curl -sf "${kb_auth[@]}" "${kb_tenant[@]}" \ + "${KILLBILL_URL}/1.0/kb/accounts/${ACCOUNT_ID}/invoices")" +INVOICE_ID="$(python3 -c "import json,sys; print(json.load(sys.stdin)[0]['invoiceId'])" <<<"${INVOICES}")" +ITEMS_FILE="$(mktemp)" +curl -sf "${kb_auth[@]}" "${kb_tenant[@]}" \ + "${KILLBILL_URL}/1.0/kb/invoices/${INVOICE_ID}" > "${ITEMS_FILE}" + +python3 - "${ITEMS_FILE}" "${SMOKE_TAX_STATE}" <<'PY' +import json, sys +path, state = sys.argv[1], sys.argv[2] +with open(path, encoding="utf-8") as fh: + inv = json.load(fh) +lines = inv.get("items") or [] +print("invoiceId", inv.get("invoiceId"), "status", inv.get("status")) +for line in lines: + print(" -", line.get("itemType"), line.get("amount"), line.get("description")) +tax = [l for l in lines if l.get("itemType") == "TAX"] +external = [l for l in lines if l.get("itemType") == "EXTERNAL_CHARGE"] +if not external: + sys.exit("FAIL: no EXTERNAL_CHARGE lines") +if tax: + print(f"OK: plugin added {len(tax)} TAX item(s)") +else: + print("WARN: no TAX lines — check:", file=sys.stderr) + print(" - ./docker/setup-tenant.sh was run", file=sys.stderr) + print(" - KINTSUGI_URL is reachable from the Kill Bill container", file=sys.stderr) + print(" - Kintsugi connection HMAC matches KINTSUGI_HMAC_SECRET", file=sys.stderr) + print(f" - tax engine enabled + {state} registration in Kintsugi", file=sys.stderr) + sys.exit(1) +PY +rm -f "${ITEMS_FILE}" diff --git a/docker/verify-plugin.sh b/docker/verify-plugin.sh new file mode 100755 index 0000000..86676bf --- /dev/null +++ b/docker/verify-plugin.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Verify the plugin bundle is running (no Kintsugi API required). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +source "${SCRIPT_DIR}/lib.sh" + +require_cmd curl +require_killbill + +echo "==> pluginsInfo" +PLUGINS_FILE="$(mktemp)" +trap 'rm -f "${PLUGINS_FILE}" /tmp/kb-kintsugi-health.json' EXIT +curl -sf "${kb_auth[@]}" "${KILLBILL_URL}/1.0/kb/pluginsInfo" > "${PLUGINS_FILE}" +python3 - "${PLUGIN_NAME}" "${PLUGINS_FILE}" <<'PY' +import json, sys +plugin_name, path = sys.argv[1], sys.argv[2] +with open(path, encoding="utf-8") as fh: + plugins = json.load(fh) +match = [p for p in plugins if p.get("pluginName") == plugin_name] +if not match: + print(f"FAIL: {plugin_name} not in pluginsInfo", file=sys.stderr) + sys.exit(1) +p = match[0] +print("pluginState:", p.get("pluginState")) +print("version:", p.get("version")) +print("services:", ", ".join(p.get("services") or [])) +if p.get("pluginState") != "RUNNING": + sys.exit("FAIL: plugin not RUNNING") +if "InvoicePluginApi" not in (p.get("services") or []): + sys.exit("FAIL: InvoicePluginApi missing") +print("OK") +PY + +echo +echo "==> healthcheck (tenant-scoped; run setup-tenant.sh first for healthy status)" +CODE="$(curl -s -o /tmp/kb-kintsugi-health.json -w "%{http_code}" \ + "${kb_auth[@]}" "${kb_tenant[@]}" \ + "${KILLBILL_URL}/plugins/${PLUGIN_NAME}/healthcheck")" +echo "HTTP ${CODE}" +cat /tmp/kb-kintsugi-health.json | python3 -m json.tool 2>/dev/null || cat /tmp/kb-kintsugi-health.json +echo + +if [[ "${CODE}" == "200" ]]; then + echo "OK: plugin verified" +else + echo "FAIL: healthcheck returned ${CODE} — run ./docker/setup-tenant.sh" >&2 + exit 1 +fi diff --git a/docs/aviate-integration.md b/docs/aviate-integration.md new file mode 100644 index 0000000..1211b64 --- /dev/null +++ b/docs/aviate-integration.md @@ -0,0 +1,48 @@ +# Aviate integration (0.1.0) + +Kill Bill feedback on this plugin asks for enterprise parity with Aviate deployment patterns. Shipped in **0.1.0**. + +## Deployment paths (Kill Bill team guidance) + +Pierre confirmed the plugin must support **both** deployment models — not only Aviate: + +| Deployment | Tax registrations & exemptions | Product tax codes | +|------------|-------------------------------|-------------------| +| **Aviate** | Aviate Billing Accounts (`taxRegistrations[]`, address on BA) | Aviate Catalog (`products:` map) | +| **Non-Aviate** | Kill Bill account **custom fields** (`taxExempt`, `customerUsageType`) | Per invoice-item **custom fields** (`taxCode`) | + +Resolution order: + +1. **Plugin properties** (primary — Aviate plugin will pass these automatically) +2. **Custom fields** (non-Aviate fallback) +3. **Aviate billing account HTTP** (optional gap-fill when `aviateIdToken` is set and ship-to was not passed as a property) + +## Plugin property contract + +Documented in `InvoicePluginPropertyNames` for coordination with the Aviate plugin: + +| Property | Purpose | +|----------|---------| +| `customerUsageType` | Entity use code | +| `taxExempt` | `true` when tax-exempt | +| `taxRegistrationNumber` / `trn` | TRN / VAT / EIN | +| `companyName` | Customer display name | +| `shipToLine1` … `shipToPostalCode` | Ship-to address (when set, skips BA HTTP lookup) | +| `taxCode_` | Per-line product tax code | + +## Shipped in 0.1.0 + +* **Idempotency** — skip Kintsugi when every taxable line already has a linked `TAX` item. +* **Custom fields** — `customerUsageType`, `taxExempt`, per-line `taxCode`. +* **Plugin properties** — documented names in `InvoicePluginPropertyNames`; properties override custom fields. +* **Aviate billing account HTTP** — optional gap-fill via `aviateIdToken` + `killbillUrl`. + +## Planned follow-ups + +* **Aviate Catalog tax codes** — resolve plan → product → Aviate tax code from tenant config or API. +* **Repairs and return documents** — `ITEM_ADJ` / `REPAIR_ADJ` as return lines; stable document IDs. + +## References + +* [Aviate Billing Accounts](https://docs.killbill.io/latest/aviate-billing-account) +* [Aviate Tax](https://docs.killbill.io/latest/aviate-tax) diff --git a/docs/killbill-docs-pr.md b/docs/killbill-docs-pr.md new file mode 100644 index 0000000..068b6e3 --- /dev/null +++ b/docs/killbill-docs-pr.md @@ -0,0 +1,23 @@ +# Draft text for Kill Bill documentation PR + +Add to https://docs.killbill.io/latest/plugin_introduction (tax plugins section): + +## Kintsugi + +The [killbill-kintsugi-plugin](https://github.com/killbill/killbill-kintsugi-plugin) delegates sales tax calculation to [Kintsugi](https://trykintsugi.com) during invoice generation. + +Install: + +```bash +kpm install_java_plugin kintsugi --from-source-file=target/kintsugi-plugin-0.1.0.jar +``` + +Per-tenant configuration: + +```properties +org.killbill.invoice.plugin=killbill-kintsugi +``` + +Upload plugin config via `uploadPluginConfig/killbill-kintsugi` with `kintsugiUrl` and `hmacSecret`. See the plugin README for setup and verification steps. + +Healthcheck: `GET /plugins/killbill-kintsugi/healthcheck` diff --git a/docs/maven-central-handoff.md b/docs/maven-central-handoff.md new file mode 100644 index 0000000..d03f0f4 --- /dev/null +++ b/docs/maven-central-handoff.md @@ -0,0 +1,54 @@ +# Maven Central handoff (Kill Bill maintainers) + +Kintsugi has prepared this plugin for publication under the existing Kill Bill +Maven namespace. Publishing to Maven Central is owned by the Kill Bill project. + +## Coordinates + +| Field | Value | +|-------|-------| +| groupId | `org.kill-bill.billing.plugin.java` | +| artifactId | `kintsugi-plugin` | +| OSGi plugin name | `killbill-kintsugi` | + +## What Kintsugi has done + +- Apache 2.0 `LICENSE` with headers on all source files +- `killbill-oss-parent` parent POM (release profile, Central URLs, signing plugins) +- Required Central metadata in `pom.xml`: `licenses`, `developers`, `scm`, `url`, `description` +- `mvn verify` passes on Java 11 in CI +- Healthcheck servlet, unit tests, README install docs +- GitHub Release with JAR attached after first tag on this repository +- Self-contained docker E2E toolkit in `docker/` (install, tenant setup, smoke test) + +## What Kill Bill does + +1. **Transfer** the repo to the `killbill` GitHub org (update `scm` URLs in `pom.xml`) +2. **Publish** using existing Kill Bill infrastructure: + - Sonatype credentials (`MAVEN_USERNAME`, `MAVEN_PASSWORD`) + - GPG signing key (`GPG_SIGNING_KEY`, `GPG_PASSPHRASE`) + - `mvn release:clean release:prepare release:perform` (see [`.github/workflows/release.yml`](.github/workflows/release.yml) in this repo) +3. **Docs PR** — draft text in [killbill-docs-pr.md](killbill-docs-pr.md) + +## Verify before first Central release + +```bash +mvn clean verify +``` + +Expected artifacts from `sonatype-oss-release` profile (provided by parent): + +- `kintsugi-plugin-.jar` +- `kintsugi-plugin--sources.jar` +- `kintsugi-plugin--javadoc.jar` + +## KPM after Central publish + +```bash +kpm install_java_plugin kintsugi +``` + +## Kintsugi platform prerequisite + +The Kintsugi API (`/killbill/tax/estimate`, `/killbill/tax/commit`) must be reachable +from customer Kill Bill deployments. Setup docs: [trykintsugi.com/docs](https://trykintsugi.com/docs). diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..48901db --- /dev/null +++ b/pom.xml @@ -0,0 +1,225 @@ + + + + 4.0.0 + + + org.kill-bill.billing + killbill-oss-parent + 0.146.63 + + + org.kill-bill.billing.plugin.java + kintsugi-plugin + 0.1.0 + bundle + Kill Bill Kintsugi Tax Plugin + Invoice plugin delegating tax calculation to Kintsugi + https://github.com/killbill/killbill-kintsugi-plugin + + + + The Apache License, Version 2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + + Kintsugi Technologies, Inc. + Kintsugi Technologies, Inc. + https://trykintsugi.com + + + + + scm:git:git://github.com/killbill/killbill-kintsugi-plugin.git + scm:git:git@github.com:killbill/killbill-kintsugi-plugin.git + v0.1.0 + https://github.com/killbill/killbill-kintsugi-plugin/tree/main + + + + Github + https://github.com/killbill/killbill-kintsugi-plugin/issues + + + + true + true + true + true + true + true + true + false + spotbugs-exclude.xml + false + com.trykintsugi.killbill.* + + + + + com.fasterxml.jackson.core + jackson-core + 2.18.6 + + + com.fasterxml.jackson.core + jackson-databind + 2.18.6 + + + com.github.tomakehurst + wiremock-standalone + 2.27.2 + test + + + com.google.inject + guice + + + jakarta.servlet + jakarta.servlet-api + provided + + + javax.inject + javax.inject + + + joda-time + joda-time + provided + + + org.apache.felix + org.apache.felix.framework + provided + + + org.jooby + jooby + + + org.kill-bill.billing + killbill-api + provided + + + org.kill-bill.billing + killbill-platform-osgi-api + provided + + + org.kill-bill.billing + killbill-platform-osgi-bundles-lib-killbill + provided + + + org.kill-bill.billing + killbill-platform-test + test + + + org.kill-bill.billing.plugin + killbill-plugin-api-invoice + + + org.kill-bill.billing.plugin + killbill-plugin-api-notification + provided + + + org.kill-bill.billing.plugin.java + killbill-base-plugin + + + org.kill-bill.billing.plugin.java + killbill-base-plugin + test-jar + test + + + org.kill-bill.commons + killbill-clock + provided + + + org.kill-bill.commons + killbill-clock + test-jar + test + + + org.mockito + mockito-core + test + + + org.slf4j + slf4j-api + provided + + + org.slf4j + slf4j-simple + test + + + org.testng + testng + test + + + + + + + org.apache.felix + maven-bundle-plugin + true + + + com.trykintsugi.killbill.KintsugiActivator + com.trykintsugi.killbill + com.trykintsugi.killbill.internal + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + fast + + + + org.apache.rat + apache-rat-plugin + + + docker/** + + + + + + diff --git a/spotbugs-exclude.xml b/spotbugs-exclude.xml new file mode 100644 index 0000000..b5d30e0 --- /dev/null +++ b/spotbugs-exclude.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/main/java/com/trykintsugi/killbill/KintsugiActivator.java b/src/main/java/com/trykintsugi/killbill/KintsugiActivator.java new file mode 100644 index 0000000..52033f1 --- /dev/null +++ b/src/main/java/com/trykintsugi/killbill/KintsugiActivator.java @@ -0,0 +1,85 @@ +/* + * Copyright 2026 Kintsugi Technologies, Inc. + * + * 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. + */ + + +package com.trykintsugi.killbill; + +import org.killbill.billing.invoice.plugin.api.InvoicePluginApi; +import org.killbill.billing.osgi.api.Healthcheck; +import org.killbill.billing.osgi.api.OSGIPluginProperties; +import org.killbill.billing.osgi.libs.killbill.KillbillActivatorBase; +import org.killbill.billing.plugin.api.notification.PluginConfigurationEventHandler; +import org.killbill.billing.plugin.core.resources.jooby.PluginApp; +import org.killbill.billing.plugin.core.resources.jooby.PluginAppBuilder; +import org.osgi.framework.BundleContext; + +import javax.servlet.Servlet; +import javax.servlet.http.HttpServlet; +import java.util.Hashtable; + +/** OSGi bundle activator — registers the Kintsugi invoice plugin. */ +public final class KintsugiActivator extends KillbillActivatorBase { + + public static final String PLUGIN_NAME = "killbill-kintsugi"; + + private KintsugiConfigurationHandler configurationHandler; + + @Override + public void start(final BundleContext context) throws Exception { + super.start(context); + + configurationHandler = new KintsugiConfigurationHandler(PLUGIN_NAME, killbillAPI); + configurationHandler.setDefaultConfigurable( + configurationHandler.createConfigurable(new java.util.Properties())); + + final KintsugiHealthcheck healthcheck = new KintsugiHealthcheck(configurationHandler); + registerHealthcheck(context, healthcheck); + + final InvoicePluginApi invoicePluginApi = new KintsugiInvoicePluginApi( + killbillAPI, configProperties, null, configurationHandler); + registerInvoicePluginApi(context, invoicePluginApi); + + final PluginApp pluginApp = new PluginAppBuilder( + PLUGIN_NAME, killbillAPI, dataSource, super.clock, configProperties) + .withRouteClass(KintsugiHealthcheckServlet.class) + .withService(healthcheck) + .build(); + final HttpServlet httpServlet = PluginApp.createServlet(pluginApp); + registerServlet(context, httpServlet); + + final PluginConfigurationEventHandler configHandler = + new PluginConfigurationEventHandler(configurationHandler); + dispatcher.registerEventHandlers(configHandler); + } + + private void registerInvoicePluginApi(final BundleContext context, final InvoicePluginApi api) { + final Hashtable props = new Hashtable<>(); + props.put(OSGIPluginProperties.PLUGIN_NAME_PROP, PLUGIN_NAME); + registrar.registerService(context, InvoicePluginApi.class, api, props); + } + + private void registerHealthcheck(final BundleContext context, final Healthcheck healthcheck) { + final Hashtable props = new Hashtable<>(); + props.put(OSGIPluginProperties.PLUGIN_NAME_PROP, PLUGIN_NAME); + registrar.registerService(context, Healthcheck.class, healthcheck, props); + } + + private void registerServlet(final BundleContext context, final Servlet servlet) { + final Hashtable props = new Hashtable<>(); + props.put(OSGIPluginProperties.PLUGIN_NAME_PROP, PLUGIN_NAME); + registrar.registerService(context, Servlet.class, servlet, props); + } +} diff --git a/src/main/java/com/trykintsugi/killbill/KintsugiAdditionalItemsResult.java b/src/main/java/com/trykintsugi/killbill/KintsugiAdditionalItemsResult.java new file mode 100644 index 0000000..129b0c6 --- /dev/null +++ b/src/main/java/com/trykintsugi/killbill/KintsugiAdditionalItemsResult.java @@ -0,0 +1,58 @@ +/* + * Copyright 2026 Kintsugi Technologies, Inc. + * + * 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. + */ + +package com.trykintsugi.killbill; + +import org.killbill.billing.invoice.api.InvoiceItem; +import org.killbill.billing.invoice.plugin.api.AdditionalItemsResult; +import org.killbill.billing.payment.api.PluginProperty; + +import java.util.Collections; +import java.util.List; + +/** {@link AdditionalItemsResult} for Kintsugi tax lines. */ +public final class KintsugiAdditionalItemsResult implements AdditionalItemsResult { + + private final List additionalItems; + private final Iterable adjustedPluginProperties; + + public KintsugiAdditionalItemsResult(final List additionalItems) { + this(additionalItems, null); + } + + private KintsugiAdditionalItemsResult( + final List additionalItems, + final Iterable adjustedPluginProperties) { + this.additionalItems = additionalItems != null + ? List.copyOf(additionalItems) + : Collections.emptyList(); + this.adjustedPluginProperties = adjustedPluginProperties; + } + + public static KintsugiAdditionalItemsResult empty() { + return new KintsugiAdditionalItemsResult(Collections.emptyList()); + } + + @Override + public List getAdditionalItems() { + return additionalItems; + } + + @Override + public Iterable getAdjustedPluginProperties() { + return adjustedPluginProperties; + } +} diff --git a/src/main/java/com/trykintsugi/killbill/KintsugiConfigurationHandler.java b/src/main/java/com/trykintsugi/killbill/KintsugiConfigurationHandler.java new file mode 100644 index 0000000..14a1d22 --- /dev/null +++ b/src/main/java/com/trykintsugi/killbill/KintsugiConfigurationHandler.java @@ -0,0 +1,73 @@ +/* + * Copyright 2026 Kintsugi Technologies, Inc. + * + * 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. + */ + + +package com.trykintsugi.killbill; + +import org.killbill.billing.osgi.libs.killbill.OSGIKillbillAPI; +import org.killbill.billing.plugin.api.notification.PluginTenantConfigurableConfigurationHandler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Properties; + +/** + * Per-tenant plugin config uploaded via {@code uploadPluginConfig/killbill-kintsugi}. + * + *

Supports YAML POJO: + *

+ * !!com.trykintsugi.killbill.KintsugiTenantConfig
+ * kintsugiUrl: https://api.trykintsugi.com
+ * hmacSecret: <shared-hmac-secret>
+ * killbillUrl: http://127.0.0.1:8080
+ * aviateIdToken: <aviate-jwt>
+ * 
+ * + *

Or key=value properties: + *

+ * kintsugiUrl=https://api.trykintsugi.com
+ * hmacSecret=<shared-hmac-secret>
+ * 
+ */ +public final class KintsugiConfigurationHandler + extends PluginTenantConfigurableConfigurationHandler { + + private static final Logger LOGGER = LoggerFactory.getLogger(KintsugiConfigurationHandler.class); + + public KintsugiConfigurationHandler(final String pluginName, final OSGIKillbillAPI osgiKillbillAPI) { + super(pluginName, osgiKillbillAPI); + } + + @Override + protected KintsugiTenantConfig createConfigurable(final Properties properties) { + final KintsugiTenantConfig config = new KintsugiTenantConfig(); + config.setKintsugiUrl(properties.getProperty("kintsugiUrl", "").trim()); + config.setHmacSecret(properties.getProperty("hmacSecret", "").trim()); + config.setKillbillUrl(properties.getProperty("killbillUrl", "").trim()); + config.setAviateIdToken(properties.getProperty("aviateIdToken", "").trim()); + if (config.hasAviateIntegration() && properties.getProperty("killbillUrl", "").isBlank()) { + LOGGER.warn( + "aviateIdToken is configured but killbillUrl is not set; " + + "defaulting to {} for billing-account lookup", + config.getKillbillUrl()); + } + LOGGER.info( + "Loaded Kintsugi tenant config (url={}, aviate={})", + config.getKintsugiUrl(), + config.hasAviateIntegration()); + return config; + } +} diff --git a/src/main/java/com/trykintsugi/killbill/KintsugiHealthcheck.java b/src/main/java/com/trykintsugi/killbill/KintsugiHealthcheck.java new file mode 100644 index 0000000..cce49a6 --- /dev/null +++ b/src/main/java/com/trykintsugi/killbill/KintsugiHealthcheck.java @@ -0,0 +1,56 @@ +/* + * Copyright 2026 Kintsugi Technologies, Inc. + * + * 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. + */ + +package com.trykintsugi.killbill; + +import org.killbill.billing.osgi.api.Healthcheck; +import org.killbill.billing.tenant.api.Tenant; + +import javax.annotation.Nullable; +import java.util.Map; +import java.util.UUID; + +/** Reports whether the plugin bundle is loaded and tenant config is present. */ +public final class KintsugiHealthcheck implements Healthcheck { + + private final KintsugiTenantConfigSource configSource; + + public KintsugiHealthcheck(final KintsugiConfigurationHandler configurationHandler) { + this(configurationHandler::getConfigurable); + } + + KintsugiHealthcheck(final KintsugiTenantConfigSource configSource) { + this.configSource = configSource; + } + + @Override + public HealthStatus getHealthStatus(@Nullable final Tenant tenant, @Nullable final Map properties) { + if (tenant == null) { + return HealthStatus.healthy("Kintsugi plugin loaded"); + } + + final KintsugiTenantConfig config = configSource.getConfigurable(tenant.getId()); + if (config == null || isBlank(config.getKintsugiUrl()) || isBlank(config.getHmacSecret())) { + return HealthStatus.unHealthy("Kintsugi plugin is not configured for tenant"); + } + + return HealthStatus.healthy("Kintsugi plugin configured"); + } + + private static boolean isBlank(final String value) { + return value == null || value.isBlank(); + } +} diff --git a/src/main/java/com/trykintsugi/killbill/KintsugiHealthcheckServlet.java b/src/main/java/com/trykintsugi/killbill/KintsugiHealthcheckServlet.java new file mode 100644 index 0000000..0025164 --- /dev/null +++ b/src/main/java/com/trykintsugi/killbill/KintsugiHealthcheckServlet.java @@ -0,0 +1,48 @@ +/* + * Copyright 2026 Kintsugi Technologies, Inc. + * + * 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. + */ + +package com.trykintsugi.killbill; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.google.inject.Inject; +import org.jooby.Result; +import org.jooby.mvc.GET; +import org.jooby.mvc.Local; +import org.jooby.mvc.Path; +import org.killbill.billing.plugin.core.resources.PluginHealthcheck; +import org.killbill.billing.tenant.api.Tenant; + +import javax.inject.Named; +import javax.inject.Singleton; +import java.util.Optional; + +@Singleton +@Path("/healthcheck") +public final class KintsugiHealthcheckServlet extends PluginHealthcheck { + + private final KintsugiHealthcheck healthcheck; + + @Inject + public KintsugiHealthcheckServlet(final KintsugiHealthcheck healthcheck) { + this.healthcheck = healthcheck; + } + + @GET + public Result check(@Local @Named("killbill_tenant") final Optional tenant) + throws JsonProcessingException { + return check(healthcheck, tenant.orElse(null), null); + } +} diff --git a/src/main/java/com/trykintsugi/killbill/KintsugiInvoicePluginApi.java b/src/main/java/com/trykintsugi/killbill/KintsugiInvoicePluginApi.java new file mode 100644 index 0000000..323acec --- /dev/null +++ b/src/main/java/com/trykintsugi/killbill/KintsugiInvoicePluginApi.java @@ -0,0 +1,141 @@ +/* + * Copyright 2026 Kintsugi Technologies, Inc. + * + * 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. + */ + +package com.trykintsugi.killbill; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.trykintsugi.killbill.internal.AccountTaxMetadata; +import com.trykintsugi.killbill.internal.InvoiceRequestMapper; +import com.trykintsugi.killbill.internal.InvoiceTaxIdempotency; +import com.trykintsugi.killbill.internal.KintsugiTaxClient; +import com.trykintsugi.killbill.internal.TaxItemMapper; +import com.trykintsugi.killbill.internal.TaxMetadataResolver; +import org.joda.time.Period; +import org.killbill.billing.account.api.Account; +import org.killbill.billing.invoice.api.Invoice; +import org.killbill.billing.invoice.api.InvoiceItem; +import org.killbill.billing.invoice.plugin.api.InvoiceContext; +import org.killbill.billing.invoice.plugin.api.InvoicePluginApiRetryException; +import org.killbill.billing.osgi.libs.killbill.OSGIConfigPropertiesService; +import org.killbill.billing.osgi.libs.killbill.OSGIKillbillAPI; +import org.killbill.billing.payment.api.PluginProperty; +import org.killbill.billing.plugin.api.invoice.PluginInvoicePluginApi; +import org.killbill.billing.tenant.api.Tenant; +import org.killbill.clock.Clock; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** Invoice plugin that delegates tax calculation to the Kintsugi tax API. */ +public final class KintsugiInvoicePluginApi extends PluginInvoicePluginApi { + + private static final Logger LOGGER = LoggerFactory.getLogger(KintsugiInvoicePluginApi.class); + private static final List RETRY_SCHEDULE = List.of( + Period.minutes(1), + Period.minutes(5), + Period.minutes(15)); + + private final KintsugiConfigurationHandler configurationHandler; + private final TaxMetadataResolver taxMetadataResolver; + + public KintsugiInvoicePluginApi( + final OSGIKillbillAPI killbillAPI, + final OSGIConfigPropertiesService configProperties, + final Clock clock, + final KintsugiConfigurationHandler configurationHandler) { + super(killbillAPI, configProperties, clock); + this.configurationHandler = configurationHandler; + this.taxMetadataResolver = new TaxMetadataResolver(killbillAPI); + } + + @Override + public KintsugiAdditionalItemsResult getAdditionalInvoiceItems( + final Invoice invoice, + final boolean dryRun, + final Iterable properties, + final InvoiceContext invoiceContext) { + final KintsugiTenantConfig config = configurationHandler.getConfigurable(invoiceContext.getTenantId()); + if (config == null || isBlank(config.getKintsugiUrl()) || isBlank(config.getHmacSecret())) { + LOGGER.warn("Kintsugi plugin not configured for tenant {}", invoiceContext.getTenantId()); + return emptyResult(); + } + + if (invoice.getInvoiceItems() == null || invoice.getInvoiceItems().isEmpty()) { + return emptyResult(); + } + + if (InvoiceTaxIdempotency.allTaxableItemsAlreadyTaxed(invoice)) { + LOGGER.debug( + "Skipping Kintsugi tax for invoice account {} — taxable lines already have TAX items", + invoice.getAccountId()); + return emptyResult(); + } + + try { + final Account account = getAccount(invoice.getAccountId(), invoiceContext); + final Tenant tenant = killbillAPI.getTenantUserApi().getTenantById(invoiceContext.getTenantId()); + final String tenantApiKey = tenant.getApiKey(); + final AccountTaxMetadata taxMetadata = taxMetadataResolver.resolve( + invoice, + account, + properties, + invoiceContext, + config, + tenantApiKey, + tenant.getApiSecret()); + final ObjectNode requestBody = InvoiceRequestMapper.toEstimateRequest( + invoice, + account, + dryRun, + invoiceContext.getTenantId() != null ? invoiceContext.getTenantId().toString() : null, + taxMetadata); + + final KintsugiTaxClient client = new KintsugiTaxClient( + config.getKintsugiUrl(), + config.getHmacSecret(), + tenantApiKey); + + final List taxLines = + client.estimate(requestBody, !dryRun); + + final Map taxableById = TaxItemMapper.indexTaxableItems(invoice); + final List taxItems = TaxItemMapper.toTaxItems(invoice, taxLines, taxableById); + + LOGGER.info( + "Kintsugi returned {} tax line(s) for invoice account {}", + taxItems.size(), + invoice.getAccountId()); + + return new KintsugiAdditionalItemsResult(taxItems); + } catch (InvoicePluginApiRetryException e) { + throw e; + } catch (Exception e) { + LOGGER.warn("Kintsugi tax estimate failed for tenant {}: {}", invoiceContext.getTenantId(), e.getMessage()); + throw new InvoicePluginApiRetryException(e, RETRY_SCHEDULE); + } + } + + private static KintsugiAdditionalItemsResult emptyResult() { + return KintsugiAdditionalItemsResult.empty(); + } + + private static boolean isBlank(final String value) { + return value == null || value.isBlank(); + } +} diff --git a/src/main/java/com/trykintsugi/killbill/KintsugiTenantConfig.java b/src/main/java/com/trykintsugi/killbill/KintsugiTenantConfig.java new file mode 100644 index 0000000..9848d44 --- /dev/null +++ b/src/main/java/com/trykintsugi/killbill/KintsugiTenantConfig.java @@ -0,0 +1,66 @@ +/* + * Copyright 2026 Kintsugi Technologies, Inc. + * + * 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. + */ + + +package com.trykintsugi.killbill; + +/** Per-tenant Kill Bill plugin configuration (YAML upload). */ +public final class KintsugiTenantConfig { + + private static final String DEFAULT_KILLBILL_URL = "http://127.0.0.1:8080"; + + private String kintsugiUrl; + private String hmacSecret; + private String killbillUrl = DEFAULT_KILLBILL_URL; + /** Aviate JWT; when blank, Aviate billing-account lookup is skipped. */ + private String aviateIdToken; + + public String getKintsugiUrl() { + return kintsugiUrl; + } + + public void setKintsugiUrl(final String kintsugiUrl) { + this.kintsugiUrl = kintsugiUrl; + } + + public String getHmacSecret() { + return hmacSecret; + } + + public void setHmacSecret(final String hmacSecret) { + this.hmacSecret = hmacSecret; + } + + public String getKillbillUrl() { + return killbillUrl != null && !killbillUrl.isBlank() ? killbillUrl.trim() : DEFAULT_KILLBILL_URL; + } + + public void setKillbillUrl(final String killbillUrl) { + this.killbillUrl = killbillUrl; + } + + public String getAviateIdToken() { + return aviateIdToken; + } + + public void setAviateIdToken(final String aviateIdToken) { + this.aviateIdToken = aviateIdToken; + } + + public boolean hasAviateIntegration() { + return aviateIdToken != null && !aviateIdToken.isBlank(); + } +} diff --git a/src/main/java/com/trykintsugi/killbill/KintsugiTenantConfigSource.java b/src/main/java/com/trykintsugi/killbill/KintsugiTenantConfigSource.java new file mode 100644 index 0000000..a620566 --- /dev/null +++ b/src/main/java/com/trykintsugi/killbill/KintsugiTenantConfigSource.java @@ -0,0 +1,26 @@ +/* + * Copyright 2026 Kintsugi Technologies, Inc. + * + * 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. + */ + +package com.trykintsugi.killbill; + +import java.util.UUID; + +/** Resolves per-tenant plugin configuration for health checks. */ +@FunctionalInterface +interface KintsugiTenantConfigSource { + + KintsugiTenantConfig getConfigurable(UUID tenantId); +} diff --git a/src/main/java/com/trykintsugi/killbill/internal/AccountTaxMetadata.java b/src/main/java/com/trykintsugi/killbill/internal/AccountTaxMetadata.java new file mode 100644 index 0000000..7ffa357 --- /dev/null +++ b/src/main/java/com/trykintsugi/killbill/internal/AccountTaxMetadata.java @@ -0,0 +1,176 @@ +/* + * Copyright 2026 Kintsugi Technologies, Inc. + * + * 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. + */ + +package com.trykintsugi.killbill.internal; + +import java.util.Collections; +import java.util.Map; +import java.util.UUID; + +/** Tax metadata merged from Aviate billing account and/or Kill Bill custom fields. */ +public final class AccountTaxMetadata { + + public enum Source { + /** Custom fields and KB account only (non-Aviate deployment). */ + CUSTOM_FIELDS, + /** Aviate billing account (with optional custom-field overlays). */ + AVIATE_BILLING_ACCOUNT + } + + private final Source source; + private final boolean taxExempt; + private final String customerUsageType; + private final String taxRegistrationNumber; + private final String companyName; + private final String contactEmail; + private final TaxAddress shipToAddress; + private final Map taxCodeByInvoiceItemId; + + private AccountTaxMetadata( + final Source source, + final boolean taxExempt, + final String customerUsageType, + final String taxRegistrationNumber, + final String companyName, + final String contactEmail, + final TaxAddress shipToAddress, + final Map taxCodeByInvoiceItemId) { + this.source = source; + this.taxExempt = taxExempt; + this.customerUsageType = customerUsageType; + this.taxRegistrationNumber = taxRegistrationNumber; + this.companyName = companyName; + this.contactEmail = contactEmail; + this.shipToAddress = shipToAddress; + this.taxCodeByInvoiceItemId = taxCodeByInvoiceItemId != null + ? Map.copyOf(taxCodeByInvoiceItemId) + : Collections.emptyMap(); + } + + public static AccountTaxMetadata empty() { + return new AccountTaxMetadata( + Source.CUSTOM_FIELDS, + false, + null, + null, + null, + null, + null, + Collections.emptyMap()); + } + + public static Builder builder() { + return new Builder(); + } + + public Source getSource() { + return source; + } + + public boolean isTaxExempt() { + return taxExempt; + } + + public String getCustomerUsageType() { + return customerUsageType; + } + + public String getTaxRegistrationNumber() { + return taxRegistrationNumber; + } + + public String getCompanyName() { + return companyName; + } + + public String getContactEmail() { + return contactEmail; + } + + public TaxAddress getShipToAddress() { + return shipToAddress; + } + + public String taxCodeForItem(final UUID invoiceItemId) { + if (invoiceItemId == null) { + return null; + } + return taxCodeByInvoiceItemId.get(invoiceItemId); + } + + public static final class Builder { + private Source source = Source.CUSTOM_FIELDS; + private boolean taxExempt; + private String customerUsageType; + private String taxRegistrationNumber; + private String companyName; + private String contactEmail; + private TaxAddress shipToAddress; + private Map taxCodeByInvoiceItemId = Collections.emptyMap(); + + public Builder source(final Source value) { + this.source = value; + return this; + } + + public Builder taxExempt(final boolean value) { + this.taxExempt = value; + return this; + } + + public Builder customerUsageType(final String value) { + this.customerUsageType = value; + return this; + } + + public Builder taxRegistrationNumber(final String value) { + this.taxRegistrationNumber = value; + return this; + } + + public Builder companyName(final String value) { + this.companyName = value; + return this; + } + + public Builder contactEmail(final String value) { + this.contactEmail = value; + return this; + } + + public Builder shipToAddress(final TaxAddress value) { + this.shipToAddress = value; + return this; + } + + public Builder taxCodeByInvoiceItemId(final Map value) { + this.taxCodeByInvoiceItemId = value != null ? Map.copyOf(value) : Collections.emptyMap(); + return this; + } + + public AccountTaxMetadata build() { + return new AccountTaxMetadata( + source, + taxExempt, + customerUsageType, + taxRegistrationNumber, + companyName, + contactEmail, + shipToAddress, + taxCodeByInvoiceItemId); + } + } +} diff --git a/src/main/java/com/trykintsugi/killbill/internal/AviateBillingAccount.java b/src/main/java/com/trykintsugi/killbill/internal/AviateBillingAccount.java new file mode 100644 index 0000000..e80b443 --- /dev/null +++ b/src/main/java/com/trykintsugi/killbill/internal/AviateBillingAccount.java @@ -0,0 +1,162 @@ +/* + * Copyright 2026 Kintsugi Technologies, Inc. + * + * 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. + */ + +package com.trykintsugi.killbill.internal; + +import com.fasterxml.jackson.databind.JsonNode; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** Parsed Aviate billing account used for tax metadata (not Aviate's internal rate engine). */ +public final class AviateBillingAccount { + + private final String companyName; + private final String contactName; + private final String email; + private final TaxAddress companyAddress; + private final List taxRegistrations; + + private AviateBillingAccount( + final String companyName, + final String contactName, + final String email, + final TaxAddress companyAddress, + final List taxRegistrations) { + this.companyName = companyName; + this.contactName = contactName; + this.email = email; + this.companyAddress = companyAddress; + this.taxRegistrations = List.copyOf(taxRegistrations); + } + + public static AviateBillingAccount fromJson(final JsonNode root) { + if (root == null || root.isMissingNode()) { + return null; + } + final List registrations = new ArrayList<>(); + final JsonNode registrationNodes = root.path("taxRegistrations"); + if (registrationNodes.isArray()) { + for (final JsonNode node : registrationNodes) { + registrations.add(TaxRegistration.fromJson(node)); + } + } + return new AviateBillingAccount( + textOrNull(root, "companyName"), + textOrNull(root, "contactName"), + textOrNull(root, "email"), + TaxAddressParser.fromAviateAddress(root.path("address")), + registrations); + } + + public boolean isTaxExempt() { + for (final TaxRegistration registration : taxRegistrations) { + if (registration.exempt()) { + return true; + } + } + return false; + } + + public TaxAddress resolveShipToAddress(final String preferredCountry) { + final Optional registration = registrationForCountry(preferredCountry); + if (registration.isPresent()) { + final TaxAddress registrationAddress = registration.get().address(); + if (registrationAddress != null && registrationAddress.hasData()) { + return registrationAddress; + } + } + if (companyAddress != null && companyAddress.hasData()) { + return companyAddress; + } + return null; + } + + public String resolveTaxRegistrationNumber(final String preferredCountry) { + return registrationForCountry(preferredCountry) + .map(TaxRegistration::trn) + .orElse(null); + } + + public String getCompanyName() { + return companyName; + } + + public String getContactName() { + return contactName; + } + + public String getEmail() { + return email; + } + + private Optional registrationForCountry(final String preferredCountry) { + if (taxRegistrations.isEmpty()) { + return Optional.empty(); + } + if (preferredCountry != null && !preferredCountry.isBlank()) { + for (final TaxRegistration registration : taxRegistrations) { + final TaxAddress address = registration.address(); + if (address != null + && preferredCountry.equalsIgnoreCase(address.getCountry())) { + return Optional.of(registration); + } + } + } + return Optional.of(taxRegistrations.get(0)); + } + + private static String textOrNull(final JsonNode node, final String field) { + final JsonNode value = node.path(field); + if (value.isMissingNode() || value.isNull()) { + return null; + } + final String text = value.asText(null); + return text != null && !text.isBlank() ? text.trim() : null; + } + + static final class TaxRegistration { + private final boolean exempt; + private final String trn; + private final TaxAddress address; + + private TaxRegistration(final boolean exempt, final String trn, final TaxAddress address) { + this.exempt = exempt; + this.trn = trn; + this.address = address; + } + + static TaxRegistration fromJson(final JsonNode node) { + return new TaxRegistration( + node.path("exempt").asBoolean(false), + textOrNull(node, "trn"), + TaxAddressParser.fromAviateAddress(node.path("address"))); + } + + boolean exempt() { + return exempt; + } + + String trn() { + return trn; + } + + TaxAddress address() { + return address; + } + } +} diff --git a/src/main/java/com/trykintsugi/killbill/internal/AviateBillingAccountClient.java b/src/main/java/com/trykintsugi/killbill/internal/AviateBillingAccountClient.java new file mode 100644 index 0000000..deae5a4 --- /dev/null +++ b/src/main/java/com/trykintsugi/killbill/internal/AviateBillingAccountClient.java @@ -0,0 +1,121 @@ +/* + * Copyright 2026 Kintsugi Technologies, Inc. + * + * 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. + */ + +package com.trykintsugi.killbill.internal; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Optional; +import java.util.UUID; + +/** + * Optional HTTP client for Aviate billing accounts. + * Absent Aviate plugin, 404, or transport errors return empty — callers fall back to custom fields. + */ +public final class AviateBillingAccountClient { + + private static final Logger LOGGER = LoggerFactory.getLogger(AviateBillingAccountClient.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String API_KEY_HEADER = "X-Killbill-ApiKey"; + private static final String API_SECRET_HEADER = "X-Killbill-ApiSecret"; + + private final HttpClient httpClient; + + public AviateBillingAccountClient() { + this(HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(2)) + .version(HttpClient.Version.HTTP_1_1) + .build()); + } + + AviateBillingAccountClient(final HttpClient httpClient) { + this.httpClient = httpClient; + } + + public Optional fetchForKbAccountId( + final String killbillBaseUrl, + final UUID kbAccountId, + final String tenantApiKey, + final String tenantApiSecret, + final String aviateIdToken) { + if (isBlank(killbillBaseUrl) + || kbAccountId == null + || isBlank(tenantApiKey) + || isBlank(tenantApiSecret) + || isBlank(aviateIdToken)) { + return Optional.empty(); + } + + final String baseUrl = killbillBaseUrl.endsWith("/") + ? killbillBaseUrl.substring(0, killbillBaseUrl.length() - 1) + : killbillBaseUrl; + final String path = "/plugins/aviate-plugin/v1/ba/forKbAccountId/" + kbAccountId; + + try { + final HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(baseUrl + path)) + .timeout(Duration.ofSeconds(3)) + .header("Authorization", "Bearer " + aviateIdToken.trim()) + .header(API_KEY_HEADER, tenantApiKey) + .header(API_SECRET_HEADER, tenantApiSecret) + .GET() + .build(); + + final HttpResponse response = + httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() == 404) { + LOGGER.debug("No Aviate billing account for KB account {}", kbAccountId); + return Optional.empty(); + } + if (response.statusCode() < 200 || response.statusCode() >= 300) { + LOGGER.warn( + "Aviate billing account lookup returned HTTP {} for KB account {}", + response.statusCode(), + kbAccountId); + return Optional.empty(); + } + + final AviateBillingAccount billingAccount = + AviateBillingAccount.fromJson(MAPPER.readTree(response.body())); + if (billingAccount == null) { + return Optional.empty(); + } + return Optional.of(billingAccount); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + LOGGER.debug("Aviate billing account lookup interrupted for KB account {}", kbAccountId); + return Optional.empty(); + } catch (Exception e) { + LOGGER.debug( + "Aviate billing account lookup failed for KB account {}: {}", + kbAccountId, + e.getMessage()); + return Optional.empty(); + } + } + + private static boolean isBlank(final String value) { + return value == null || value.isBlank(); + } +} diff --git a/src/main/java/com/trykintsugi/killbill/internal/InvoicePluginPropertyNames.java b/src/main/java/com/trykintsugi/killbill/internal/InvoicePluginPropertyNames.java new file mode 100644 index 0000000..ecba232 --- /dev/null +++ b/src/main/java/com/trykintsugi/killbill/internal/InvoicePluginPropertyNames.java @@ -0,0 +1,48 @@ +/* + * Copyright 2026 Kintsugi Technologies, Inc. + * + * 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. + */ + +package com.trykintsugi.killbill.internal; + +/** + * Plugin property names for invoice tax metadata. + * + *

The Aviate plugin is expected to pass these on invoice generation; custom fields remain the + * fallback for non-Aviate deployments. + */ +public final class InvoicePluginPropertyNames { + + /** Entity use code for the customer. */ + public static final String CUSTOMER_USAGE_TYPE = "customerUsageType"; + /** When {@code true}, customer is tax-exempt. */ + public static final String TAX_EXEMPT = "taxExempt"; + /** Tax registration number (TRN / VAT / EIN). */ + public static final String TAX_REGISTRATION_NUMBER = "taxRegistrationNumber"; + /** Alias for {@link #TAX_REGISTRATION_NUMBER}. */ + public static final String TRN = "trn"; + /** Company or customer display name. */ + public static final String COMPANY_NAME = "companyName"; + /** Per-line tax code prefix; full key is {@code taxCode_}. */ + public static final String TAX_CODE_PREFIX = "taxCode_"; + /** Ship-to address components. */ + public static final String SHIP_TO_LINE1 = "shipToLine1"; + public static final String SHIP_TO_LINE2 = "shipToLine2"; + public static final String SHIP_TO_CITY = "shipToCity"; + public static final String SHIP_TO_STATE = "shipToState"; + public static final String SHIP_TO_COUNTRY = "shipToCountry"; + public static final String SHIP_TO_POSTAL_CODE = "shipToPostalCode"; + + private InvoicePluginPropertyNames() {} +} diff --git a/src/main/java/com/trykintsugi/killbill/internal/InvoiceRequestMapper.java b/src/main/java/com/trykintsugi/killbill/internal/InvoiceRequestMapper.java new file mode 100644 index 0000000..ac758c5 --- /dev/null +++ b/src/main/java/com/trykintsugi/killbill/internal/InvoiceRequestMapper.java @@ -0,0 +1,235 @@ +/* + * Copyright 2026 Kintsugi Technologies, Inc. + * + * 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. + */ + +package com.trykintsugi.killbill.internal; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.joda.time.LocalDate; +import org.killbill.billing.account.api.Account; +import org.killbill.billing.invoice.api.Invoice; +import org.killbill.billing.invoice.api.InvoiceItem; +import org.killbill.billing.invoice.api.InvoiceItemType; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** Maps Kill Bill invoice state to a Kintsugi tax estimate request JSON payload. */ +public final class InvoiceRequestMapper { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** Default product labels for external charges without a plan name. */ + static final String EXTERNAL_CHARGE_CATEGORY = "Physical"; + static final String EXTERNAL_CHARGE_SUBCATEGORY = "General Physical"; + + private InvoiceRequestMapper() {} + + public static ObjectNode toEstimateRequest( + final Invoice invoice, + final Account account, + final boolean dryRun, + final String tenantId) { + return toEstimateRequest(invoice, account, dryRun, tenantId, AccountTaxMetadata.empty()); + } + + public static ObjectNode toEstimateRequest( + final Invoice invoice, + final Account account, + final boolean dryRun, + final String tenantId, + final AccountTaxMetadata taxMetadata) { + final AccountTaxMetadata metadata = taxMetadata != null ? taxMetadata : AccountTaxMetadata.empty(); + final ArrayNode lineItems = MAPPER.createArrayNode(); + + for (final InvoiceItem item : invoice.getInvoiceItems()) { + if (shouldSkipItem(item)) { + continue; + } + final String externalId = item.getId() != null + ? item.getId().toString() + : UUID.randomUUID().toString(); + + final ObjectNode line = MAPPER.createObjectNode(); + line.put("external_id", externalId); + line.put("amount", formatAmount(item.getAmount())); + line.put("quantity", formatQuantity(item.getQuantity())); + line.put("kind", "item"); + if (item.getDescription() != null) { + line.put("description", item.getDescription()); + } + if (item.getId() != null) { + line.put("invoice_item_id", item.getId().toString()); + } + if (item.getInvoiceItemType() != null) { + line.put("item_type", item.getInvoiceItemType().name()); + } + if (item.getPlanName() != null) { + line.put("plan_name", item.getPlanName()); + line.put("external_product_id", item.getPlanName()); + } + if (item.getPrettyProductName() != null) { + line.put("product_name", item.getPrettyProductName()); + } else if (item.getInvoiceItemType() == InvoiceItemType.EXTERNAL_CHARGE) { + line.put("product_category", EXTERNAL_CHARGE_CATEGORY); + line.put("product_subcategory", EXTERNAL_CHARGE_SUBCATEGORY); + } + final String taxCode = metadata.taxCodeForItem(item.getId()); + if (taxCode != null) { + line.put("tax_code", taxCode); + } + lineItems.add(line); + } + + final ObjectNode shipTo = resolveAddress(account, metadata); + final ObjectNode billTo = accountToAddress(account); + final ObjectNode customer = accountToCustomer(account, metadata); + final String transactionDate = formatInvoiceDate(invoice); + + final ObjectNode root = KintsugiTaxClient.buildEstimateRequest( + UUID.randomUUID().toString(), + invoice.getCurrency().toString(), + invoice.getId() != null ? invoice.getId().toString() : UUID.randomUUID().toString(), + invoice.getAccountId().toString(), + dryRun, + lineItems, + shipTo, + billTo, + customer, + transactionDate); + + if (tenantId != null && !tenantId.isBlank()) { + root.put("tenant_id", tenantId); + } + root.put("plugin_name", "killbill-kintsugi"); + + if (invoice.getInvoiceNumber() != null) { + final ObjectNode document = (ObjectNode) root.path("documents").get(0); + document.put("invoice_number", invoice.getInvoiceNumber()); + } + + return root; + } + + /** Returns external_id keyed by Kill Bill invoice item id for response mapping. */ + public static Map externalIdsForInvoice(final Invoice invoice) { + final Map externalIds = new HashMap<>(); + for (final InvoiceItem item : invoice.getInvoiceItems()) { + if (shouldSkipItem(item) || item.getId() == null) { + continue; + } + externalIds.put(item.getId(), item.getId().toString()); + } + return externalIds; + } + + public static boolean isSkippedItemType(final InvoiceItemType type) { + return type == InvoiceItemType.TAX + || type == InvoiceItemType.ITEM_ADJ + || type == InvoiceItemType.CREDIT_ADJ + || type == InvoiceItemType.REPAIR_ADJ; + } + + private static boolean shouldSkipItem(final InvoiceItem item) { + return isSkippedItemType(item.getInvoiceItemType()); + } + + private static ObjectNode resolveAddress(final Account account, final AccountTaxMetadata metadata) { + if (metadata.getShipToAddress() != null && metadata.getShipToAddress().hasData()) { + return metadata.getShipToAddress().toJson(MAPPER); + } + return accountToAddress(account); + } + + private static ObjectNode accountToAddress(final Account account) { + final ObjectNode address = MAPPER.createObjectNode(); + if (account.getCountry() != null) { + address.put("country", account.getCountry()); + } + if (account.getPostalCode() != null) { + address.put("postal_code", account.getPostalCode()); + } + if (account.getStateOrProvince() != null) { + address.put("state", account.getStateOrProvince()); + } + if (account.getCity() != null) { + address.put("city", account.getCity()); + } + if (account.getAddress1() != null) { + address.put("line1", account.getAddress1()); + } + if (account.getAddress2() != null) { + address.put("line2", account.getAddress2()); + } + return address; + } + + private static ObjectNode accountToCustomer(final Account account, final AccountTaxMetadata metadata) { + final ObjectNode customer = MAPPER.createObjectNode(); + final String externalId = account.getExternalKey() != null + ? account.getExternalKey() + : account.getId().toString(); + customer.put("external_id", externalId); + final String email = metadata.getContactEmail() != null + ? metadata.getContactEmail() + : account.getEmail(); + if (email != null) { + customer.put("email", email); + } + final String name = metadata.getCompanyName() != null + ? metadata.getCompanyName() + : account.getName(); + if (name != null) { + customer.put("name", name); + } + if (metadata.isTaxExempt()) { + customer.put("exempt", true); + } + if (metadata.getCustomerUsageType() != null) { + customer.put("entity_use_code", metadata.getCustomerUsageType()); + } + if (metadata.getTaxRegistrationNumber() != null) { + customer.put("tax_registration_number", metadata.getTaxRegistrationNumber()); + } + return customer; + } + + static String formatInvoiceDate(final Invoice invoice) { + final LocalDate invoiceDate = invoice.getInvoiceDate(); + if (invoiceDate == null) { + return LocalDate.now().toString(); + } + return invoiceDate.toString(); + } + + static String formatAmount(final BigDecimal amount) { + if (amount == null) { + return "0.00"; + } + return amount.setScale(2, RoundingMode.HALF_UP).toPlainString(); + } + + static String formatQuantity(final BigDecimal quantity) { + if (quantity == null) { + return "1"; + } + return quantity.stripTrailingZeros().toPlainString(); + } +} diff --git a/src/main/java/com/trykintsugi/killbill/internal/InvoiceTaxIdempotency.java b/src/main/java/com/trykintsugi/killbill/internal/InvoiceTaxIdempotency.java new file mode 100644 index 0000000..51bc7b7 --- /dev/null +++ b/src/main/java/com/trykintsugi/killbill/internal/InvoiceTaxIdempotency.java @@ -0,0 +1,65 @@ +/* + * Copyright 2026 Kintsugi Technologies, Inc. + * + * 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. + */ + +package com.trykintsugi.killbill.internal; + +import org.killbill.billing.invoice.api.Invoice; +import org.killbill.billing.invoice.api.InvoiceItem; +import org.killbill.billing.invoice.api.InvoiceItemType; + +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +/** Detects when tax items were already generated for an invoice (re-invoice idempotency). */ +public final class InvoiceTaxIdempotency { + + private InvoiceTaxIdempotency() {} + + /** + * Returns true when every taxable line on the invoice already has a linked {@code TAX} item. + * Avoids duplicate tax on a second plugin pass when TAX items are already linked. + */ + public static boolean allTaxableItemsAlreadyTaxed(final Invoice invoice) { + if (invoice.getInvoiceItems() == null || invoice.getInvoiceItems().isEmpty()) { + return false; + } + + final Set taxableItemIds = new HashSet<>(); + final Set taxedItemIds = new HashSet<>(); + + for (final InvoiceItem item : invoice.getInvoiceItems()) { + if (item.getId() == null) { + if (!InvoiceRequestMapper.isSkippedItemType(item.getInvoiceItemType())) { + return false; + } + continue; + } + if (item.getInvoiceItemType() == InvoiceItemType.TAX) { + if (item.getLinkedItemId() != null) { + taxedItemIds.add(item.getLinkedItemId()); + } + } else if (!InvoiceRequestMapper.isSkippedItemType(item.getInvoiceItemType())) { + taxableItemIds.add(item.getId()); + } + } + + if (taxableItemIds.isEmpty()) { + return false; + } + return taxedItemIds.containsAll(taxableItemIds); + } +} diff --git a/src/main/java/com/trykintsugi/killbill/internal/KintsugiTaxClient.java b/src/main/java/com/trykintsugi/killbill/internal/KintsugiTaxClient.java new file mode 100644 index 0000000..b56d9f8 --- /dev/null +++ b/src/main/java/com/trykintsugi/killbill/internal/KintsugiTaxClient.java @@ -0,0 +1,179 @@ +/* + * Copyright 2026 Kintsugi Technologies, Inc. + * + * 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. + */ + + +package com.trykintsugi.killbill.internal; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.math.BigDecimal; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +/** HTTP client for Kintsugi /killbill/tax/estimate. */ +public final class KintsugiTaxClient { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String SIGNATURE_HEADER = "X-Killbill-Kintsugi-Signature"; + private static final String API_KEY_HEADER = "X-Killbill-ApiKey"; + + private final HttpClient httpClient; + private final String kintsugiBaseUrl; + private final String hmacSecret; + private final String tenantApiKey; + + public KintsugiTaxClient( + final String kintsugiBaseUrl, + final String hmacSecret, + final String tenantApiKey) { + this.kintsugiBaseUrl = kintsugiBaseUrl.endsWith("/") + ? kintsugiBaseUrl.substring(0, kintsugiBaseUrl.length() - 1) + : kintsugiBaseUrl; + this.hmacSecret = hmacSecret; + this.tenantApiKey = tenantApiKey; + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .version(HttpClient.Version.HTTP_1_1) + .build(); + } + + public List estimate( + final ObjectNode requestBody, + final boolean commit) throws Exception { + final String path = commit ? "/killbill/tax/commit" : "/killbill/tax/estimate"; + final byte[] bodyBytes = MAPPER.writeValueAsBytes(requestBody); + final String signature = hmacSha256Hex(hmacSecret, bodyBytes); + + final HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(kintsugiBaseUrl + path)) + .timeout(Duration.ofSeconds(10)) + .header("Content-Type", "application/json") + .header(SIGNATURE_HEADER, signature) + .header(API_KEY_HEADER, tenantApiKey) + .POST(HttpRequest.BodyPublishers.ofByteArray(bodyBytes)) + .build(); + + final HttpResponse response = + httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IllegalStateException( + "Kintsugi tax API returned HTTP " + response.statusCode()); + } + + return parseTaxLines(response.body()); + } + + static List parseTaxLines(final String responseJson) throws Exception { + final List results = new ArrayList<>(); + final JsonNode root = MAPPER.readTree(responseJson); + final JsonNode documents = root.path("documents"); + if (!documents.isArray()) { + return results; + } + for (final JsonNode doc : documents) { + final JsonNode lineItems = doc.path("line_items"); + if (!lineItems.isArray()) { + continue; + } + for (final JsonNode line : lineItems) { + final String externalId = line.path("external_id").asText(null); + final BigDecimal taxAmount = new BigDecimal(line.path("tax_amount").asText("0")); + final BigDecimal rate = new BigDecimal(line.path("rate").asText("0")); + if (externalId != null) { + results.add(new TaxLineResult(externalId, taxAmount, rate)); + } + } + } + return results; + } + + static String hmacSha256Hex(final String secret, final byte[] payload) throws Exception { + final Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + final byte[] digest = mac.doFinal(payload); + final StringBuilder sb = new StringBuilder(digest.length * 2); + for (final byte b : digest) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } + + public static ObjectNode buildEstimateRequest( + final String requestId, + final String currency, + final String invoiceId, + final String accountId, + final boolean dryRun, + final ArrayNode lineItems, + final ObjectNode shipTo, + final ObjectNode billTo, + final ObjectNode customer, + final String transactionDate) { + final ObjectNode root = MAPPER.createObjectNode(); + root.put("id", requestId); + root.put("currency_code", currency); + final ObjectNode document = MAPPER.createObjectNode(); + document.put("id", invoiceId); + document.put("account_id", accountId); + document.put("dry_run", dryRun); + document.put("transaction_date", transactionDate); + document.set("ship_to", shipTo); + document.set("bill_to", billTo); + document.set("customer", customer); + document.set("line_items", lineItems); + root.set("documents", MAPPER.createArrayNode().add(document)); + return root; + } + + public static final class TaxLineResult { + private final String lineExternalId; + private final BigDecimal taxAmount; + private final BigDecimal ratePercent; + + public TaxLineResult( + final String lineExternalId, + final BigDecimal taxAmount, + final BigDecimal ratePercent) { + this.lineExternalId = lineExternalId; + this.taxAmount = taxAmount; + this.ratePercent = ratePercent; + } + + public String lineExternalId() { + return lineExternalId; + } + + public BigDecimal taxAmount() { + return taxAmount; + } + + public BigDecimal ratePercent() { + return ratePercent; + } + } +} diff --git a/src/main/java/com/trykintsugi/killbill/internal/TaxAddress.java b/src/main/java/com/trykintsugi/killbill/internal/TaxAddress.java new file mode 100644 index 0000000..29ed16a --- /dev/null +++ b/src/main/java/com/trykintsugi/killbill/internal/TaxAddress.java @@ -0,0 +1,100 @@ +/* + * Copyright 2026 Kintsugi Technologies, Inc. + * + * 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. + */ + +package com.trykintsugi.killbill.internal; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.killbill.billing.account.api.Account; + +/** Normalized postal address for ship-to / bill-to on tax requests. */ +public final class TaxAddress { + + private final String line1; + private final String line2; + private final String city; + private final String state; + private final String country; + private final String postalCode; + + public TaxAddress( + final String line1, + final String line2, + final String city, + final String state, + final String country, + final String postalCode) { + this.line1 = line1; + this.line2 = line2; + this.city = city; + this.state = state; + this.country = country; + this.postalCode = postalCode; + } + + public static TaxAddress fromAccount(final Account account) { + if (account == null) { + return null; + } + return new TaxAddress( + account.getAddress1(), + account.getAddress2(), + account.getCity(), + account.getStateOrProvince(), + account.getCountry(), + account.getPostalCode()); + } + + public boolean hasData() { + return !isBlank(line1) + || !isBlank(line2) + || !isBlank(city) + || !isBlank(state) + || !isBlank(country) + || !isBlank(postalCode); + } + + public ObjectNode toJson(final ObjectMapper mapper) { + final ObjectNode address = mapper.createObjectNode(); + if (!isBlank(country)) { + address.put("country", country); + } + if (!isBlank(postalCode)) { + address.put("postal_code", postalCode); + } + if (!isBlank(state)) { + address.put("state", state); + } + if (!isBlank(city)) { + address.put("city", city); + } + if (!isBlank(line1)) { + address.put("line1", line1); + } + if (!isBlank(line2)) { + address.put("line2", line2); + } + return address; + } + + public String getCountry() { + return country; + } + + private static boolean isBlank(final String value) { + return value == null || value.isBlank(); + } +} diff --git a/src/main/java/com/trykintsugi/killbill/internal/TaxAddressParser.java b/src/main/java/com/trykintsugi/killbill/internal/TaxAddressParser.java new file mode 100644 index 0000000..e09e114 --- /dev/null +++ b/src/main/java/com/trykintsugi/killbill/internal/TaxAddressParser.java @@ -0,0 +1,46 @@ +/* + * Copyright 2026 Kintsugi Technologies, Inc. + * + * 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. + */ + +package com.trykintsugi.killbill.internal; + +import com.fasterxml.jackson.databind.JsonNode; + +final class TaxAddressParser { + + private TaxAddressParser() {} + + static TaxAddress fromAviateAddress(final JsonNode node) { + if (node == null || node.isMissingNode() || node.isNull()) { + return null; + } + return new TaxAddress( + textOrNull(node, "addressLine1"), + textOrNull(node, "addressLine2"), + textOrNull(node, "city"), + textOrNull(node, "state"), + textOrNull(node, "country"), + textOrNull(node, "postalCode")); + } + + private static String textOrNull(final JsonNode node, final String field) { + final JsonNode value = node.path(field); + if (value.isMissingNode() || value.isNull()) { + return null; + } + final String text = value.asText(null); + return text != null && !text.isBlank() ? text.trim() : null; + } +} diff --git a/src/main/java/com/trykintsugi/killbill/internal/TaxInputMetadataLoader.java b/src/main/java/com/trykintsugi/killbill/internal/TaxInputMetadataLoader.java new file mode 100644 index 0000000..85abb4d --- /dev/null +++ b/src/main/java/com/trykintsugi/killbill/internal/TaxInputMetadataLoader.java @@ -0,0 +1,275 @@ +/* + * Copyright 2026 Kintsugi Technologies, Inc. + * + * 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. + */ + +package com.trykintsugi.killbill.internal; + +import org.killbill.billing.ObjectType; +import org.killbill.billing.invoice.api.Invoice; +import org.killbill.billing.invoice.api.InvoiceItem; +import org.killbill.billing.osgi.libs.killbill.OSGIKillbillAPI; +import org.killbill.billing.payment.api.PluginProperty; +import org.killbill.billing.plugin.api.PluginProperties; +import org.killbill.billing.util.callcontext.TenantContext; +import org.killbill.billing.util.customfield.CustomField; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +/** + * Loads tax input metadata. Plugin properties take precedence over custom fields. + */ +final class TaxInputMetadataLoader { + + /** @see InvoicePluginPropertyNames#CUSTOMER_USAGE_TYPE */ + static final String CUSTOMER_USAGE_TYPE = InvoicePluginPropertyNames.CUSTOMER_USAGE_TYPE; + /** @see InvoicePluginPropertyNames#TAX_EXEMPT */ + static final String TAX_EXEMPT = InvoicePluginPropertyNames.TAX_EXEMPT; + /** Custom field name for per-line tax code. */ + static final String TAX_CODE = "taxCode"; + + private TaxInputMetadataLoader() {} + + static TaxInputSnapshot load( + final OSGIKillbillAPI killbillAPI, + final Invoice invoice, + final Iterable pluginProperties, + final TenantContext context) throws Exception { + final String usageType = resolveCustomerUsageType(killbillAPI, invoice, pluginProperties, context); + final Optional taxExempt = resolveTaxExempt(killbillAPI, invoice, pluginProperties, context); + final Map taxCodes = resolveLineTaxCodes(killbillAPI, invoice, pluginProperties, context); + final String trn = resolveTaxRegistrationNumber(pluginProperties); + final String companyName = propertyValue(pluginProperties, InvoicePluginPropertyNames.COMPANY_NAME); + final TaxAddress shipTo = resolveShipToFromProperties(pluginProperties); + final boolean shipToFromPluginProperties = shipTo != null; + return new TaxInputSnapshot( + usageType, taxExempt, trn, companyName, shipTo, shipToFromPluginProperties, taxCodes); + } + + private static String resolveCustomerUsageType( + final OSGIKillbillAPI killbillAPI, + final Invoice invoice, + final Iterable pluginProperties, + final TenantContext context) throws Exception { + final String fromProperty = PluginProperties.findPluginPropertyValue(CUSTOMER_USAGE_TYPE, pluginProperties); + if (fromProperty != null && !fromProperty.isBlank()) { + return fromProperty.trim(); + } + return accountFieldValue(killbillAPI, invoice.getAccountId(), CUSTOMER_USAGE_TYPE, context); + } + + private static Optional resolveTaxExempt( + final OSGIKillbillAPI killbillAPI, + final Invoice invoice, + final Iterable pluginProperties, + final TenantContext context) throws Exception { + final String fromProperty = PluginProperties.findPluginPropertyValue(TAX_EXEMPT, pluginProperties); + if (fromProperty != null && !fromProperty.isBlank()) { + return Optional.of(Boolean.parseBoolean(fromProperty.trim())); + } + final String fromField = accountFieldValue(killbillAPI, invoice.getAccountId(), TAX_EXEMPT, context); + if (fromField != null && !fromField.isBlank()) { + return Optional.of(Boolean.parseBoolean(fromField.trim())); + } + return Optional.empty(); + } + + private static String resolveTaxRegistrationNumber(final Iterable pluginProperties) { + final String trn = propertyValue(pluginProperties, InvoicePluginPropertyNames.TAX_REGISTRATION_NUMBER); + if (trn != null) { + return trn; + } + return propertyValue(pluginProperties, InvoicePluginPropertyNames.TRN); + } + + private static TaxAddress resolveShipToFromProperties(final Iterable pluginProperties) { + final String line1 = propertyValue(pluginProperties, InvoicePluginPropertyNames.SHIP_TO_LINE1); + final String line2 = propertyValue(pluginProperties, InvoicePluginPropertyNames.SHIP_TO_LINE2); + final String city = propertyValue(pluginProperties, InvoicePluginPropertyNames.SHIP_TO_CITY); + final String state = propertyValue(pluginProperties, InvoicePluginPropertyNames.SHIP_TO_STATE); + final String country = propertyValue(pluginProperties, InvoicePluginPropertyNames.SHIP_TO_COUNTRY); + final String postalCode = propertyValue(pluginProperties, InvoicePluginPropertyNames.SHIP_TO_POSTAL_CODE); + if (line1 == null && line2 == null && city == null && state == null && country == null && postalCode == null) { + return null; + } + final TaxAddress address = new TaxAddress(line1, line2, city, state, country, postalCode); + return address.hasData() ? address : null; + } + + private static Map resolveLineTaxCodes( + final OSGIKillbillAPI killbillAPI, + final Invoice invoice, + final Iterable pluginProperties, + final TenantContext context) throws Exception { + final Set invoiceItemIds = new HashSet<>(); + for (final InvoiceItem item : invoice.getInvoiceItems()) { + if (item.getId() != null) { + invoiceItemIds.add(item.getId()); + } + } + + final Map taxCodes = new HashMap<>(); + if (!invoiceItemIds.isEmpty()) { + final List customFields = killbillAPI.getCustomFieldUserApi() + .getCustomFieldsForAccountType(invoice.getAccountId(), ObjectType.INVOICE_ITEM, context); + for (final CustomField field : customFields) { + if (field == null || field.getObjectId() == null) { + continue; + } + if (!TAX_CODE.equals(field.getFieldName())) { + continue; + } + if (!invoiceItemIds.contains(field.getObjectId())) { + continue; + } + final String value = field.getFieldValue(); + if (value != null && !value.isBlank()) { + taxCodes.put(field.getObjectId(), value.trim()); + } + } + } + applyPluginPropertyTaxCodes(pluginProperties, taxCodes); + return taxCodes; + } + + private static void applyPluginPropertyTaxCodes( + final Iterable pluginProperties, + final Map taxCodes) { + if (pluginProperties == null) { + return; + } + final String prefix = InvoicePluginPropertyNames.TAX_CODE_PREFIX; + for (final PluginProperty property : pluginProperties) { + if (property == null || property.getKey() == null) { + continue; + } + if (!property.getKey().startsWith(prefix)) { + continue; + } + try { + final UUID itemId = UUID.fromString(property.getKey().substring(prefix.length())); + final String value = stringValue(property.getValue()); + if (value != null) { + taxCodes.put(itemId, value); + } + } catch (IllegalArgumentException ignored) { + // Not a UUID suffix — skip. + } + } + } + + private static String stringValue(final Object value) { + if (value == null) { + return null; + } + final String text = value.toString(); + if (text.isBlank()) { + return null; + } + return text.trim(); + } + + private static String propertyValue( + final Iterable pluginProperties, + final String key) { + final String value = PluginProperties.findPluginPropertyValue(key, pluginProperties); + if (value == null || value.isBlank()) { + return null; + } + return value.trim(); + } + + private static String accountFieldValue( + final OSGIKillbillAPI killbillAPI, + final UUID accountId, + final String fieldName, + final TenantContext context) throws Exception { + final List customFields = killbillAPI.getCustomFieldUserApi() + .getCustomFieldsForObject(accountId, ObjectType.ACCOUNT, context); + for (final CustomField field : customFields) { + if (field != null && fieldName.equals(field.getFieldName())) { + final String value = field.getFieldValue(); + if (value != null && !value.isBlank()) { + return value.trim(); + } + } + } + return null; + } + + static final class TaxInputSnapshot { + private final String customerUsageType; + private final Optional taxExempt; + private final String taxRegistrationNumber; + private final String companyName; + private final TaxAddress shipToAddress; + private final boolean shipToFromPluginProperties; + private final Map taxCodeByInvoiceItemId; + + TaxInputSnapshot( + final String customerUsageType, + final Optional taxExempt, + final String taxRegistrationNumber, + final String companyName, + final TaxAddress shipToAddress, + final boolean shipToFromPluginProperties, + final Map taxCodeByInvoiceItemId) { + this.customerUsageType = customerUsageType; + this.taxExempt = taxExempt; + this.taxRegistrationNumber = taxRegistrationNumber; + this.companyName = companyName; + this.shipToAddress = shipToAddress; + this.shipToFromPluginProperties = shipToFromPluginProperties; + this.taxCodeByInvoiceItemId = taxCodeByInvoiceItemId; + } + + String customerUsageType() { + return customerUsageType; + } + + boolean taxExempt() { + return taxExempt.orElse(false); + } + + boolean taxExemptExplicitlySet() { + return taxExempt.isPresent(); + } + + String taxRegistrationNumber() { + return taxRegistrationNumber; + } + + String companyName() { + return companyName; + } + + TaxAddress shipToAddress() { + return shipToAddress; + } + + boolean shipToFromPluginProperties() { + return shipToFromPluginProperties; + } + + Map taxCodeByInvoiceItemId() { + return taxCodeByInvoiceItemId; + } + } +} diff --git a/src/main/java/com/trykintsugi/killbill/internal/TaxItemMapper.java b/src/main/java/com/trykintsugi/killbill/internal/TaxItemMapper.java new file mode 100644 index 0000000..1c28a33 --- /dev/null +++ b/src/main/java/com/trykintsugi/killbill/internal/TaxItemMapper.java @@ -0,0 +1,94 @@ +/* + * Copyright 2026 Kintsugi Technologies, Inc. + * + * 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. + */ + + +package com.trykintsugi.killbill.internal; + +import org.joda.time.LocalDate; +import org.killbill.billing.invoice.api.Invoice; +import org.killbill.billing.invoice.api.InvoiceItem; +import org.killbill.billing.plugin.api.invoice.PluginInvoiceItem; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** Maps Kintsugi tax lines to Kill Bill TAX invoice items. */ +public final class TaxItemMapper { + + private TaxItemMapper() {} + + public static List toTaxItems( + final Invoice invoice, + final List taxLines, + final Map taxableItemsByExternalId) { + final List taxItems = new ArrayList<>(); + final LocalDate invoiceDate = invoice.getInvoiceDate(); + + for (final KintsugiTaxClient.TaxLineResult taxLine : taxLines) { + if (taxLine.taxAmount() == null || taxLine.taxAmount().compareTo(BigDecimal.ZERO) == 0) { + continue; + } + final InvoiceItem linkedItem = findByExternalId( + taxableItemsByExternalId, taxLine.lineExternalId()); + if (linkedItem == null) { + continue; + } + taxItems.add(buildTaxItem(invoice, linkedItem, invoiceDate, taxLine)); + } + return taxItems; + } + + public static Map indexTaxableItems(final Invoice invoice) { + final Map byExternalId = new HashMap<>(); + for (final InvoiceItem item : invoice.getInvoiceItems()) { + if (item.getId() != null) { + byExternalId.put(item.getId(), item); + } + } + return byExternalId; + } + + private static InvoiceItem findByExternalId( + final Map items, + final String externalId) { + try { + return items.get(UUID.fromString(externalId)); + } catch (IllegalArgumentException e) { + return null; + } + } + + private static InvoiceItem buildTaxItem( + final Invoice invoice, + final InvoiceItem linkedItem, + final LocalDate invoiceDate, + final KintsugiTaxClient.TaxLineResult taxLine) { + final String description = taxLine.ratePercent() != null + ? String.format("Sales tax (%.2f%%)", taxLine.ratePercent()) + : "Sales tax"; + return PluginInvoiceItem.createTaxItem( + linkedItem, + invoice.getId(), + invoiceDate, + null, + taxLine.taxAmount(), + description); + } +} diff --git a/src/main/java/com/trykintsugi/killbill/internal/TaxMetadataResolver.java b/src/main/java/com/trykintsugi/killbill/internal/TaxMetadataResolver.java new file mode 100644 index 0000000..23e1519 --- /dev/null +++ b/src/main/java/com/trykintsugi/killbill/internal/TaxMetadataResolver.java @@ -0,0 +1,126 @@ +/* + * Copyright 2026 Kintsugi Technologies, Inc. + * + * 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. + */ + +package com.trykintsugi.killbill.internal; + +import com.trykintsugi.killbill.KintsugiTenantConfig; +import org.killbill.billing.account.api.Account; +import org.killbill.billing.invoice.api.Invoice; +import org.killbill.billing.osgi.libs.killbill.OSGIKillbillAPI; +import org.killbill.billing.payment.api.PluginProperty; +import org.killbill.billing.util.callcontext.TenantContext; + +import java.util.Optional; + +/** + * Resolves tax metadata for both deployment models (one plugin): + *

    + *
  1. Plugin properties — primary; Aviate plugin will pass these on invoice generation
  2. + *
  3. Custom fields — fallback for non-Aviate tenants
  4. + *
  5. Aviate billing account HTTP — optional gap-fill when {@code aviateIdToken} is set
  6. + *
+ */ +public final class TaxMetadataResolver { + + /** @see InvoicePluginPropertyNames#CUSTOMER_USAGE_TYPE */ + public static final String CUSTOMER_USAGE_TYPE = InvoicePluginPropertyNames.CUSTOMER_USAGE_TYPE; + /** @see InvoicePluginPropertyNames#TAX_EXEMPT */ + public static final String TAX_EXEMPT = InvoicePluginPropertyNames.TAX_EXEMPT; + /** Custom field name for per-line tax code. */ + public static final String TAX_CODE = TaxInputMetadataLoader.TAX_CODE; + + private final OSGIKillbillAPI killbillAPI; + private final AviateBillingAccountClient aviateClient; + + public TaxMetadataResolver(final OSGIKillbillAPI killbillAPI) { + this(killbillAPI, new AviateBillingAccountClient()); + } + + TaxMetadataResolver( + final OSGIKillbillAPI killbillAPI, + final AviateBillingAccountClient aviateClient) { + this.killbillAPI = killbillAPI; + this.aviateClient = aviateClient; + } + + public AccountTaxMetadata resolve( + final Invoice invoice, + final Account account, + final Iterable pluginProperties, + final TenantContext context, + final KintsugiTenantConfig config, + final String tenantApiKey, + final String tenantApiSecret) throws Exception { + final TaxInputMetadataLoader.TaxInputSnapshot input = TaxInputMetadataLoader.load( + killbillAPI, invoice, pluginProperties, context); + + AccountTaxMetadata.Builder builder = fromInput(input); + + if (config != null + && config.hasAviateIntegration() + && !input.shipToFromPluginProperties()) { + final Optional billingAccount = aviateClient.fetchForKbAccountId( + config.getKillbillUrl(), + invoice.getAccountId(), + tenantApiKey, + tenantApiSecret, + config.getAviateIdToken()); + if (billingAccount.isPresent()) { + builder = fillAviateGaps(builder, billingAccount.get(), account, input); + } + } + + return builder.build(); + } + + private static AccountTaxMetadata.Builder fromInput( + final TaxInputMetadataLoader.TaxInputSnapshot input) { + return AccountTaxMetadata.builder() + .source(AccountTaxMetadata.Source.CUSTOM_FIELDS) + .taxExempt(input.taxExempt()) + .customerUsageType(input.customerUsageType()) + .taxRegistrationNumber(input.taxRegistrationNumber()) + .companyName(input.companyName()) + .shipToAddress(input.shipToAddress()) + .taxCodeByInvoiceItemId(input.taxCodeByInvoiceItemId()); + } + + private static AccountTaxMetadata.Builder fillAviateGaps( + final AccountTaxMetadata.Builder builder, + final AviateBillingAccount billingAccount, + final Account account, + final TaxInputMetadataLoader.TaxInputSnapshot input) { + final String preferredCountry = account != null ? account.getCountry() : null; + builder.source(AccountTaxMetadata.Source.AVIATE_BILLING_ACCOUNT); + + if (!input.taxExemptExplicitlySet() && billingAccount.isTaxExempt()) { + builder.taxExempt(true); + } + if (input.taxRegistrationNumber() == null) { + builder.taxRegistrationNumber(billingAccount.resolveTaxRegistrationNumber(preferredCountry)); + } + if (input.companyName() == null) { + builder.companyName(billingAccount.getCompanyName()); + } + if (input.shipToAddress() == null) { + builder.shipToAddress(billingAccount.resolveShipToAddress(preferredCountry)); + } + if (billingAccount.getEmail() != null) { + builder.contactEmail(billingAccount.getEmail()); + } + return builder; + } +} diff --git a/src/test/java/com/trykintsugi/killbill/TestInvoiceContext.java b/src/test/java/com/trykintsugi/killbill/TestInvoiceContext.java new file mode 100644 index 0000000..cacd474 --- /dev/null +++ b/src/test/java/com/trykintsugi/killbill/TestInvoiceContext.java @@ -0,0 +1,79 @@ +/* + * Copyright 2026 Kintsugi Technologies, Inc. + * + * 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. + */ + +package com.trykintsugi.killbill; + +import org.joda.time.LocalDate; +import org.killbill.billing.invoice.api.Invoice; +import org.killbill.billing.invoice.plugin.api.InvoiceContext; +import org.killbill.billing.plugin.api.PluginCallContext; +import org.killbill.billing.util.callcontext.CallContext; + +import java.util.List; + +/** Test {@link InvoiceContext} for invoice plugin API tests. */ +public final class TestInvoiceContext extends PluginCallContext implements InvoiceContext { + + private final LocalDate targetDate; + private final Invoice invoice; + private final List existingInvoices; + private final boolean isDryRun; + private final boolean isRescheduled; + + public TestInvoiceContext( + final LocalDate targetDate, + final Invoice invoice, + final List existingInvoices, + final boolean isDryRun, + final boolean isRescheduled, + final CallContext context) { + super( + KintsugiActivator.PLUGIN_NAME, + context.getCreatedDate(), + context.getAccountId(), + context.getTenantId()); + this.targetDate = targetDate; + this.invoice = invoice; + this.existingInvoices = existingInvoices; + this.isDryRun = isDryRun; + this.isRescheduled = isRescheduled; + } + + @Override + public LocalDate getTargetDate() { + return targetDate; + } + + @Override + public Invoice getInvoice() { + return invoice; + } + + @Override + public List getExistingInvoices() { + return existingInvoices; + } + + @Override + public boolean isDryRun() { + return isDryRun; + } + + @Override + public boolean isRescheduled() { + return isRescheduled; + } +} diff --git a/src/test/java/com/trykintsugi/killbill/TestKintsugiConfigurationHandler.java b/src/test/java/com/trykintsugi/killbill/TestKintsugiConfigurationHandler.java new file mode 100644 index 0000000..da7dd8e --- /dev/null +++ b/src/test/java/com/trykintsugi/killbill/TestKintsugiConfigurationHandler.java @@ -0,0 +1,94 @@ +/* + * Copyright 2026 Kintsugi Technologies, Inc. + * + * 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. + */ + +package com.trykintsugi.killbill; + +import org.killbill.billing.osgi.libs.killbill.OSGIKillbillAPI; +import org.killbill.billing.tenant.api.Tenant; +import org.mockito.Mockito; +import org.testng.Assert; +import org.testng.annotations.Test; + +import java.util.Properties; +import java.util.UUID; + +public class TestKintsugiConfigurationHandler { + + @Test(groups = "fast") + public void testCreateConfigurableTrimsProperties() { + final KintsugiConfigurationHandler handler = new KintsugiConfigurationHandler( + KintsugiActivator.PLUGIN_NAME, Mockito.mock(OSGIKillbillAPI.class)); + + final Properties properties = new Properties(); + properties.setProperty("kintsugiUrl", " https://api.example.com "); + properties.setProperty("hmacSecret", " shared-secret "); + + final KintsugiTenantConfig config = handler.createConfigurable(properties); + + Assert.assertEquals(config.getKintsugiUrl(), "https://api.example.com"); + Assert.assertEquals(config.getHmacSecret(), "shared-secret"); + } + + @Test(groups = "fast") + public void testGetConfigurableReturnsTenantConfig() { + final KintsugiConfigurationHandler handler = new KintsugiConfigurationHandler( + KintsugiActivator.PLUGIN_NAME, Mockito.mock(OSGIKillbillAPI.class)); + + final Properties properties = new Properties(); + properties.setProperty("kintsugiUrl", "https://api.example.com"); + properties.setProperty("hmacSecret", "shared-secret"); + handler.setDefaultConfigurable(handler.createConfigurable(properties)); + + final UUID tenantId = UUID.randomUUID(); + final KintsugiTenantConfig config = handler.getConfigurable(tenantId); + + Assert.assertEquals(config.getKintsugiUrl(), "https://api.example.com"); + Assert.assertEquals(config.getHmacSecret(), "shared-secret"); + } + + @Test(groups = "fast") + public void testAviateConfigIsOptional() { + final KintsugiConfigurationHandler handler = new KintsugiConfigurationHandler( + KintsugiActivator.PLUGIN_NAME, Mockito.mock(OSGIKillbillAPI.class)); + + final Properties properties = new Properties(); + properties.setProperty("kintsugiUrl", "https://api.example.com"); + properties.setProperty("hmacSecret", "shared-secret"); + properties.setProperty("killbillUrl", "http://kb.example.com:8080"); + properties.setProperty("aviateIdToken", "jwt-token"); + + final KintsugiTenantConfig config = handler.createConfigurable(properties); + + Assert.assertEquals(config.getKillbillUrl(), "http://kb.example.com:8080"); + Assert.assertEquals(config.getAviateIdToken(), "jwt-token"); + Assert.assertTrue(config.hasAviateIntegration()); + } + + @Test(groups = "fast") + public void testDefaultKillbillUrlWhenBlank() { + final KintsugiConfigurationHandler handler = new KintsugiConfigurationHandler( + KintsugiActivator.PLUGIN_NAME, Mockito.mock(OSGIKillbillAPI.class)); + + final Properties properties = new Properties(); + properties.setProperty("kintsugiUrl", "https://api.example.com"); + properties.setProperty("hmacSecret", "shared-secret"); + + final KintsugiTenantConfig config = handler.createConfigurable(properties); + + Assert.assertEquals(config.getKillbillUrl(), "http://127.0.0.1:8080"); + Assert.assertFalse(config.hasAviateIntegration()); + } +} diff --git a/src/test/java/com/trykintsugi/killbill/TestKintsugiHealthcheck.java b/src/test/java/com/trykintsugi/killbill/TestKintsugiHealthcheck.java new file mode 100644 index 0000000..04f4852 --- /dev/null +++ b/src/test/java/com/trykintsugi/killbill/TestKintsugiHealthcheck.java @@ -0,0 +1,90 @@ +/* + * Copyright 2026 Kintsugi Technologies, Inc. + * + * 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. + */ + +package com.trykintsugi.killbill; + +import org.killbill.billing.tenant.api.Tenant; +import org.mockito.Mockito; +import org.testng.Assert; +import org.testng.annotations.Test; + +import java.util.UUID; + +public class TestKintsugiHealthcheck { + + @Test(groups = "fast") + public void testReportsHealthyWhenTenantIsAbsent() { + final KintsugiHealthcheck healthcheck = new KintsugiHealthcheck(tenantId -> null); + + Assert.assertTrue(healthcheck.getHealthStatus(null, null).isHealthy()); + } + + @Test(groups = "fast") + public void testReportsUnhealthyWhenTenantConfigMissing() { + final UUID tenantId = UUID.randomUUID(); + final Tenant tenant = Mockito.mock(Tenant.class); + Mockito.when(tenant.getId()).thenReturn(tenantId); + + final KintsugiHealthcheck healthcheck = new KintsugiHealthcheck(id -> null); + + Assert.assertFalse(healthcheck.getHealthStatus(tenant, null).isHealthy()); + } + + @Test(groups = "fast") + public void testReportsUnhealthyWhenUrlBlank() { + final UUID tenantId = UUID.randomUUID(); + final Tenant tenant = Mockito.mock(Tenant.class); + Mockito.when(tenant.getId()).thenReturn(tenantId); + + final KintsugiTenantConfig config = new KintsugiTenantConfig(); + config.setKintsugiUrl(""); + config.setHmacSecret("secret"); + + final KintsugiHealthcheck healthcheck = new KintsugiHealthcheck(id -> config); + + Assert.assertFalse(healthcheck.getHealthStatus(tenant, null).isHealthy()); + } + + @Test(groups = "fast") + public void testReportsUnhealthyWhenUrlOrSecretBlank() { + final UUID tenantId = UUID.randomUUID(); + final Tenant tenant = Mockito.mock(Tenant.class); + Mockito.when(tenant.getId()).thenReturn(tenantId); + + final KintsugiTenantConfig config = new KintsugiTenantConfig(); + config.setKintsugiUrl("https://api.example.com"); + config.setHmacSecret(""); + + final KintsugiHealthcheck healthcheck = new KintsugiHealthcheck(id -> config); + + Assert.assertFalse(healthcheck.getHealthStatus(tenant, null).isHealthy()); + } + + @Test(groups = "fast") + public void testReportsHealthyWhenTenantConfigPresent() { + final UUID tenantId = UUID.randomUUID(); + final Tenant tenant = Mockito.mock(Tenant.class); + final KintsugiTenantConfig config = new KintsugiTenantConfig(); + config.setKintsugiUrl("https://api.example.com"); + config.setHmacSecret("secret"); + + Mockito.when(tenant.getId()).thenReturn(tenantId); + + final KintsugiHealthcheck healthcheck = new KintsugiHealthcheck(id -> config); + + Assert.assertTrue(healthcheck.getHealthStatus(tenant, null).isHealthy()); + } +} diff --git a/src/test/java/com/trykintsugi/killbill/TestKintsugiInvoicePluginApi.java b/src/test/java/com/trykintsugi/killbill/TestKintsugiInvoicePluginApi.java new file mode 100644 index 0000000..d9508de --- /dev/null +++ b/src/test/java/com/trykintsugi/killbill/TestKintsugiInvoicePluginApi.java @@ -0,0 +1,305 @@ +/* + * Copyright 2026 Kintsugi Technologies, Inc. + * + * 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. + */ + +package com.trykintsugi.killbill; + +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.verification.LoggedRequest; +import org.killbill.billing.account.api.Account; +import org.killbill.billing.catalog.api.Currency; +import org.killbill.billing.invoice.api.Invoice; +import org.killbill.billing.invoice.api.InvoiceItem; +import org.killbill.billing.invoice.api.InvoiceItemType; +import org.killbill.billing.invoice.plugin.api.InvoiceContext; +import org.killbill.billing.invoice.plugin.api.InvoicePluginApiRetryException; +import org.killbill.billing.osgi.libs.killbill.OSGIConfigPropertiesService; +import org.killbill.billing.osgi.libs.killbill.OSGIKillbillAPI; +import org.killbill.billing.plugin.TestUtils; +import org.killbill.billing.plugin.api.PluginCallContext; +import org.killbill.billing.tenant.api.Tenant; +import org.killbill.billing.tenant.api.TenantUserApi; +import org.killbill.billing.ObjectType; +import org.killbill.billing.util.callcontext.CallContext; +import org.killbill.billing.util.api.CustomFieldUserApi; +import org.killbill.clock.Clock; +import org.killbill.clock.DefaultClock; +import org.mockito.Mockito; +import org.osgi.framework.BundleContext; +import org.testng.Assert; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import java.math.BigDecimal; +import java.util.LinkedList; +import java.util.List; +import java.util.Properties; +import java.util.UUID; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.matching; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; + +public class TestKintsugiInvoicePluginApi { + + private static final String HMAC_SECRET = "test-hmac-secret"; + private static final String TENANT_API_KEY = "tenant-api-key"; + + private WireMockServer wireMockServer; + private KintsugiInvoicePluginApi pluginApi; + private KintsugiConfigurationHandler configurationHandler; + private Account account; + private UUID tenantId; + private InvoiceContext invoiceContext; + + @BeforeMethod(groups = "fast") + public void setUp() throws Exception { + wireMockServer = new WireMockServer(wireMockConfig().dynamicPort()); + wireMockServer.start(); + + final Clock clock = new DefaultClock(); + account = TestUtils.buildAccount( + Currency.USD, "1 Market St", null, "Austin", "TX", "78701", "US"); + tenantId = UUID.randomUUID(); + final CallContext callContext = new PluginCallContext( + KintsugiActivator.PLUGIN_NAME, clock.getUTCNow(), account.getId(), tenantId); + invoiceContext = new TestInvoiceContext(null, null, null, false, false, callContext); + + final OSGIKillbillAPI osgiKillbillAPI = TestUtils.buildOSGIKillbillAPI(account); + final TenantUserApi tenantUserApi = Mockito.mock(TenantUserApi.class); + final Tenant tenant = Mockito.mock(Tenant.class); + Mockito.when(tenant.getApiKey()).thenReturn(TENANT_API_KEY); + Mockito.when(tenant.getApiSecret()).thenReturn("tenant-api-secret"); + Mockito.when(tenantUserApi.getTenantById(tenantId)).thenReturn(tenant); + Mockito.when(osgiKillbillAPI.getTenantUserApi()).thenReturn(tenantUserApi); + + final CustomFieldUserApi customFieldUserApi = Mockito.mock(CustomFieldUserApi.class); + Mockito.when(osgiKillbillAPI.getCustomFieldUserApi()).thenReturn(customFieldUserApi); + Mockito.when(customFieldUserApi.getCustomFieldsForObject( + Mockito.any(), Mockito.eq(ObjectType.ACCOUNT), Mockito.any())) + .thenReturn(List.of()); + Mockito.when(customFieldUserApi.getCustomFieldsForAccountType( + Mockito.any(), Mockito.eq(ObjectType.INVOICE_ITEM), Mockito.any())) + .thenReturn(List.of()); + + configurationHandler = new KintsugiConfigurationHandler( + KintsugiActivator.PLUGIN_NAME, osgiKillbillAPI); + configurePlugin(wireMockBaseUrl()); + + pluginApi = new KintsugiInvoicePluginApi( + osgiKillbillAPI, + new OSGIConfigPropertiesService(Mockito.mock(BundleContext.class)), + clock, + configurationHandler); + } + + @AfterMethod(groups = "fast") + public void tearDown() { + if (wireMockServer != null) { + wireMockServer.stop(); + } + } + + @Test(groups = "fast") + public void testMissingConfigReturnsEmpty() { + final Properties properties = new Properties(); + properties.setProperty("kintsugiUrl", ""); + properties.setProperty("hmacSecret", ""); + configurationHandler.setDefaultConfigurable(configurationHandler.createConfigurable(properties)); + + final Invoice invoice = TestUtils.buildInvoice(account); + final List invoiceItems = new LinkedList<>(); + Mockito.when(invoice.getInvoiceItems()).thenReturn(invoiceItems); + invoiceItems.add(TestUtils.buildInvoiceItem( + invoice, InvoiceItemType.EXTERNAL_CHARGE, new BigDecimal("100"), null)); + + Assert.assertTrue(pluginApi.getAdditionalInvoiceItems( + invoice, true, List.of(), invoiceContext).getAdditionalItems().isEmpty()); + } + + @Test(groups = "fast") + public void testEmptyInvoiceReturnsEmpty() { + final Invoice invoice = TestUtils.buildInvoice(account); + Mockito.when(invoice.getInvoiceItems()).thenReturn(List.of()); + + Assert.assertTrue(pluginApi.getAdditionalInvoiceItems( + invoice, true, List.of(), invoiceContext).getAdditionalItems().isEmpty()); + } + + @Test(groups = "fast") + public void testExternalChargeReturnsTaxItems() throws Exception { + final UUID itemId = UUID.randomUUID(); + stubTaxResponse(itemId, "8.25", "/killbill/tax/estimate"); + + final Invoice invoice = buildInvoiceWithExternalCharge(itemId, new BigDecimal("100")); + + final List taxItems = pluginApi.getAdditionalInvoiceItems( + invoice, true, List.of(), invoiceContext).getAdditionalItems(); + + Assert.assertEquals(taxItems.size(), 1); + Assert.assertEquals(taxItems.get(0).getInvoiceItemType(), InvoiceItemType.TAX); + Assert.assertEquals(taxItems.get(0).getAmount(), new BigDecimal("8.25")); + + wireMockServer.verify(postRequestedFor(urlPathEqualTo("/killbill/tax/estimate")) + .withHeader("X-Killbill-ApiKey", equalTo(TENANT_API_KEY)) + .withHeader("X-Killbill-Kintsugi-Signature", matching("[a-f0-9]{64}"))); + } + + @Test(groups = "fast") + public void testMultipleLineItemsReturnMultipleTaxItems() throws Exception { + final UUID itemId1 = UUID.randomUUID(); + final UUID itemId2 = UUID.randomUUID(); + final String body = "{\"documents\":[{\"line_items\":[" + + "{\"external_id\":\"" + itemId1 + "\",\"tax_amount\":\"1.00\",\"rate\":\"8.25\"}," + + "{\"external_id\":\"" + itemId2 + "\",\"tax_amount\":\"2.50\",\"rate\":\"8.25\"}" + + "]}]}"; + wireMockServer.stubFor(post(urlPathEqualTo("/killbill/tax/estimate")) + .willReturn(aResponse().withStatus(200).withBody(body))); + + final Invoice invoice = TestUtils.buildInvoice(account); + final List invoiceItems = new LinkedList<>(); + Mockito.when(invoice.getInvoiceItems()).thenReturn(invoiceItems); + + final InvoiceItem item1 = TestUtils.buildInvoiceItem( + invoice, InvoiceItemType.EXTERNAL_CHARGE, new BigDecimal("10"), null); + Mockito.when(item1.getId()).thenReturn(itemId1); + final InvoiceItem item2 = TestUtils.buildInvoiceItem( + invoice, InvoiceItemType.EXTERNAL_CHARGE, new BigDecimal("25"), null); + Mockito.when(item2.getId()).thenReturn(itemId2); + invoiceItems.add(item1); + invoiceItems.add(item2); + + final List taxItems = pluginApi.getAdditionalInvoiceItems( + invoice, true, List.of(), invoiceContext).getAdditionalItems(); + + Assert.assertEquals(taxItems.size(), 2); + Assert.assertEquals(taxItems.get(0).getAmount(), new BigDecimal("1.00")); + Assert.assertEquals(taxItems.get(1).getAmount(), new BigDecimal("2.50")); + } + + @Test(groups = "fast") + public void testDryRunUsesEstimateEndpoint() throws Exception { + final UUID itemId = UUID.randomUUID(); + stubTaxResponse(itemId, "1.00", "/killbill/tax/estimate"); + + final Invoice invoice = buildInvoiceWithExternalCharge(itemId, new BigDecimal("50")); + + pluginApi.getAdditionalInvoiceItems(invoice, true, List.of(), invoiceContext); + + wireMockServer.verify(1, postRequestedFor(urlPathEqualTo("/killbill/tax/estimate"))); + wireMockServer.verify(0, postRequestedFor(urlPathEqualTo("/killbill/tax/commit"))); + } + + @Test(groups = "fast") + public void testCommitUsesCommitEndpoint() throws Exception { + final UUID itemId = UUID.randomUUID(); + stubTaxResponse(itemId, "1.00", "/killbill/tax/commit"); + + final Invoice invoice = buildInvoiceWithExternalCharge(itemId, new BigDecimal("50")); + + pluginApi.getAdditionalInvoiceItems(invoice, false, List.of(), invoiceContext); + + wireMockServer.verify(1, postRequestedFor(urlPathEqualTo("/killbill/tax/commit"))); + wireMockServer.verify(0, postRequestedFor(urlPathEqualTo("/killbill/tax/estimate"))); + } + + @Test(groups = "fast", expectedExceptions = InvoicePluginApiRetryException.class) + public void testApiFailureTriggersRetry() throws Exception { + wireMockServer.stubFor(post(urlPathEqualTo("/killbill/tax/estimate")) + .willReturn(aResponse().withStatus(503).withBody("{\"error\":\"unavailable\"}"))); + + final UUID itemId = UUID.randomUUID(); + final Invoice invoice = buildInvoiceWithExternalCharge(itemId, new BigDecimal("100")); + + pluginApi.getAdditionalInvoiceItems(invoice, true, List.of(), invoiceContext); + } + + @Test(groups = "fast") + public void testSignatureHeaderIsPresent() throws Exception { + final UUID itemId = UUID.randomUUID(); + stubTaxResponse(itemId, "2.00", "/killbill/tax/estimate"); + + final Invoice invoice = buildInvoiceWithExternalCharge(itemId, new BigDecimal("25")); + + pluginApi.getAdditionalInvoiceItems(invoice, true, List.of(), invoiceContext); + + final LoggedRequest request = wireMockServer.findAll(postRequestedFor(urlPathEqualTo("/killbill/tax/estimate"))) + .get(0); + final String signature = request.getHeader("X-Killbill-Kintsugi-Signature"); + Assert.assertNotNull(signature); + Assert.assertTrue(signature.matches("[a-f0-9]{64}")); + Assert.assertFalse(request.getBodyAsString().isEmpty()); + } + + @Test(groups = "fast") + public void testReInvoiceSkipsApiWhenTaxItemsAlreadyPresent() throws Exception { + final UUID chargeId = UUID.randomUUID(); + final Invoice invoice = TestUtils.buildInvoice(account); + final List invoiceItems = new LinkedList<>(); + Mockito.when(invoice.getInvoiceItems()).thenReturn(invoiceItems); + + final InvoiceItem charge = TestUtils.buildInvoiceItem( + invoice, InvoiceItemType.EXTERNAL_CHARGE, new BigDecimal("100"), null); + Mockito.when(charge.getId()).thenReturn(chargeId); + invoiceItems.add(charge); + + final InvoiceItem tax = TestUtils.buildInvoiceItem( + invoice, InvoiceItemType.TAX, new BigDecimal("8.25"), null); + Mockito.when(tax.getId()).thenReturn(UUID.randomUUID()); + Mockito.when(tax.getLinkedItemId()).thenReturn(chargeId); + invoiceItems.add(tax); + + final List result = pluginApi.getAdditionalInvoiceItems( + invoice, true, List.of(), invoiceContext).getAdditionalItems(); + + Assert.assertTrue(result.isEmpty()); + wireMockServer.verify(0, postRequestedFor(urlPathEqualTo("/killbill/tax/estimate"))); + wireMockServer.verify(0, postRequestedFor(urlPathEqualTo("/killbill/tax/commit"))); + } + + private void configurePlugin(final String kintsugiUrl) { + final Properties properties = new Properties(); + properties.setProperty("kintsugiUrl", kintsugiUrl); + properties.setProperty("hmacSecret", HMAC_SECRET); + configurationHandler.setDefaultConfigurable(configurationHandler.createConfigurable(properties)); + } + + private String wireMockBaseUrl() { + return "http://localhost:" + wireMockServer.port(); + } + + private Invoice buildInvoiceWithExternalCharge(final UUID itemId, final BigDecimal amount) { + final Invoice invoice = TestUtils.buildInvoice(account); + final List invoiceItems = new LinkedList<>(); + Mockito.when(invoice.getInvoiceItems()).thenReturn(invoiceItems); + + final InvoiceItem item = TestUtils.buildInvoiceItem( + invoice, InvoiceItemType.EXTERNAL_CHARGE, amount, null); + Mockito.when(item.getId()).thenReturn(itemId); + invoiceItems.add(item); + return invoice; + } + + private void stubTaxResponse(final UUID itemId, final String taxAmount, final String path) { + final String body = "{\"documents\":[{\"line_items\":[{\"external_id\":\"" + + itemId + "\",\"tax_amount\":\"" + taxAmount + "\",\"rate\":\"8.25\"}]}]}"; + wireMockServer.stubFor(post(urlPathEqualTo(path)).willReturn(aResponse().withStatus(200).withBody(body))); + } +} diff --git a/src/test/java/com/trykintsugi/killbill/internal/TestInvoiceRequestMapper.java b/src/test/java/com/trykintsugi/killbill/internal/TestInvoiceRequestMapper.java new file mode 100644 index 0000000..481fca2 --- /dev/null +++ b/src/test/java/com/trykintsugi/killbill/internal/TestInvoiceRequestMapper.java @@ -0,0 +1,274 @@ +/* + * Copyright 2026 Kintsugi Technologies, Inc. + * + * 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. + */ + +package com.trykintsugi.killbill.internal; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.joda.time.LocalDate; +import org.killbill.billing.account.api.Account; +import org.killbill.billing.catalog.api.Currency; +import org.killbill.billing.invoice.api.Invoice; +import org.killbill.billing.invoice.api.InvoiceItem; +import org.killbill.billing.invoice.api.InvoiceItemType; +import org.mockito.Mockito; +import org.testng.Assert; +import org.testng.annotations.Test; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +public class TestInvoiceRequestMapper { + + @Test(groups = "fast") + public void testFormatAmountUsesTwoDecimalPlaces() { + Assert.assertEquals(InvoiceRequestMapper.formatAmount(new BigDecimal("100")), "100.00"); + Assert.assertEquals(InvoiceRequestMapper.formatAmount(new BigDecimal("99.994")), "99.99"); + Assert.assertEquals(InvoiceRequestMapper.formatAmount(null), "0.00"); + } + + @Test(groups = "fast") + public void testFormatQuantityDefaultsToOne() { + Assert.assertEquals(InvoiceRequestMapper.formatQuantity(null), "1"); + Assert.assertEquals(InvoiceRequestMapper.formatQuantity(new BigDecimal("2.0")), "2"); + } + + @Test(groups = "fast") + public void testExternalChargeUsesDefaultProductLabels() { + Assert.assertEquals(InvoiceRequestMapper.EXTERNAL_CHARGE_CATEGORY, "Physical"); + Assert.assertEquals(InvoiceRequestMapper.EXTERNAL_CHARGE_SUBCATEGORY, "General Physical"); + } + + @Test(groups = "fast") + public void testToEstimateRequestMapsInvoiceFields() { + final UUID accountId = UUID.randomUUID(); + final UUID invoiceId = UUID.randomUUID(); + final UUID itemId = UUID.randomUUID(); + + final Account account = Mockito.mock(Account.class); + Mockito.when(account.getId()).thenReturn(accountId); + Mockito.when(account.getExternalKey()).thenReturn("acct-ext"); + Mockito.when(account.getCountry()).thenReturn("US"); + Mockito.when(account.getPostalCode()).thenReturn("94105"); + Mockito.when(account.getStateOrProvince()).thenReturn("CA"); + Mockito.when(account.getCity()).thenReturn("San Francisco"); + Mockito.when(account.getAddress1()).thenReturn("1 Market St"); + Mockito.when(account.getEmail()).thenReturn("billing@example.com"); + Mockito.when(account.getName()).thenReturn("Example Inc"); + + final InvoiceItem item = Mockito.mock(InvoiceItem.class); + Mockito.when(item.getId()).thenReturn(itemId); + Mockito.when(item.getAmount()).thenReturn(new BigDecimal("100.00")); + Mockito.when(item.getQuantity()).thenReturn(BigDecimal.ONE); + Mockito.when(item.getDescription()).thenReturn("Widget"); + Mockito.when(item.getInvoiceItemType()).thenReturn(InvoiceItemType.EXTERNAL_CHARGE); + Mockito.when(item.getPlanName()).thenReturn(null); + Mockito.when(item.getPrettyProductName()).thenReturn(null); + + final Invoice invoice = Mockito.mock(Invoice.class); + Mockito.when(invoice.getId()).thenReturn(invoiceId); + Mockito.when(invoice.getAccountId()).thenReturn(accountId); + Mockito.when(invoice.getCurrency()).thenReturn(Currency.USD); + Mockito.when(invoice.getInvoiceDate()).thenReturn(new LocalDate(2026, 1, 15)); + Mockito.when(invoice.getInvoiceNumber()).thenReturn(42); + Mockito.when(invoice.getInvoiceItems()).thenReturn(List.of(item)); + + final ObjectNode request = InvoiceRequestMapper.toEstimateRequest( + invoice, account, true, "tenant-1"); + + Assert.assertEquals(request.path("currency_code").asText(), "USD"); + Assert.assertEquals(request.path("plugin_name").asText(), "killbill-kintsugi"); + Assert.assertEquals(request.path("tenant_id").asText(), "tenant-1"); + + final ObjectNode document = (ObjectNode) request.path("documents").get(0); + Assert.assertTrue(document.path("dry_run").asBoolean()); + Assert.assertEquals(document.path("transaction_date").asText(), "2026-01-15"); + Assert.assertEquals(document.path("invoice_number").asInt(), 42); + + final ObjectNode line = (ObjectNode) document.path("line_items").get(0); + Assert.assertEquals(line.path("external_id").asText(), itemId.toString()); + Assert.assertEquals(line.path("product_category").asText(), "Physical"); + Assert.assertEquals(line.path("product_subcategory").asText(), "General Physical"); + } + + @Test(groups = "fast") + public void testSkipsTaxAndAdjustmentItems() { + final UUID accountId = UUID.randomUUID(); + final UUID invoiceId = UUID.randomUUID(); + final UUID taxableItemId = UUID.randomUUID(); + + final Account account = Mockito.mock(Account.class); + Mockito.when(account.getId()).thenReturn(accountId); + Mockito.when(account.getExternalKey()).thenReturn("acct-ext"); + + final InvoiceItem taxableItem = Mockito.mock(InvoiceItem.class); + Mockito.when(taxableItem.getId()).thenReturn(taxableItemId); + Mockito.when(taxableItem.getAmount()).thenReturn(new BigDecimal("100.00")); + Mockito.when(taxableItem.getQuantity()).thenReturn(BigDecimal.ONE); + Mockito.when(taxableItem.getInvoiceItemType()).thenReturn(InvoiceItemType.EXTERNAL_CHARGE); + + final InvoiceItem taxItem = Mockito.mock(InvoiceItem.class); + Mockito.when(taxItem.getId()).thenReturn(UUID.randomUUID()); + Mockito.when(taxItem.getAmount()).thenReturn(new BigDecimal("8.25")); + Mockito.when(taxItem.getInvoiceItemType()).thenReturn(InvoiceItemType.TAX); + + final InvoiceItem adjustmentItem = Mockito.mock(InvoiceItem.class); + Mockito.when(adjustmentItem.getId()).thenReturn(UUID.randomUUID()); + Mockito.when(adjustmentItem.getAmount()).thenReturn(new BigDecimal("-10.00")); + Mockito.when(adjustmentItem.getInvoiceItemType()).thenReturn(InvoiceItemType.ITEM_ADJ); + + final Invoice invoice = Mockito.mock(Invoice.class); + Mockito.when(invoice.getId()).thenReturn(invoiceId); + Mockito.when(invoice.getAccountId()).thenReturn(accountId); + Mockito.when(invoice.getCurrency()).thenReturn(Currency.USD); + Mockito.when(invoice.getInvoiceDate()).thenReturn(new LocalDate(2026, 1, 15)); + Mockito.when(invoice.getInvoiceItems()).thenReturn(List.of(taxableItem, taxItem, adjustmentItem)); + + final ObjectNode request = InvoiceRequestMapper.toEstimateRequest( + invoice, account, true, null); + final ObjectNode document = (ObjectNode) request.path("documents").get(0); + + Assert.assertEquals(document.path("line_items").size(), 1); + Assert.assertEquals( + document.path("line_items").get(0).path("external_id").asText(), + taxableItemId.toString()); + + final Map externalIds = InvoiceRequestMapper.externalIdsForInvoice(invoice); + Assert.assertEquals(externalIds.size(), 1); + Assert.assertEquals(externalIds.get(taxableItemId), taxableItemId.toString()); + } + + @Test(groups = "fast") + public void testPlanNameIsMappedToProductFields() { + final UUID accountId = UUID.randomUUID(); + final UUID invoiceId = UUID.randomUUID(); + final UUID itemId = UUID.randomUUID(); + + final Account account = Mockito.mock(Account.class); + Mockito.when(account.getId()).thenReturn(accountId); + Mockito.when(account.getExternalKey()).thenReturn("acct-ext"); + + final InvoiceItem item = Mockito.mock(InvoiceItem.class); + Mockito.when(item.getId()).thenReturn(itemId); + Mockito.when(item.getAmount()).thenReturn(new BigDecimal("10.00")); + Mockito.when(item.getQuantity()).thenReturn(BigDecimal.ONE); + Mockito.when(item.getInvoiceItemType()).thenReturn(InvoiceItemType.RECURRING); + Mockito.when(item.getPlanName()).thenReturn("basic-monthly"); + Mockito.when(item.getPrettyProductName()).thenReturn("Basic"); + + final Invoice invoice = Mockito.mock(Invoice.class); + Mockito.when(invoice.getId()).thenReturn(invoiceId); + Mockito.when(invoice.getAccountId()).thenReturn(accountId); + Mockito.when(invoice.getCurrency()).thenReturn(Currency.USD); + Mockito.when(invoice.getInvoiceDate()).thenReturn(new LocalDate(2026, 1, 15)); + Mockito.when(invoice.getInvoiceItems()).thenReturn(List.of(item)); + + final ObjectNode request = InvoiceRequestMapper.toEstimateRequest( + invoice, account, false, null); + final ObjectNode line = (ObjectNode) request.path("documents").get(0).path("line_items").get(0); + + Assert.assertEquals(line.path("plan_name").asText(), "basic-monthly"); + Assert.assertEquals(line.path("external_product_id").asText(), "basic-monthly"); + Assert.assertEquals(line.path("product_name").asText(), "Basic"); + Assert.assertFalse(line.has("product_category")); + } + + @Test(groups = "fast") + public void testTaxMetadataEnrichesCustomerAndLines() { + final UUID accountId = UUID.randomUUID(); + final UUID invoiceId = UUID.randomUUID(); + final UUID itemId = UUID.randomUUID(); + + final Account account = Mockito.mock(Account.class); + Mockito.when(account.getId()).thenReturn(accountId); + Mockito.when(account.getExternalKey()).thenReturn("acct-ext"); + + final InvoiceItem item = Mockito.mock(InvoiceItem.class); + Mockito.when(item.getId()).thenReturn(itemId); + Mockito.when(item.getAmount()).thenReturn(new BigDecimal("10.00")); + Mockito.when(item.getQuantity()).thenReturn(BigDecimal.ONE); + Mockito.when(item.getInvoiceItemType()).thenReturn(InvoiceItemType.EXTERNAL_CHARGE); + + final Invoice invoice = Mockito.mock(Invoice.class); + Mockito.when(invoice.getId()).thenReturn(invoiceId); + Mockito.when(invoice.getAccountId()).thenReturn(accountId); + Mockito.when(invoice.getCurrency()).thenReturn(Currency.USD); + Mockito.when(invoice.getInvoiceDate()).thenReturn(new LocalDate(2026, 1, 15)); + Mockito.when(invoice.getInvoiceItems()).thenReturn(List.of(item)); + + final AccountTaxMetadata metadata = AccountTaxMetadata.builder() + .taxExempt(true) + .customerUsageType("G") + .taxCodeByInvoiceItemId(Map.of(itemId, "P0000000")) + .build(); + + final ObjectNode request = InvoiceRequestMapper.toEstimateRequest( + invoice, account, true, null, metadata); + final ObjectNode document = (ObjectNode) request.path("documents").get(0); + final ObjectNode customer = (ObjectNode) document.path("customer"); + + Assert.assertTrue(customer.path("exempt").asBoolean()); + Assert.assertEquals(customer.path("entity_use_code").asText(), "G"); + Assert.assertEquals( + document.path("line_items").get(0).path("tax_code").asText(), + "P0000000"); + } + + @Test(groups = "fast") + public void testAviateShipToAddressOverridesAccountAddress() { + final UUID accountId = UUID.randomUUID(); + final UUID invoiceId = UUID.randomUUID(); + final UUID itemId = UUID.randomUUID(); + + final Account account = Mockito.mock(Account.class); + Mockito.when(account.getId()).thenReturn(accountId); + Mockito.when(account.getExternalKey()).thenReturn("acct-ext"); + Mockito.when(account.getCountry()).thenReturn("US"); + Mockito.when(account.getPostalCode()).thenReturn("78701"); + + final InvoiceItem item = Mockito.mock(InvoiceItem.class); + Mockito.when(item.getId()).thenReturn(itemId); + Mockito.when(item.getAmount()).thenReturn(new BigDecimal("10.00")); + Mockito.when(item.getQuantity()).thenReturn(BigDecimal.ONE); + Mockito.when(item.getInvoiceItemType()).thenReturn(InvoiceItemType.EXTERNAL_CHARGE); + + final Invoice invoice = Mockito.mock(Invoice.class); + Mockito.when(invoice.getId()).thenReturn(invoiceId); + Mockito.when(invoice.getAccountId()).thenReturn(accountId); + Mockito.when(invoice.getCurrency()).thenReturn(Currency.USD); + Mockito.when(invoice.getInvoiceDate()).thenReturn(new LocalDate(2026, 1, 15)); + Mockito.when(invoice.getInvoiceItems()).thenReturn(List.of(item)); + + final AccountTaxMetadata metadata = AccountTaxMetadata.builder() + .source(AccountTaxMetadata.Source.AVIATE_BILLING_ACCOUNT) + .companyName("CloudSprout Inc.") + .taxRegistrationNumber("12-3456789") + .shipToAddress(new TaxAddress( + "100 Main Street", null, "San Francisco", "CA", "US", "94105")) + .build(); + + final ObjectNode request = InvoiceRequestMapper.toEstimateRequest( + invoice, account, true, null, metadata); + final ObjectNode document = (ObjectNode) request.path("documents").get(0); + + Assert.assertEquals(document.path("ship_to").path("postal_code").asText(), "94105"); + Assert.assertEquals(document.path("customer").path("name").asText(), "CloudSprout Inc."); + Assert.assertEquals( + document.path("customer").path("tax_registration_number").asText(), + "12-3456789"); + } +} diff --git a/src/test/java/com/trykintsugi/killbill/internal/TestInvoiceTaxIdempotency.java b/src/test/java/com/trykintsugi/killbill/internal/TestInvoiceTaxIdempotency.java new file mode 100644 index 0000000..f40718f --- /dev/null +++ b/src/test/java/com/trykintsugi/killbill/internal/TestInvoiceTaxIdempotency.java @@ -0,0 +1,113 @@ +/* + * Copyright 2026 Kintsugi Technologies, Inc. + * + * 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. + */ + +package com.trykintsugi.killbill.internal; + +import org.killbill.billing.invoice.api.Invoice; +import org.killbill.billing.invoice.api.InvoiceItem; +import org.killbill.billing.invoice.api.InvoiceItemType; +import org.mockito.Mockito; +import org.testng.Assert; +import org.testng.annotations.Test; + +import java.math.BigDecimal; +import java.util.List; +import java.util.UUID; + +public class TestInvoiceTaxIdempotency { + + @Test(groups = "fast") + public void testReturnsFalseWhenNoTaxItems() { + final UUID itemId = UUID.randomUUID(); + final InvoiceItem charge = chargeItem(itemId, new BigDecimal("100")); + final Invoice invoice = invoiceWithItems(charge); + + Assert.assertFalse(InvoiceTaxIdempotency.allTaxableItemsAlreadyTaxed(invoice)); + } + + @Test(groups = "fast") + public void testReturnsTrueWhenAllTaxableLinesHaveTaxItems() { + final UUID chargeId = UUID.randomUUID(); + final InvoiceItem charge = chargeItem(chargeId, new BigDecimal("100")); + final InvoiceItem tax = taxItem(chargeId, new BigDecimal("8.25")); + final Invoice invoice = invoiceWithItems(charge, tax); + + Assert.assertTrue(InvoiceTaxIdempotency.allTaxableItemsAlreadyTaxed(invoice)); + } + + @Test(groups = "fast") + public void testReturnsFalseWhenOnlySomeLinesTaxed() { + final UUID chargeId1 = UUID.randomUUID(); + final UUID chargeId2 = UUID.randomUUID(); + final InvoiceItem charge1 = chargeItem(chargeId1, new BigDecimal("100")); + final InvoiceItem charge2 = chargeItem(chargeId2, new BigDecimal("50")); + final InvoiceItem tax = taxItem(chargeId1, new BigDecimal("8.25")); + final Invoice invoice = invoiceWithItems(charge1, charge2, tax); + + Assert.assertFalse(InvoiceTaxIdempotency.allTaxableItemsAlreadyTaxed(invoice)); + } + + @Test(groups = "fast") + public void testIgnoresAdjustmentItemsWhenCheckingCoverage() { + final UUID chargeId = UUID.randomUUID(); + final InvoiceItem charge = chargeItem(chargeId, new BigDecimal("100")); + final InvoiceItem tax = taxItem(chargeId, new BigDecimal("8.25")); + + final InvoiceItem adjustment = Mockito.mock(InvoiceItem.class); + Mockito.when(adjustment.getId()).thenReturn(UUID.randomUUID()); + Mockito.when(adjustment.getAmount()).thenReturn(new BigDecimal("-10")); + Mockito.when(adjustment.getInvoiceItemType()).thenReturn(InvoiceItemType.ITEM_ADJ); + + final Invoice invoice = invoiceWithItems(charge, tax, adjustment); + + Assert.assertTrue(InvoiceTaxIdempotency.allTaxableItemsAlreadyTaxed(invoice)); + } + + @Test(groups = "fast") + public void testReturnsFalseWhenTaxableLineHasNullId() { + final InvoiceItem chargeWithoutId = Mockito.mock(InvoiceItem.class); + Mockito.when(chargeWithoutId.getId()).thenReturn(null); + Mockito.when(chargeWithoutId.getAmount()).thenReturn(new BigDecimal("100")); + Mockito.when(chargeWithoutId.getInvoiceItemType()).thenReturn(InvoiceItemType.EXTERNAL_CHARGE); + + final Invoice invoice = invoiceWithItems(chargeWithoutId); + + Assert.assertFalse(InvoiceTaxIdempotency.allTaxableItemsAlreadyTaxed(invoice)); + } + + private static Invoice invoiceWithItems(final InvoiceItem... items) { + final Invoice invoice = Mockito.mock(Invoice.class); + Mockito.when(invoice.getInvoiceItems()).thenReturn(List.of(items)); + return invoice; + } + + private static InvoiceItem chargeItem(final UUID id, final BigDecimal amount) { + final InvoiceItem item = Mockito.mock(InvoiceItem.class); + Mockito.when(item.getId()).thenReturn(id); + Mockito.when(item.getAmount()).thenReturn(amount); + Mockito.when(item.getInvoiceItemType()).thenReturn(InvoiceItemType.EXTERNAL_CHARGE); + return item; + } + + private static InvoiceItem taxItem(final UUID linkedId, final BigDecimal amount) { + final InvoiceItem item = Mockito.mock(InvoiceItem.class); + Mockito.when(item.getId()).thenReturn(UUID.randomUUID()); + Mockito.when(item.getLinkedItemId()).thenReturn(linkedId); + Mockito.when(item.getAmount()).thenReturn(amount); + Mockito.when(item.getInvoiceItemType()).thenReturn(InvoiceItemType.TAX); + return item; + } +} diff --git a/src/test/java/com/trykintsugi/killbill/internal/TestKintsugiTaxClient.java b/src/test/java/com/trykintsugi/killbill/internal/TestKintsugiTaxClient.java new file mode 100644 index 0000000..36371b3 --- /dev/null +++ b/src/test/java/com/trykintsugi/killbill/internal/TestKintsugiTaxClient.java @@ -0,0 +1,173 @@ +/* + * Copyright 2026 Kintsugi Technologies, Inc. + * + * 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. + */ + +package com.trykintsugi.killbill.internal; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.github.tomakehurst.wiremock.WireMockServer; +import org.testng.Assert; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import java.math.BigDecimal; +import java.util.List; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; + +public class TestKintsugiTaxClient { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private WireMockServer wireMockServer; + + @BeforeMethod(groups = "fast") + public void setUpWireMock() { + wireMockServer = new WireMockServer(wireMockConfig().dynamicPort()); + wireMockServer.start(); + } + + @AfterMethod(groups = "fast") + public void tearDownWireMock() { + if (wireMockServer != null) { + wireMockServer.stop(); + } + } + + @Test(groups = "fast") + public void testBuildEstimateRequestIncludesDocumentFields() throws Exception { + final ArrayNode lineItems = MAPPER.createArrayNode(); + final ObjectNode line = MAPPER.createObjectNode(); + line.put("external_id", "line-1"); + line.put("amount", "100.00"); + lineItems.add(line); + final ObjectNode shipTo = MAPPER.createObjectNode(); + shipTo.put("country", "US"); + + final ObjectNode billTo = MAPPER.createObjectNode(); + billTo.put("country", "US"); + final ObjectNode customer = MAPPER.createObjectNode(); + customer.put("external_id", "acc-1"); + + final ObjectNode root = KintsugiTaxClient.buildEstimateRequest( + "req-1", "USD", "inv-1", "acc-1", true, lineItems, shipTo, billTo, customer, + "2026-01-15T00:00:00.000Z"); + final ObjectNode document = (ObjectNode) root.path("documents").get(0); + Assert.assertEquals(document.path("id").asText(), "inv-1"); + Assert.assertEquals(document.path("account_id").asText(), "acc-1"); + Assert.assertTrue(document.path("dry_run").asBoolean()); + Assert.assertEquals(document.path("customer").path("external_id").asText(), "acc-1"); + } + + @Test(groups = "fast") + public void testParseTaxLinesMapsResponse() throws Exception { + final String json = "{\"documents\":[{\"line_items\":[" + + "{\"external_id\":\"line-1\",\"tax_amount\":\"8.25\",\"rate\":\"8.25\"}" + + "]}]}"; + final List lines = KintsugiTaxClient.parseTaxLines(json); + Assert.assertEquals(lines.size(), 1); + Assert.assertEquals(lines.get(0).lineExternalId(), "line-1"); + Assert.assertEquals(lines.get(0).taxAmount(), new BigDecimal("8.25")); + } + + @Test(groups = "fast") + public void testParseTaxLinesReturnsEmptyForMissingDocuments() throws Exception { + Assert.assertTrue(KintsugiTaxClient.parseTaxLines("{}").isEmpty()); + Assert.assertTrue(KintsugiTaxClient.parseTaxLines("{\"documents\":null}").isEmpty()); + } + + @Test(groups = "fast") + public void testHmacIsDeterministic() throws Exception { + final byte[] body = "{\"id\":\"x\"}".getBytes(); + final String sig1 = KintsugiTaxClient.hmacSha256Hex("secret", body); + final String sig2 = KintsugiTaxClient.hmacSha256Hex("secret", body); + Assert.assertEquals(sig1, sig2); + Assert.assertFalse(sig1.isEmpty()); + } + + @Test(groups = "fast") + public void testEstimateSendsSignedRequest() throws Exception { + final String responseBody = "{\"documents\":[{\"line_items\":[" + + "{\"external_id\":\"line-1\",\"tax_amount\":\"1.00\",\"rate\":\"8.25\"}" + + "]}]}"; + wireMockServer.stubFor(post(urlPathEqualTo("/killbill/tax/estimate")) + .willReturn(aResponse().withStatus(200).withBody(responseBody))); + + final ObjectNode requestBody = MAPPER.createObjectNode(); + requestBody.put("id", "req-1"); + final byte[] bodyBytes = MAPPER.writeValueAsBytes(requestBody); + + final KintsugiTaxClient client = newClient(); + final List lines = client.estimate(requestBody, false); + + Assert.assertEquals(lines.size(), 1); + wireMockServer.verify(postRequestedFor(urlPathEqualTo("/killbill/tax/estimate")) + .withHeader("X-Killbill-ApiKey", equalTo("tenant-key")) + .withHeader( + "X-Killbill-Kintsugi-Signature", + equalTo(KintsugiTaxClient.hmacSha256Hex("hmac-secret", bodyBytes)))); + } + + @Test(groups = "fast") + public void testCommitUsesCommitPath() throws Exception { + wireMockServer.stubFor(post(urlPathEqualTo("/killbill/tax/commit")) + .willReturn(aResponse().withStatus(200).withBody("{\"documents\":[]}"))); + + final ObjectNode requestBody = MAPPER.createObjectNode(); + requestBody.put("id", "req-2"); + + newClient().estimate(requestBody, true); + + wireMockServer.verify(postRequestedFor(urlPathEqualTo("/killbill/tax/commit"))); + } + + @Test(groups = "fast", expectedExceptions = IllegalStateException.class) + public void testEstimateThrowsOnHttpError() throws Exception { + wireMockServer.stubFor(post(urlPathEqualTo("/killbill/tax/estimate")) + .willReturn(aResponse().withStatus(401).withBody("{\"error\":\"unauthorized\"}"))); + + newClient().estimate(MAPPER.createObjectNode(), false); + } + + @Test(groups = "fast") + public void testStripsTrailingSlashFromBaseUrl() throws Exception { + wireMockServer.stubFor(post(urlPathEqualTo("/killbill/tax/estimate")) + .willReturn(aResponse().withStatus(200).withBody("{\"documents\":[]}"))); + + final KintsugiTaxClient client = new KintsugiTaxClient( + wireMockBaseUrl() + "/", + "hmac-secret", + "tenant-key"); + client.estimate(MAPPER.createObjectNode(), false); + + wireMockServer.verify(postRequestedFor(urlPathEqualTo("/killbill/tax/estimate"))); + } + + private KintsugiTaxClient newClient() { + return new KintsugiTaxClient(wireMockBaseUrl(), "hmac-secret", "tenant-key"); + } + + private String wireMockBaseUrl() { + return "http://localhost:" + wireMockServer.port(); + } +} diff --git a/src/test/java/com/trykintsugi/killbill/internal/TestTaxItemMapper.java b/src/test/java/com/trykintsugi/killbill/internal/TestTaxItemMapper.java new file mode 100644 index 0000000..9ae28a0 --- /dev/null +++ b/src/test/java/com/trykintsugi/killbill/internal/TestTaxItemMapper.java @@ -0,0 +1,103 @@ +/* + * Copyright 2026 Kintsugi Technologies, Inc. + * + * 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. + */ + +package com.trykintsugi.killbill.internal; + +import org.joda.time.LocalDate; +import org.killbill.billing.invoice.api.Invoice; +import org.killbill.billing.invoice.api.InvoiceItem; +import org.killbill.billing.invoice.api.InvoiceItemType; +import org.mockito.Mockito; +import org.testng.Assert; +import org.testng.annotations.Test; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +public class TestTaxItemMapper { + + @Test(groups = "fast") + public void testToTaxItemsSkipsZeroTaxLines() { + final UUID invoiceId = UUID.randomUUID(); + final UUID itemId = UUID.randomUUID(); + + final Invoice invoice = Mockito.mock(Invoice.class); + Mockito.when(invoice.getId()).thenReturn(invoiceId); + Mockito.when(invoice.getInvoiceDate()).thenReturn(new LocalDate(2026, 1, 15)); + + final InvoiceItem taxableItem = Mockito.mock(InvoiceItem.class); + Mockito.when(taxableItem.getId()).thenReturn(itemId); + Mockito.when(taxableItem.getInvoiceItemType()).thenReturn(InvoiceItemType.EXTERNAL_CHARGE); + Mockito.when(taxableItem.getAmount()).thenReturn(new BigDecimal("100.00")); + Mockito.when(invoice.getInvoiceItems()).thenReturn(List.of(taxableItem)); + + final List taxLines = List.of( + new KintsugiTaxClient.TaxLineResult(itemId.toString(), BigDecimal.ZERO, BigDecimal.ZERO), + new KintsugiTaxClient.TaxLineResult( + itemId.toString(), new BigDecimal("8.25"), new BigDecimal("8.25"))); + + final Map taxableItems = TaxItemMapper.indexTaxableItems(invoice); + final List taxItems = TaxItemMapper.toTaxItems(invoice, taxLines, taxableItems); + + Assert.assertEquals(taxItems.size(), 1); + Assert.assertEquals(taxItems.get(0).getInvoiceItemType(), InvoiceItemType.TAX); + Assert.assertEquals(taxItems.get(0).getAmount(), new BigDecimal("8.25")); + Assert.assertTrue(taxItems.get(0).getDescription().contains("8.25")); + } + + @Test(groups = "fast") + public void testToTaxItemsSkipsUnmatchedExternalIds() { + final UUID invoiceId = UUID.randomUUID(); + final UUID itemId = UUID.randomUUID(); + + final Invoice invoice = Mockito.mock(Invoice.class); + Mockito.when(invoice.getId()).thenReturn(invoiceId); + Mockito.when(invoice.getInvoiceDate()).thenReturn(new LocalDate(2026, 1, 15)); + + final InvoiceItem taxableItem = Mockito.mock(InvoiceItem.class); + Mockito.when(taxableItem.getId()).thenReturn(itemId); + Mockito.when(invoice.getInvoiceItems()).thenReturn(List.of(taxableItem)); + + final List taxLines = List.of( + new KintsugiTaxClient.TaxLineResult( + UUID.randomUUID().toString(), new BigDecimal("8.25"), new BigDecimal("8.25")), + new KintsugiTaxClient.TaxLineResult("not-a-uuid", new BigDecimal("1.00"), new BigDecimal("1.00"))); + + final List taxItems = TaxItemMapper.toTaxItems( + invoice, taxLines, TaxItemMapper.indexTaxableItems(invoice)); + + Assert.assertTrue(taxItems.isEmpty()); + } + + @Test(groups = "fast") + public void testIndexTaxableItemsIgnoresNullIds() { + final Invoice invoice = Mockito.mock(Invoice.class); + final InvoiceItem withId = Mockito.mock(InvoiceItem.class); + final InvoiceItem withoutId = Mockito.mock(InvoiceItem.class); + final UUID itemId = UUID.randomUUID(); + + Mockito.when(withId.getId()).thenReturn(itemId); + Mockito.when(withoutId.getId()).thenReturn(null); + Mockito.when(invoice.getInvoiceItems()).thenReturn(List.of(withId, withoutId)); + + final Map indexed = TaxItemMapper.indexTaxableItems(invoice); + + Assert.assertEquals(indexed.size(), 1); + Assert.assertSame(indexed.get(itemId), withId); + } +} diff --git a/src/test/java/com/trykintsugi/killbill/internal/TestTaxMetadataResolver.java b/src/test/java/com/trykintsugi/killbill/internal/TestTaxMetadataResolver.java new file mode 100644 index 0000000..f23911a --- /dev/null +++ b/src/test/java/com/trykintsugi/killbill/internal/TestTaxMetadataResolver.java @@ -0,0 +1,408 @@ +/* + * Copyright 2026 Kintsugi Technologies, Inc. + * + * 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. + */ + +package com.trykintsugi.killbill.internal; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.tomakehurst.wiremock.WireMockServer; +import com.trykintsugi.killbill.KintsugiTenantConfig; +import org.killbill.billing.account.api.Account; +import org.killbill.billing.ObjectType; +import org.killbill.billing.invoice.api.Invoice; +import org.killbill.billing.invoice.api.InvoiceItem; +import org.killbill.billing.invoice.api.InvoiceItemType; +import org.killbill.billing.osgi.libs.killbill.OSGIKillbillAPI; +import org.killbill.billing.payment.api.PluginProperty; +import org.killbill.billing.plugin.api.PluginCallContext; +import org.killbill.billing.util.callcontext.TenantContext; +import org.killbill.billing.util.customfield.CustomField; +import org.killbill.billing.util.api.CustomFieldUserApi; +import org.killbill.clock.DefaultClock; +import org.mockito.Mockito; +import org.testng.Assert; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import java.math.BigDecimal; +import java.util.List; +import java.util.UUID; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; + +public class TestTaxMetadataResolver { + + private static final String TENANT_API_KEY = "tenant-api-key"; + private static final String TENANT_API_SECRET = "tenant-api-secret"; + private static final String AVIATE_TOKEN = "aviate-jwt"; + + private WireMockServer wireMockServer; + + @BeforeMethod(groups = "fast") + public void setUp() { + wireMockServer = new WireMockServer(wireMockConfig().dynamicPort()); + wireMockServer.start(); + } + + @AfterMethod(groups = "fast") + public void tearDown() { + if (wireMockServer != null) { + wireMockServer.stop(); + } + } + + @Test(groups = "fast") + public void testResolvesAccountCustomFieldsWithoutAviate() throws Exception { + final UUID accountId = UUID.randomUUID(); + final UUID tenantId = UUID.randomUUID(); + final UUID itemId = UUID.randomUUID(); + + final Invoice invoice = Mockito.mock(Invoice.class); + Mockito.when(invoice.getAccountId()).thenReturn(accountId); + final InvoiceItem item = Mockito.mock(InvoiceItem.class); + Mockito.when(item.getId()).thenReturn(itemId); + Mockito.when(item.getInvoiceItemType()).thenReturn(InvoiceItemType.EXTERNAL_CHARGE); + Mockito.when(item.getAmount()).thenReturn(new BigDecimal("10")); + Mockito.when(invoice.getInvoiceItems()).thenReturn(List.of(item)); + + final CustomField usageField = customField(accountId, ObjectType.ACCOUNT, TaxMetadataResolver.CUSTOMER_USAGE_TYPE, "G"); + final CustomField exemptField = customField(accountId, ObjectType.ACCOUNT, TaxMetadataResolver.TAX_EXEMPT, "true"); + final CustomField taxCodeField = customField(itemId, ObjectType.INVOICE_ITEM, TaxMetadataResolver.TAX_CODE, "P0000000"); + + final OSGIKillbillAPI killbillAPI = mockCustomFields( + accountId, tenantId, List.of(usageField, exemptField), List.of(taxCodeField)); + final TaxMetadataResolver resolver = new TaxMetadataResolver(killbillAPI); + final KintsugiTenantConfig config = baseConfig(); + + final AccountTaxMetadata metadata = resolver.resolve( + invoice, + Mockito.mock(Account.class), + List.of(), + tenantContext(tenantId), + config, + TENANT_API_KEY, + TENANT_API_SECRET); + + Assert.assertEquals(metadata.getSource(), AccountTaxMetadata.Source.CUSTOM_FIELDS); + Assert.assertTrue(metadata.isTaxExempt()); + Assert.assertEquals(metadata.getCustomerUsageType(), "G"); + Assert.assertEquals(metadata.taxCodeForItem(itemId), "P0000000"); + } + + @Test(groups = "fast") + public void testPluginPropertiesOverrideAccountFields() throws Exception { + final UUID accountId = UUID.randomUUID(); + final UUID tenantId = UUID.randomUUID(); + + final Invoice invoice = Mockito.mock(Invoice.class); + Mockito.when(invoice.getAccountId()).thenReturn(accountId); + Mockito.when(invoice.getInvoiceItems()).thenReturn(List.of()); + + final OSGIKillbillAPI killbillAPI = Mockito.mock(OSGIKillbillAPI.class); + final CustomFieldUserApi customFieldUserApi = Mockito.mock(CustomFieldUserApi.class); + Mockito.when(killbillAPI.getCustomFieldUserApi()).thenReturn(customFieldUserApi); + + final List properties = List.of( + new PluginProperty(TaxMetadataResolver.CUSTOMER_USAGE_TYPE, "A", false), + new PluginProperty(TaxMetadataResolver.TAX_EXEMPT, "true", false)); + + final TaxMetadataResolver resolver = new TaxMetadataResolver(killbillAPI); + final AccountTaxMetadata metadata = resolver.resolve( + invoice, + Mockito.mock(Account.class), + properties, + tenantContext(tenantId), + baseConfig(), + TENANT_API_KEY, + TENANT_API_SECRET); + + Assert.assertTrue(metadata.isTaxExempt()); + Assert.assertEquals(metadata.getCustomerUsageType(), "A"); + Mockito.verify(customFieldUserApi, Mockito.never()) + .getCustomFieldsForObject(Mockito.any(), Mockito.any(), Mockito.any()); + } + + @Test(groups = "fast") + public void testPluginPropertyTaxCodeOverridesCustomField() throws Exception { + final UUID accountId = UUID.randomUUID(); + final UUID tenantId = UUID.randomUUID(); + final UUID itemId = UUID.randomUUID(); + + final Invoice invoice = Mockito.mock(Invoice.class); + Mockito.when(invoice.getAccountId()).thenReturn(accountId); + final InvoiceItem item = Mockito.mock(InvoiceItem.class); + Mockito.when(item.getId()).thenReturn(itemId); + Mockito.when(invoice.getInvoiceItems()).thenReturn(List.of(item)); + + final CustomField taxCodeField = customField( + itemId, ObjectType.INVOICE_ITEM, TaxMetadataResolver.TAX_CODE, "FROM_FIELD"); + final OSGIKillbillAPI killbillAPI = mockCustomFields( + accountId, tenantId, List.of(), List.of(taxCodeField)); + final TaxMetadataResolver resolver = new TaxMetadataResolver(killbillAPI); + + final List properties = List.of( + new PluginProperty(InvoicePluginPropertyNames.TAX_CODE_PREFIX + itemId, "FROM_PROPERTY", false)); + + final AccountTaxMetadata metadata = resolver.resolve( + invoice, + Mockito.mock(Account.class), + properties, + tenantContext(tenantId), + baseConfig(), + TENANT_API_KEY, + TENANT_API_SECRET); + + Assert.assertEquals(metadata.taxCodeForItem(itemId), "FROM_PROPERTY"); + } + + @Test(groups = "fast") + public void testPluginPropertyShipToSkipsAviateHttpLookup() throws Exception { + final UUID accountId = UUID.randomUUID(); + final UUID tenantId = UUID.randomUUID(); + + final Invoice invoice = Mockito.mock(Invoice.class); + Mockito.when(invoice.getAccountId()).thenReturn(accountId); + Mockito.when(invoice.getInvoiceItems()).thenReturn(List.of()); + + final OSGIKillbillAPI killbillAPI = mockCustomFields(accountId, tenantId, List.of(), List.of()); + final TaxMetadataResolver resolver = new TaxMetadataResolver(killbillAPI); + + final List properties = List.of( + new PluginProperty(InvoicePluginPropertyNames.SHIP_TO_COUNTRY, "US", false), + new PluginProperty(InvoicePluginPropertyNames.SHIP_TO_POSTAL_CODE, "94105", false), + new PluginProperty(InvoicePluginPropertyNames.TAX_REGISTRATION_NUMBER, "12-3456789", false)); + + final AccountTaxMetadata metadata = resolver.resolve( + invoice, + Mockito.mock(Account.class), + properties, + tenantContext(tenantId), + aviateConfig(), + TENANT_API_KEY, + TENANT_API_SECRET); + + Assert.assertNotNull(metadata.getShipToAddress()); + Assert.assertEquals(metadata.getShipToAddress().getCountry(), "US"); + Assert.assertEquals(metadata.getTaxRegistrationNumber(), "12-3456789"); + Assert.assertEquals(metadata.getSource(), AccountTaxMetadata.Source.CUSTOM_FIELDS); + wireMockServer.verify(0, getRequestedFor(urlPathEqualTo( + "/plugins/aviate-plugin/v1/ba/forKbAccountId/" + accountId))); + } + + @Test(groups = "fast") + public void testExplicitTaxExemptFalseOverridesAviateExemption() throws Exception { + final UUID accountId = UUID.randomUUID(); + final UUID tenantId = UUID.randomUUID(); + + final Invoice invoice = Mockito.mock(Invoice.class); + Mockito.when(invoice.getAccountId()).thenReturn(accountId); + Mockito.when(invoice.getInvoiceItems()).thenReturn(List.of()); + + final Account account = Mockito.mock(Account.class); + Mockito.when(account.getCountry()).thenReturn("US"); + + final String body = "{\"taxRegistrations\":[{\"exempt\":true,\"trn\":\"98-7654321\"," + + "\"address\":{\"country\":\"US\",\"postalCode\":\"02101\"}}]}"; + wireMockServer.stubFor(get(urlPathEqualTo("/plugins/aviate-plugin/v1/ba/forKbAccountId/" + accountId)) + .willReturn(aResponse().withStatus(200).withBody(body))); + + final OSGIKillbillAPI killbillAPI = mockCustomFields(accountId, tenantId, List.of(), List.of()); + final TaxMetadataResolver resolver = new TaxMetadataResolver(killbillAPI); + + final List properties = List.of( + new PluginProperty(TaxMetadataResolver.TAX_EXEMPT, "false", false)); + + final AccountTaxMetadata metadata = resolver.resolve( + invoice, account, properties, tenantContext(tenantId), aviateConfig(), + TENANT_API_KEY, TENANT_API_SECRET); + + Assert.assertFalse(metadata.isTaxExempt()); + Assert.assertEquals(metadata.getSource(), AccountTaxMetadata.Source.AVIATE_BILLING_ACCOUNT); + } + + @Test(groups = "fast") + public void testAviateHttpFailureFallsBackToCustomFields() throws Exception { + final UUID accountId = UUID.randomUUID(); + final UUID tenantId = UUID.randomUUID(); + + final Invoice invoice = Mockito.mock(Invoice.class); + Mockito.when(invoice.getAccountId()).thenReturn(accountId); + Mockito.when(invoice.getInvoiceItems()).thenReturn(List.of()); + + wireMockServer.stubFor(get(urlPathEqualTo("/plugins/aviate-plugin/v1/ba/forKbAccountId/" + accountId)) + .willReturn(aResponse().withStatus(503).withBody("unavailable"))); + + final CustomField exemptField = customField(accountId, ObjectType.ACCOUNT, TaxMetadataResolver.TAX_EXEMPT, "true"); + final OSGIKillbillAPI killbillAPI = mockCustomFields( + accountId, tenantId, List.of(exemptField), List.of()); + final TaxMetadataResolver resolver = new TaxMetadataResolver(killbillAPI); + + final AccountTaxMetadata metadata = resolver.resolve( + invoice, + Mockito.mock(Account.class), + List.of(), + tenantContext(tenantId), + aviateConfig(), + TENANT_API_KEY, + TENANT_API_SECRET); + + Assert.assertEquals(metadata.getSource(), AccountTaxMetadata.Source.CUSTOM_FIELDS); + Assert.assertTrue(metadata.isTaxExempt()); + Assert.assertNull(metadata.getShipToAddress()); + } + + @Test(groups = "fast") + public void testAviateBillingAccountOverridesAddressAndExemption() throws Exception { + final UUID accountId = UUID.randomUUID(); + final UUID tenantId = UUID.randomUUID(); + + final Invoice invoice = Mockito.mock(Invoice.class); + Mockito.when(invoice.getAccountId()).thenReturn(accountId); + Mockito.when(invoice.getInvoiceItems()).thenReturn(List.of()); + + final Account account = Mockito.mock(Account.class); + Mockito.when(account.getCountry()).thenReturn("US"); + + final String body = "{" + + "\"companyName\":\"CloudSprout Inc.\"," + + "\"email\":\"billing@cloudsprout.example\"," + + "\"address\":{\"addressLine1\":\"100 Main Street\",\"city\":\"San Francisco\"," + + "\"state\":\"CA\",\"country\":\"US\",\"postalCode\":\"94105\"}," + + "\"taxRegistrations\":[{\"name\":\"US Tax Registration\",\"exempt\":false," + + "\"trn\":\"12-3456789\",\"address\":{\"addressLine1\":\"100 Main Street\"," + + "\"city\":\"San Francisco\",\"state\":\"CA\",\"country\":\"US\",\"postalCode\":\"94105\"}}]" + + "}"; + wireMockServer.stubFor(get(urlPathEqualTo("/plugins/aviate-plugin/v1/ba/forKbAccountId/" + accountId)) + .willReturn(aResponse().withStatus(200).withBody(body))); + + final OSGIKillbillAPI killbillAPI = mockCustomFields(accountId, tenantId, List.of(), List.of()); + final TaxMetadataResolver resolver = new TaxMetadataResolver(killbillAPI); + final KintsugiTenantConfig config = aviateConfig(); + + final AccountTaxMetadata metadata = resolver.resolve( + invoice, account, List.of(), tenantContext(tenantId), config, + TENANT_API_KEY, TENANT_API_SECRET); + + Assert.assertEquals(metadata.getSource(), AccountTaxMetadata.Source.AVIATE_BILLING_ACCOUNT); + Assert.assertEquals(metadata.getCompanyName(), "CloudSprout Inc."); + Assert.assertEquals(metadata.getTaxRegistrationNumber(), "12-3456789"); + Assert.assertNotNull(metadata.getShipToAddress()); + Assert.assertEquals(metadata.getShipToAddress().getCountry(), "US"); + + wireMockServer.verify(getRequestedFor(urlPathEqualTo("/plugins/aviate-plugin/v1/ba/forKbAccountId/" + accountId)) + .withHeader("Authorization", equalTo("Bearer " + AVIATE_TOKEN)) + .withHeader("X-Killbill-ApiKey", equalTo(TENANT_API_KEY)) + .withHeader("X-Killbill-ApiSecret", equalTo(TENANT_API_SECRET))); + } + + @Test(groups = "fast") + public void testAviate404FallsBackToCustomFields() throws Exception { + final UUID accountId = UUID.randomUUID(); + final UUID tenantId = UUID.randomUUID(); + + final Invoice invoice = Mockito.mock(Invoice.class); + Mockito.when(invoice.getAccountId()).thenReturn(accountId); + Mockito.when(invoice.getInvoiceItems()).thenReturn(List.of()); + + wireMockServer.stubFor(get(urlPathEqualTo("/plugins/aviate-plugin/v1/ba/forKbAccountId/" + accountId)) + .willReturn(aResponse().withStatus(404))); + + final CustomField exemptField = customField(accountId, ObjectType.ACCOUNT, TaxMetadataResolver.TAX_EXEMPT, "true"); + final OSGIKillbillAPI killbillAPI = mockCustomFields( + accountId, tenantId, List.of(exemptField), List.of()); + final TaxMetadataResolver resolver = new TaxMetadataResolver(killbillAPI); + + final AccountTaxMetadata metadata = resolver.resolve( + invoice, + Mockito.mock(Account.class), + List.of(), + tenantContext(tenantId), + aviateConfig(), + TENANT_API_KEY, + TENANT_API_SECRET); + + Assert.assertEquals(metadata.getSource(), AccountTaxMetadata.Source.CUSTOM_FIELDS); + Assert.assertTrue(metadata.isTaxExempt()); + Assert.assertNull(metadata.getShipToAddress()); + } + + @Test(groups = "fast") + public void testAviateExemptRegistrationSetsTaxExempt() throws Exception { + final AviateBillingAccount billingAccount = AviateBillingAccount.fromJson(new ObjectMapper().readTree( + "{\"taxRegistrations\":[{\"exempt\":true,\"trn\":\"98-7654321\"," + + "\"address\":{\"country\":\"US\",\"postalCode\":\"02101\"}}]}")); + Assert.assertNotNull(billingAccount); + Assert.assertTrue(billingAccount.isTaxExempt()); + Assert.assertEquals(billingAccount.resolveTaxRegistrationNumber("US"), "98-7654321"); + } + + private KintsugiTenantConfig baseConfig() { + final KintsugiTenantConfig config = new KintsugiTenantConfig(); + config.setKintsugiUrl("https://api.example.com"); + config.setHmacSecret("secret"); + return config; + } + + private KintsugiTenantConfig aviateConfig() { + final KintsugiTenantConfig config = baseConfig(); + config.setKillbillUrl("http://localhost:" + wireMockServer.port()); + config.setAviateIdToken(AVIATE_TOKEN); + return config; + } + + private static OSGIKillbillAPI mockCustomFields( + final UUID accountId, + final UUID tenantId, + final List accountFields, + final List itemFields) throws Exception { + final OSGIKillbillAPI killbillAPI = Mockito.mock(OSGIKillbillAPI.class); + final CustomFieldUserApi customFieldUserApi = Mockito.mock(CustomFieldUserApi.class); + Mockito.when(killbillAPI.getCustomFieldUserApi()).thenReturn(customFieldUserApi); + Mockito.when(customFieldUserApi.getCustomFieldsForObject( + Mockito.eq(accountId), Mockito.eq(ObjectType.ACCOUNT), Mockito.any(TenantContext.class))) + .thenReturn(accountFields); + Mockito.when(customFieldUserApi.getCustomFieldsForAccountType( + Mockito.eq(accountId), Mockito.eq(ObjectType.INVOICE_ITEM), Mockito.any(TenantContext.class))) + .thenReturn(itemFields); + return killbillAPI; + } + + private static CustomField customField( + final UUID objectId, + final ObjectType objectType, + final String fieldName, + final String value) { + final CustomField field = Mockito.mock(CustomField.class); + Mockito.when(field.getObjectId()).thenReturn(objectId); + Mockito.when(field.getObjectType()).thenReturn(objectType); + Mockito.when(field.getFieldName()).thenReturn(fieldName); + Mockito.when(field.getFieldValue()).thenReturn(value); + return field; + } + + private static TenantContext tenantContext(final UUID tenantId) { + return new PluginCallContext( + "killbill-kintsugi", + new DefaultClock().getUTCNow(), + UUID.randomUUID(), + tenantId); + } +}