diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 33b3aa0a..d5ab8f8c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -24,3 +24,7 @@ updates: directory: "/giving-example" schedule: interval: "daily" + - package-ecosystem: "gradle" + directory: "/in-person-payments-example" + schedule: + interval: "daily" \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9b24add7..0ebe121f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,8 +3,20 @@ name: Java CI with Gradle on: push: branches: [ main ] + paths-ignore: + - '**/README.md' + - README.md + - .gitignore + - .gitpod.yml + - LICENSE pull_request: branches: [ main ] + paths-ignore: + - '**/README.md' + - README.md + - .gitignore + - .gitpod.yml + - LICENSE jobs: build-checkout: diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 5094c2c8..6008b0ce 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -4,8 +4,20 @@ on: workflow_dispatch: push: branches: [ main ] + paths-ignore: + - '**/README.md' + - README.md + - .gitignore + - .gitpod.yml + - LICENSE pull_request: branches: [ main ] + paths-ignore: + - '**/README.md' + - README.md + - .gitignore + - .gitpod.yml + - LICENSE jobs: checkout: diff --git a/.gitpod.yml b/.gitpod.yml index 6e8a5e80..6522fa62 100644 --- a/.gitpod.yml +++ b/.gitpod.yml @@ -27,6 +27,10 @@ tasks: echo "Build giftcard-example application" cd giftcard-example && ./gradlew bootJar ;; + "in-person-payments-example") + echo "Build in-person-payments-example application" + cd in-person-payments-example && ./gradlew bootJar + ;; "subscription-example") echo "Build subscription-example application" cd subscription-example && ./gradlew bootJar diff --git a/README.md b/README.md index b9fb2e2d..fac9b583 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,8 @@ Get started by navigating to one of the supported demos below. | Demos | Description | Details | |---------------------------------------------------------:|:---------------------------------------------------------------------------------|:----------------------------------------| | [`Checkout Example`](checkout-example) | E-commerce checkout flow with different payment methods. | [See below](#checkout-example) | -| [`Advanced Checkout Example`](checkout-example-advanced) | E-commerce checkout flow with different payment methods, using the 3 steps flow. | [See below](#advanced-checkout-example) | +| [`Advanced Checkout Example`](checkout-example-advanced) | E-commerce checkout flow with different payment methods, using the 3 steps flow. | [See below](#advanced-checkout-example) | +| [`In-person Payments Example`](in-person-payments-example) | In-person payments using a POS terminal and the terminal-api/sync endpoint. | [See below](#in-person-payments-example) | | [`Gift Card Example`](giftcard-example) | Gift Cards checkout flow using partial orders. | [See below](#gift-card-example) | | [`Pay By Link Example`](paybylink-example) | Create payment links in seconds. | [See below](#paybylink-example) | | [`Subscription Example`](subscription-example) | Subscription flow using Adyen tokenization. | [See below](#subscription-example) | @@ -40,6 +41,15 @@ See the [advanced integration flow](https://docs.adyen.com/online-payments/web-d ![Card Checkout Demo](checkout-example/src/main/resources/static/images/cardcheckout.gif) +## [In-person Payments Example](in-person-payments-example) +[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/adyen-examples/adyen-dotnet-online-payments/tree/main/in-person-payments-example) + +[First time with Gitpod?](https://github.com/adyen-examples/.github/blob/main/pages/gitpod-get-started.md) + +The [in-person payments example](in-person-payments-example) features an in-person payment [cloud terminal API](https://docs.adyen.com/point-of-sale/design-your-integration/choose-your-architecture/cloud/) integration. Within this demo app, you can make in-person payments using a terminal, initiate reversals (refunds) and check transaction statuses. + +![Card In-person Payments Demo](in-person-payments-example/wwwroot/images/cardinpersonpayments.gif) + ## [Gift Card Example](giftcard-example) [![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/adyen-examples/adyen-java-spring-online-payments/tree/main/giftcard-example) diff --git a/in-person-payments-example/Dockerfile b/in-person-payments-example/Dockerfile new file mode 100644 index 00000000..1ded3f38 --- /dev/null +++ b/in-person-payments-example/Dockerfile @@ -0,0 +1,3 @@ +FROM amazoncorretto:17-alpine-jdk +COPY build/libs/in-person-payments-example-0.0.1-SNAPSHOT.jar adyen-java-spring-in-person-payments-example-0.0.1-SNAPSHOT.jar +ENTRYPOINT ["java","-jar","/adyen-java-spring-in-person-payments-example-0.0.1-SNAPSHOT.jar"] diff --git a/in-person-payments-example/README.md b/in-person-payments-example/README.md new file mode 100644 index 00000000..b6587a67 --- /dev/null +++ b/in-person-payments-example/README.md @@ -0,0 +1,103 @@ +# Adyen [In-person Payment Demo](https://docs.adyen.com/point-of-sale/) Integration Demo + +## Run demo in one-click +[![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/adyen-examples/adyen-java-spring-online-payments/tree/main/in-person-payments-example) + [First time with Gitpod?](https://github.com/adyen-examples/.github/blob/main/pages/gitpod-get-started.md) + +## Description +This demo shows developers how to use the Adyen [Cloud Terminal API](https://docs.adyen.com/point-of-sale/design-your-integration/choose-your-architecture/cloud/) `/terminal-api/sync` to make requests to your connected terminal. + +The following implementations are included: +- [Payment requests](https://docs.adyen.com/point-of-sale/basic-tapi-integration/make-a-payment/) +- [Referenced refund requests](https://docs.adyen.com/point-of-sale/basic-tapi-integration/refund-payment/referenced/) +- [Cancel/abort requests](https://docs.adyen.com/point-of-sale/basic-tapi-integration/cancel-a-transaction/) +- [Transaction status requests](https://docs.adyen.com/point-of-sale/basic-tapi-integration/verify-transaction-status/) + +There are typically two ways to integrate in-person payments: local or cloud communications. +To find out which solution (or hybrid) suits your needs, visit the following [documentation page](https://docs.adyen.com/point-of-sale/design-your-integration/choose-your-architecture/#choosing-between-cloud-and-local). + +You can find the [Terminal API documentation](https://docs.adyen.com/point-of-sale/design-your-integration/terminal-api/terminal-api-reference/) here. + +This demo integrates the Adyen API Library for Java ([GitHub](https://github.com/Adyen/adyen-java-api-library) | [Docs](https://docs.adyen.com/development-resources/libraries/?tab=java_1)). +You can find the [Terminal API documentation](https://docs.adyen.com/point-of-sale/design-your-integration/terminal-api/terminal-api-reference/) here. + +![In-person Payments Demo](wwwroot/images/cardinpersonpayments.gif) + + +## Requirements +- A [terminal device](https://docs.adyen.com/point-of-sale/user-manuals/) and a [test card](https://docs.adyen.com/point-of-sale/testing-pos-payments/) from Adyen +- [Adyen API Credentials](https://docs.adyen.com/development-resources/api-credentials/) +- Java 17 + + +## 1. Installation +``` +git clone https://github.com/adyen-examples/adyen-java-spring-online-payments.git +``` + +## 2. Set the environment variables +* [API key](https://docs.adyen.com/user-management/how-to-get-the-api-key) +* [HMAC Key](https://docs.adyen.com/development-resources/webhooks/verify-hmac-signatures) +* `ADYEN_POS_POI_ID`: the unique ID of your payment terminal for the NEXO Sale to POI protocol. + - **Format:** `[device model]-[serial number]` **Example:** `V400m-123456789` + + +On Linux/Mac/Windows export/set the environment variables. +```shell +export ADYEN_API_KEY=yourAdyenApiKey +export ADYEN_HMAC_KEY=yourHmacKey +export ADYEN_POS_POI_ID=v400m-123456789 +``` + +Alternatively, it's possible to define the variables in the `application.properties`. +```txt +ADYEN_API_KEY=yourAdyenApiKey +ADYEN_HMAC_KEY=yourHmacKey +ADYEN_POS_POI_ID=v400m-123456789 +``` + +## 4. Run the application + +``` +cd in-person-payments-example + +./gradlew bootRun +``` + +# Webhooks + +Webhooks deliver asynchronous notifications about the payment status and other events that are important to receive and process. +You can find more information about webhooks in [this blog post](https://www.adyen.com/knowledge-hub/consuming-webhooks). + +### Webhook setup + +In the Customer Area under the `Developers → Webhooks` section, [create](https://docs.adyen.com/development-resources/webhooks/#set-up-webhooks-in-your-customer-area) a new `Standard webhook`. + +A good practice is to set up basic authentication, copy the generated HMAC Key and set it as an environment variable. The application will use this to verify the [HMAC signatures](https://docs.adyen.com/development-resources/webhooks/verify-hmac-signatures/). + +Make sure the webhook is **enabled**, so it can receive notifications. + +### Expose an endpoint + +This demo provides a simple webhook implementation exposed at `/api/webhooks/notifications` that shows you how to receive, validate and consume the webhook payload. + +### Test your webhook + +The following webhooks `events` should be enabled: +* **AUTHORISATION** +* **CANCEL_OR_REFUND** +* **REFUND_FAILED** +* **REFUNDED_REVERSE** + + +To make sure that the Adyen platform can reach your application, we have written a [Webhooks Testing Guide](https://github.com/adyen-examples/.github/blob/main/pages/webhooks-testing.md) +that explores several options on how you can easily achieve this (e.g. running on localhost or cloud). + + +## Usage +1. Select the cloud terminal api integration. +2. Select a table. +3. Select pay to perform a payment. +4. Complete the instructions on your terminal. +5. Select reversal to refund the payment. +6. If webhooks are set up, listen for the notifications to update the payment status (reversals/refunds happen asynchronously and may take some time to receive). \ No newline at end of file diff --git a/in-person-payments-example/build.gradle b/in-person-payments-example/build.gradle new file mode 100644 index 00000000..212a21bb --- /dev/null +++ b/in-person-payments-example/build.gradle @@ -0,0 +1,29 @@ +plugins { + alias(libs.plugins.spring.framework) + alias(libs.plugins.spring.dependency) + + id 'java' +} + +group = 'com.adyen' +version = '0.0.1-SNAPSHOT' + +sourceCompatibility = 17 +targetCompatibility = 17 + +repositories { + mavenCentral() +} + +dependencies { + implementation libs.adyen.java + implementation libs.bundles.spring + + testImplementation(libs.bundles.spring.test){ + exclude (group: 'org.junit.vintage', module: 'junit-vintage-engine') + } +} + +test { + useJUnitPlatform() +} diff --git a/in-person-payments-example/gradle/wrapper/gradle-wrapper.jar b/in-person-payments-example/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..7454180f Binary files /dev/null and b/in-person-payments-example/gradle/wrapper/gradle-wrapper.jar differ diff --git a/in-person-payments-example/gradle/wrapper/gradle-wrapper.properties b/in-person-payments-example/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..070cb702 --- /dev/null +++ b/in-person-payments-example/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/in-person-payments-example/gradlew b/in-person-payments-example/gradlew new file mode 100755 index 00000000..1b6c7873 --- /dev/null +++ b/in-person-payments-example/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# 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 +# +# https://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. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/in-person-payments-example/gradlew.bat b/in-person-payments-example/gradlew.bat new file mode 100644 index 00000000..107acd32 --- /dev/null +++ b/in-person-payments-example/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/in-person-payments-example/settings.gradle b/in-person-payments-example/settings.gradle new file mode 100644 index 00000000..dbaaf22e --- /dev/null +++ b/in-person-payments-example/settings.gradle @@ -0,0 +1,9 @@ +rootProject.name = 'adyen-java-spring-in-person-payments' + +dependencyResolutionManagement { + versionCatalogs { + libs { + from(files("../gradle/libs.versions.toml")) + } + } +} diff --git a/in-person-payments-example/src/main/java/com/adyen/ipp/ApplicationProperty.java b/in-person-payments-example/src/main/java/com/adyen/ipp/ApplicationProperty.java new file mode 100644 index 00000000..a49bf05e --- /dev/null +++ b/in-person-payments-example/src/main/java/com/adyen/ipp/ApplicationProperty.java @@ -0,0 +1,55 @@ +package com.adyen.ipp; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +public class ApplicationProperty { + + @Value("${server.port}") + private int serverPort; + + @Value("${ADYEN_API_KEY:#{null}}") + private String apiKey; + + @Value("${ADYEN_HMAC_KEY:#{null}}") + private String hmacKey; + + @Value("${ADYEN_POS_POI_ID:#{null}}") + private String poiId; + + @Value("${saleId:POS_SALE_ID_42}") + private String saleId; + + public int getServerPort() { + return serverPort; + } + + public void setServerPort(int serverPort) { + this.serverPort = serverPort; + } + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public String getHmacKey() { + return hmacKey; + } + + public void setHmacKey(String hmacKey) { + this.hmacKey = hmacKey; + } + + public String getPoiId() { return poiId; } + + public void setPoiId(String poiId) { this.poiId = poiId; } + + public String getSaleId() { return saleId; } + + public void setSaleId(String saleId) { this.saleId = saleId ; } +} diff --git a/in-person-payments-example/src/main/java/com/adyen/ipp/Config.java b/in-person-payments-example/src/main/java/com/adyen/ipp/Config.java new file mode 100644 index 00000000..f7d68ba8 --- /dev/null +++ b/in-person-payments-example/src/main/java/com/adyen/ipp/Config.java @@ -0,0 +1,12 @@ +package com.adyen.ipp; + +import org.springframework.context.annotation.Bean; + +import nz.net.ultraq.thymeleaf.layoutdialect.LayoutDialect; + +public class Config { + @Bean + public LayoutDialect layoutDialect() { + return new LayoutDialect(); + } +} diff --git a/in-person-payments-example/src/main/java/com/adyen/ipp/InPersonPaymentsApplication.java b/in-person-payments-example/src/main/java/com/adyen/ipp/InPersonPaymentsApplication.java new file mode 100644 index 00000000..491c5446 --- /dev/null +++ b/in-person-payments-example/src/main/java/com/adyen/ipp/InPersonPaymentsApplication.java @@ -0,0 +1,30 @@ +package com.adyen.ipp; + +import jakarta.annotation.PostConstruct; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + + +@SpringBootApplication +public class InPersonPaymentsApplication { + + private static final Logger log = LoggerFactory.getLogger(InPersonPaymentsApplication.class); + + @Autowired + private ApplicationProperty applicationProperty; + + public static void main(String[] args) { + SpringApplication.run(InPersonPaymentsApplication.class, args); + } + + @PostConstruct + public void init() { + log.info("\n----------------------------------------------------------\n\t" + + "Application is running on http://localhost:" + applicationProperty.getServerPort() + + "\n----------------------------------------------------------"); + } + +} diff --git a/in-person-payments-example/src/main/java/com/adyen/ipp/api/InPersonPaymentsController.java b/in-person-payments-example/src/main/java/com/adyen/ipp/api/InPersonPaymentsController.java new file mode 100644 index 00000000..7f0bd50d --- /dev/null +++ b/in-person-payments-example/src/main/java/com/adyen/ipp/api/InPersonPaymentsController.java @@ -0,0 +1,199 @@ +package com.adyen.ipp.api; + +import com.adyen.model.nexo.*; +import com.adyen.ipp.ApplicationProperty; +import com.adyen.ipp.model.*; +import com.adyen.ipp.request.*; +import com.adyen.ipp.response.*; +import com.adyen.ipp.service.*; +import com.adyen.ipp.util.IdUtility; +import com.adyen.service.exception.ApiException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.io.IOException; + +/** + * REST controller for using Adyen checkout API + */ +@RestController +@RequestMapping("/api") +public class InPersonPaymentsController { + private final Logger log = LoggerFactory.getLogger(InPersonPaymentsController.class); + + private final ApplicationProperty applicationProperty; + + @Autowired + private PosAbortService posAbortService; + + @Autowired + private PosPaymentService posPaymentService; + + @Autowired + private PosReversalService posReversalService; + + @Autowired + private PosTransactionStatusService posTransactionStatusService; + + @Autowired + private TableService tableService; + + @Autowired + public InPersonPaymentsController(ApplicationProperty applicationProperty) { + + this.applicationProperty = applicationProperty; + } + + @PostMapping("/create-payment") + public ResponseEntity createPayment(@RequestBody CreatePaymentRequest request) throws IOException, ApiException { + Table table = tableService.getTables().stream() + .filter(t -> t.getTableName().equals(request.getTableName())) + .findFirst() + .orElse(null); + + if (table == null) { + return ResponseEntity + .status(404) + .body(new CreatePaymentResponse() + .result("failure") + .refusalReason("Table " + request.getTableName() + " not found")); + } + + try { + String serviceId = IdUtility.getRandomAlphanumericId(10); + table.getPaymentStatusDetails().setServiceId(serviceId); + table.setPaymentStatus(PaymentStatus.PaymentInProgress); + + var response = posPaymentService.sendPaymentRequest(serviceId, applicationProperty.getPoiId(), applicationProperty.getSaleId(), request.getCurrency(), request.getAmount()); + + if (response == null || response.getSaleToPOIResponse() == null || response.getSaleToPOIResponse().getPaymentResponse() == null) { + table.setPaymentStatus(PaymentStatus.NotPaid); + return ResponseEntity + .badRequest() + .body(new CreatePaymentResponse() + .result("failure") + .refusalReason("Empty payment response")); + } + + var paymentResponse = response.getSaleToPOIResponse().getPaymentResponse(); + + switch (paymentResponse.getResponse().getResult()) { + case SUCCESS: + table.setPaymentStatus(PaymentStatus.Paid); + table.getPaymentStatusDetails().setPoiTransactionId(paymentResponse.getPOIData().getPOITransactionID().getTransactionID()); + table.getPaymentStatusDetails().setPoiTransactionTimeStamp(paymentResponse.getPOIData().getPOITransactionID().getTimeStamp()); + table.getPaymentStatusDetails().setSaleTransactionId(paymentResponse.getSaleData().getSaleTransactionID().getTransactionID()); + table.getPaymentStatusDetails().setSaleTransactionTimeStamp(paymentResponse.getSaleData().getSaleTransactionID().getTimeStamp()); + + return ResponseEntity + .ok() + .body(new CreatePaymentResponse() + .result("success")); + case FAILURE: + table.setPaymentStatus(PaymentStatus.NotPaid); + table.getPaymentStatusDetails().setRefusalReason("Payment terminal responded with: " + paymentResponse.getResponse().getErrorCondition()); + table.getPaymentStatusDetails().setPoiTransactionId(paymentResponse.getPOIData().getPOITransactionID().getTransactionID()); + table.getPaymentStatusDetails().setPoiTransactionTimeStamp(paymentResponse.getPOIData().getPOITransactionID().getTimeStamp()); + table.getPaymentStatusDetails().setSaleTransactionId(paymentResponse.getSaleData().getSaleTransactionID().getTransactionID()); + table.getPaymentStatusDetails().setSaleTransactionTimeStamp(paymentResponse.getSaleData().getSaleTransactionID().getTimeStamp()); + + return ResponseEntity + .ok() + .body(new CreatePaymentResponse() + .result("failure") + .refusalReason(table.getPaymentStatusDetails().getRefusalReason())); + default: + table.setPaymentStatus(PaymentStatus.NotPaid); + + return ResponseEntity + .badRequest() + .body(new CreatePaymentResponse() + .result("failure") + .refusalReason("Could not reach payment terminal with POI ID " + applicationProperty.getPoiId())); + } + + } catch (IOException | ApiException e) { + log.error(e.toString()); + table.setPaymentStatus(PaymentStatus.NotPaid); + throw e; + } + } + + @PostMapping("/create-reversal") + public ResponseEntity createReversal(@RequestBody CreateReversalRequest request) throws IOException, ApiException { + try { + Table table = tableService.getTables().stream() + .filter(t -> t.getTableName().equals(request.getTableName())) + .findFirst() + .orElse(null); + + if (table == null) { + return ResponseEntity + .status(404) + .body(new CreateReversalResponse() + .result("failure") + .refusalReason("Table " + request.getTableName() + " not found")); + } + + var response = posReversalService.sendReversalRequest(ReversalReasonType.MERCHANT_CANCEL, table.getPaymentStatusDetails().getSaleTransactionId(), table.getPaymentStatusDetails().getPoiTransactionId(), applicationProperty.getPoiId(), applicationProperty.getSaleId()); + + if (response == null || response.getSaleToPOIResponse() == null || response.getSaleToPOIResponse().getReversalResponse() == null) { + return ResponseEntity + .badRequest() + .body(new CreateReversalResponse() + .result("failure") + .refusalReason("Empty reversal response")); + } + + var reversalResponse = response.getSaleToPOIResponse().getReversalResponse(); + + switch (reversalResponse.getResponse().getResult()) { + case SUCCESS: + table.setPaymentStatus(PaymentStatus.RefundInProgress); + return ResponseEntity + .ok() + .body(new CreateReversalResponse() + .result("success")); + case FAILURE: + table.setPaymentStatus(PaymentStatus.RefundFailed); + return ResponseEntity + .ok() + .body(new CreateReversalResponse() + .result("failure") + .refusalReason("Payment terminal responded with: " + java.net.URLDecoder.decode(reversalResponse.getResponse().getAdditionalResponse()))); + default: + return ResponseEntity + .badRequest() + .body(new CreateReversalResponse() + .result("failure") + .refusalReason("Could not reach payment terminal with POI ID " + applicationProperty.getPoiId())); + } + } catch (IOException | ApiException e) { + log.error(e.toString()); + throw e; + } + } + + @GetMapping("/abort/{tableName}") + public ResponseEntity abort(@PathVariable String tableName) throws IOException, ApiException { + try { + Table table = tableService.getTables().stream() + .filter(t -> t.getTableName().equals(tableName)) + .findFirst() + .orElse(null); + + if (table == null || table.getPaymentStatusDetails() == null || table.getPaymentStatusDetails().getServiceId() == null) { + return ResponseEntity.notFound().build(); + } + var abortResponse = posAbortService.sendAbortRequest(table.getPaymentStatusDetails().getServiceId(), applicationProperty.getPoiId(), applicationProperty.getSaleId()); + + return ResponseEntity.ok().body(abortResponse); + } catch (IOException | ApiException e) { + log.error(e.toString()); + throw e; + } + } +} \ No newline at end of file diff --git a/in-person-payments-example/src/main/java/com/adyen/ipp/api/WebhookController.java b/in-person-payments-example/src/main/java/com/adyen/ipp/api/WebhookController.java new file mode 100644 index 00000000..26cd88fc --- /dev/null +++ b/in-person-payments-example/src/main/java/com/adyen/ipp/api/WebhookController.java @@ -0,0 +1,103 @@ +package com.adyen.ipp.api; + +import com.adyen.ipp.ApplicationProperty; +import com.adyen.model.notification.NotificationRequest; +import com.adyen.util.HMACValidator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.io.IOException; +import java.security.SignatureException; + +/** + * REST controller for receiving Adyen webhook notifications + */ +@RestController +@RequestMapping("/api") +public class WebhookController { + private final Logger log = LoggerFactory.getLogger(WebhookController.class); + + private ApplicationProperty applicationProperty; + + @Autowired + public WebhookController(ApplicationProperty applicationProperty) { + this.applicationProperty = applicationProperty; + + if (this.applicationProperty.getHmacKey() == null) { + log.warn("ADYEN_HMAC_KEY is UNDEFINED (Webhook cannot be authenticated)"); + } + } + + /** + * Process the incoming Webhook event: get NotificationRequestItem, validate HMAC signature, + * consume the event asynchronously, send response ["accepted"] + * + * @param json Payload of the webhook event + * @return + */ + @PostMapping("/webhooks/notifications") + public ResponseEntity webhooks(@RequestBody String json) throws IOException { + + // from JSON string to object + var notificationRequest = NotificationRequest.fromJson(json); + + // fetch first (and only) NotificationRequestItem + var notificationRequestItem = notificationRequest.getNotificationItems().stream().findFirst(); + + if (notificationRequestItem.isPresent()) { + + var item = notificationRequestItem.get(); + + try { + if (!getHmacValidator().validateHMAC(item, this.applicationProperty.getHmacKey())) { + // invalid HMAC signature: do not send [accepted] response + log.warn("Could not validate HMAC signature for incoming webhook message: {}", item); + throw new RuntimeException("Invalid HMAC signature"); + } + + log.info("Received webhook success:{} eventCode:{}", item.isSuccess(), item.getEventCode()); + + // consume payload + if(item.isSuccess()) { + if (item.getEventCode().equals("AUTHORISATION")) { + // webhook with recurring token + log.info("Webhook AUTHORISATION - PspReference {}", item.getPspReference()); + } else if (item.getEventCode().equals("CANCEL_OR_REFUND")) { + // webhook with payment authorisation + log.info("Webhook CANCEL_OR_REFUND - PspReference {}", item.getPspReference()); + } else if (item.getEventCode().equals("REFUND_FAILED")) { + // webhook with payment authorisation + log.info("Webhook REFUND_FAILED - PspReference {}", item.getPspReference()); + } else if (item.getEventCode().equals("REFUNDED_REVERSED")) { + // webhook with payment authorisation + log.info("Webhook REFUNDED_REVERSED - PspReference {}", item.getPspReference()); + } else { + // unexpected eventCode + log.warn("Unexpected eventCode: " + item.getEventCode()); + } + } else { + // Operation has failed: check the reason field for failure information. + log.info("Operation has failed: " + item.getReason()); + } + + } catch (SignatureException e) { + // Unexpected error during HMAC validation: do not send [accepted] response + log.error("Error while validating HMAC Key", e); + throw new RuntimeException(e.getMessage()); + } + + } + + // Acknowledge event has been consumed + return ResponseEntity.ok().body("[accepted]"); + } + + @Bean + public HMACValidator getHmacValidator() { + return new HMACValidator(); + } +} diff --git a/in-person-payments-example/src/main/java/com/adyen/ipp/model/PaymentStatus.java b/in-person-payments-example/src/main/java/com/adyen/ipp/model/PaymentStatus.java new file mode 100644 index 00000000..0eb434c6 --- /dev/null +++ b/in-person-payments-example/src/main/java/com/adyen/ipp/model/PaymentStatus.java @@ -0,0 +1,43 @@ +package com.adyen.ipp.model; + +public enum PaymentStatus { + /** + * Indicates that the customer has not paid yet. + */ + NotPaid, + + /** + * Indicates that the customer is going to pay, e.g. the payment request is sent to the terminal. + */ + PaymentInProgress, + + /** + * Indicates that the customer has paid for the table, e.g. successful payment request. + */ + Paid, + + /** + * A refund is set to {@link #RefundInProgress} when the merchant has initiated the referenced refund process. + * Referenced refunds are processed asynchronously and are updated through webhooks. + * See also Referenced Refunds. + */ + RefundInProgress, + + /** + * A refund is set to {@link #Refunded} when the webhook CANCEL_OR_REFUND is successfully received. + * See Cancel or Refund Webhook. + */ + Refunded, + + /** + * A refund is set to {@link #RefundFailed} when the webhook REFUND_FAILED is successfully received. + * See Refund Failed. + */ + RefundFailed, + + /** + * A refund is set to {@link #RefundedReversed} when the webhook REFUNDED_REVERSED is successfully received. + * See Refunded Reversed. + */ + RefundedReversed +} \ No newline at end of file diff --git a/in-person-payments-example/src/main/java/com/adyen/ipp/model/PaymentStatusDetails.java b/in-person-payments-example/src/main/java/com/adyen/ipp/model/PaymentStatusDetails.java new file mode 100644 index 00000000..8c7a8414 --- /dev/null +++ b/in-person-payments-example/src/main/java/com/adyen/ipp/model/PaymentStatusDetails.java @@ -0,0 +1,100 @@ +package com.adyen.ipp.model; + +import javax.xml.datatype.XMLGregorianCalendar; + +public class PaymentStatusDetails { + /** + * PspReference: It is possible to get the PspReference from the Response.AdditionalData property: + * https://docs.adyen.com/point-of-sale/basic-tapi-integration/verify-transaction-status/#id324618081 + */ + private String pspReference; + + /** + * The API provides a refusal reason when there's an error. This will get populated when "failure" is sent as a response. + */ + private String refusalReason; + + /** + * The POI Transaction Id, populated when a TableStatus is set to PaymentStatus.Paid. + * Example value: CmI6001693237705007.TG6DVRZ3HVTFWR82. + */ + private String poiTransactionId; + + /** + * Date of the POI transaction. + */ + private XMLGregorianCalendar poiTransactionTimeStamp; + + /** + * The SaleTransactionId (SaleReferenceId), populated when a TableStatus is set to PaymentStatus.Paid. + * Example value: 6abcb27d-9082-40d9-969d-1c7f283ebd52. + */ + private String saleTransactionId; + + /** + * Date of the Sale transaction. + */ + private XMLGregorianCalendar saleTransactionTimeStamp; + + /** + * The unique ID of a message pair, which processes the transaction. The value is assigned when you initiate a payment transaction to the terminal, and used to cancel/abort the request. + * This unique request ID, consisting of 1-10 alphanumeric characters is generated using the Utilities.IdUtility.GetRandomAlphanumericId(int) function and must be unique within the last 48 hours for the terminal (POIID) being used. + */ + private String serviceId; + + public String getPspReference() { + return pspReference; + } + + public void setPspReference(String pspReference) { + this.pspReference = pspReference; + } + + public String getRefusalReason() { + return refusalReason; + } + + public void setRefusalReason(String refusalReason) { + this.refusalReason = refusalReason; + } + + public String getPoiTransactionId() { + return poiTransactionId; + } + + public void setPoiTransactionId(String poiTransactionId) { + this.poiTransactionId = poiTransactionId; + } + + public XMLGregorianCalendar getPoiTransactionTimeStamp() { + return poiTransactionTimeStamp; + } + + public void setPoiTransactionTimeStamp(XMLGregorianCalendar poiTransactionTimeStamp) { + this.poiTransactionTimeStamp = poiTransactionTimeStamp; + } + + public String getSaleTransactionId() { + return saleTransactionId; + } + + public void setSaleTransactionId(String saleTransactionId) { + this.saleTransactionId = saleTransactionId; + } + + public XMLGregorianCalendar getSaleTransactionTimeStamp() { + return saleTransactionTimeStamp; + } + + public void setSaleTransactionTimeStamp(XMLGregorianCalendar saleTransactionTimeStamp) { + this.saleTransactionTimeStamp = saleTransactionTimeStamp; + } + + public String getServiceId() { + return serviceId; + } + + public void setServiceId(String serviceId) { + this.serviceId = serviceId; + } +} diff --git a/in-person-payments-example/src/main/java/com/adyen/ipp/model/Table.java b/in-person-payments-example/src/main/java/com/adyen/ipp/model/Table.java new file mode 100644 index 00000000..4ec3f4a6 --- /dev/null +++ b/in-person-payments-example/src/main/java/com/adyen/ipp/model/Table.java @@ -0,0 +1,66 @@ +package com.adyen.ipp.model; + +import java.math.BigDecimal; + +public class Table { + /** + * Currency used for the Amount (e.g. "EUR", "USD"). + */ + private String currency; + + /** + * The table amount to-be-paid, in DECIMAL units (example: 42.99), the terminal API does not use minor units. + */ + private BigDecimal amount; + + /** + * Name of the table, used to uniquely identify the table. + */ + private String tableName; + + /** + * Status of the table, used to check if the table has paid. + */ + private PaymentStatus paymentStatus = PaymentStatus.NotPaid; + + /** + * Object that is populated with information when the payment process is started. + */ + private PaymentStatusDetails paymentStatusDetails; + + public String getCurrency() { + return currency; + } + + public void setCurrency(String currency) { + this.currency = currency; + } + + public BigDecimal getAmount() { return amount; } + + public void setAmount(BigDecimal amount) { + this.amount = amount; + } + + public String getTableName() { + return tableName; + } + + public void setTableName(String tableName) { + this.tableName = tableName; + } + + public PaymentStatus getPaymentStatus() { + return paymentStatus; + } + + public void setPaymentStatus(PaymentStatus paymentStatus) { + this.paymentStatus = paymentStatus; + } + + public PaymentStatusDetails getPaymentStatusDetails() { + return paymentStatusDetails; + } + + public void setPaymentStatusDetails(PaymentStatusDetails paymentStatusDetails) { this.paymentStatusDetails = paymentStatusDetails; } +} diff --git a/in-person-payments-example/src/main/java/com/adyen/ipp/request/CreatePaymentRequest.java b/in-person-payments-example/src/main/java/com/adyen/ipp/request/CreatePaymentRequest.java new file mode 100644 index 00000000..1c419606 --- /dev/null +++ b/in-person-payments-example/src/main/java/com/adyen/ipp/request/CreatePaymentRequest.java @@ -0,0 +1,33 @@ +package com.adyen.ipp.request; + +import java.math.BigDecimal; + +public class CreatePaymentRequest { + private String tableName; + private BigDecimal amount; + private String currency; + + public String getTableName() { + return tableName; + } + + public void setTableName(String tableName) { + this.tableName = tableName; + } + + public BigDecimal getAmount() { + return amount; + } + + public void setAmount(BigDecimal amount) { + this.amount = amount; + } + + public String getCurrency() { + return currency; + } + + public void setCurrency(String currency) { + this.currency = currency; + } +} diff --git a/in-person-payments-example/src/main/java/com/adyen/ipp/request/CreateReversalRequest.java b/in-person-payments-example/src/main/java/com/adyen/ipp/request/CreateReversalRequest.java new file mode 100644 index 00000000..635e8143 --- /dev/null +++ b/in-person-payments-example/src/main/java/com/adyen/ipp/request/CreateReversalRequest.java @@ -0,0 +1,12 @@ +package com.adyen.ipp.request; +public class CreateReversalRequest { + private String tableName; + + public String getTableName() { + return tableName; + } + + public void setTableName(String tableName) { + this.tableName = tableName; + } +} diff --git a/in-person-payments-example/src/main/java/com/adyen/ipp/response/CreatePaymentResponse.java b/in-person-payments-example/src/main/java/com/adyen/ipp/response/CreatePaymentResponse.java new file mode 100644 index 00000000..9d43615c --- /dev/null +++ b/in-person-payments-example/src/main/java/com/adyen/ipp/response/CreatePaymentResponse.java @@ -0,0 +1,31 @@ +package com.adyen.ipp.response; + +public class CreatePaymentResponse { + private String result; + private String refusalReason; + + public String getResult() { + return result; + } + + public void setResult(String result) { + this.result = result; + } + + public String getRefusalReason() { + return refusalReason; + } + + public void setRefusalReason(String refusalReason) { + this.refusalReason = refusalReason; + } + + public CreatePaymentResponse refusalReason(String refusalReason){ + this.refusalReason = refusalReason; + return this; + } + public CreatePaymentResponse result(String result){ + this.result = result; + return this; + } +} diff --git a/in-person-payments-example/src/main/java/com/adyen/ipp/response/CreateReversalResponse.java b/in-person-payments-example/src/main/java/com/adyen/ipp/response/CreateReversalResponse.java new file mode 100644 index 00000000..f8087a00 --- /dev/null +++ b/in-person-payments-example/src/main/java/com/adyen/ipp/response/CreateReversalResponse.java @@ -0,0 +1,31 @@ +package com.adyen.ipp.response; + +public class CreateReversalResponse { + private String result; + private String refusalReason; + + public String getResult() { + return result; + } + + public void setResult(String result) { + this.result = result; + } + + public String getRefusalReason() { + return refusalReason; + } + + public void setRefusalReason(String refusalReason) { + this.refusalReason = refusalReason; + } + + public CreateReversalResponse refusalReason(String refusalReason){ + this.refusalReason = refusalReason; + return this; + } + public CreateReversalResponse result(String result){ + this.result = result; + return this; + } +} diff --git a/in-person-payments-example/src/main/java/com/adyen/ipp/service/PosAbortService.java b/in-person-payments-example/src/main/java/com/adyen/ipp/service/PosAbortService.java new file mode 100644 index 00000000..1e6f9774 --- /dev/null +++ b/in-person-payments-example/src/main/java/com/adyen/ipp/service/PosAbortService.java @@ -0,0 +1,50 @@ +package com.adyen.ipp.service; + +import com.adyen.ipp.util.IdUtility; +import com.adyen.service.exception.ApiException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import com.adyen.model.nexo.*; +import com.adyen.model.terminal.*; + +import java.io.IOException; + +@Service +public class PosAbortService { + @Autowired + private TerminalCloudApiService terminalCloudAPIService; + + public TerminalAPIResponse sendAbortRequest(String serviceId, String poiId, String saleId) throws IOException, ApiException { + TerminalAPIRequest request = getAbortRequest(serviceId, poiId, saleId); + return terminalCloudAPIService.getTerminalCloudApi().sync(request); + } + + private TerminalAPIRequest getAbortRequest(String serviceId, String poiId, String saleId) { + var messageHeader = new MessageHeader(); + messageHeader.setMessageCategory(MessageCategoryType.ABORT); + messageHeader.setMessageClass(MessageClassType.SERVICE); + messageHeader.setMessageType(MessageType.REQUEST); + messageHeader.setPOIID(poiId); + messageHeader.setSaleID(saleId); + messageHeader.setServiceID(IdUtility.getRandomAlphanumericId(10)); + + var messageReference = new MessageReference(); + messageReference.setMessageCategory(MessageCategoryType.PAYMENT); + messageReference.setServiceID(serviceId); + messageReference.setPOIID(poiId); + messageReference.setSaleID(saleId); + + var abortRequest = new AbortRequest(); + abortRequest.setAbortReason("MerchantAbort"); + abortRequest.setMessageReference(messageReference); + + var saleToPOIRequest = new SaleToPOIRequest(); + saleToPOIRequest.setMessageHeader(messageHeader); + saleToPOIRequest.setAbortRequest(abortRequest); + + var terminalAPIRequest = new TerminalAPIRequest(); + terminalAPIRequest.setSaleToPOIRequest(saleToPOIRequest); + + return terminalAPIRequest; + } +} \ No newline at end of file diff --git a/in-person-payments-example/src/main/java/com/adyen/ipp/service/PosPaymentService.java b/in-person-payments-example/src/main/java/com/adyen/ipp/service/PosPaymentService.java new file mode 100644 index 00000000..07c36706 --- /dev/null +++ b/in-person-payments-example/src/main/java/com/adyen/ipp/service/PosPaymentService.java @@ -0,0 +1,69 @@ +package com.adyen.ipp.service; + +import com.adyen.model.nexo.*; +import com.adyen.model.terminal.TerminalAPIRequest; +import com.adyen.model.terminal.TerminalAPIResponse; +import com.adyen.service.exception.ApiException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import javax.xml.datatype.DatatypeConfigurationException; +import javax.xml.datatype.DatatypeFactory; +import java.io.IOException; +import java.math.BigDecimal; +import java.util.GregorianCalendar; +import java.util.UUID; + +@Service +public class PosPaymentService { + @Autowired + private TerminalCloudApiService terminalCloudAPIService; + + private DatatypeFactory dataTypeFactory; + + public PosPaymentService() throws DatatypeConfigurationException { + dataTypeFactory = DatatypeFactory.newInstance(); + } + + public TerminalAPIResponse sendPaymentRequest(String serviceId, String poiId, String saleId, String currency, BigDecimal amount) throws IOException, ApiException { + TerminalAPIRequest request = getPaymentRequest(serviceId, poiId, saleId, currency, amount); + return terminalCloudAPIService.getTerminalCloudApi().sync(request); + } + + private TerminalAPIRequest getPaymentRequest(String serviceId, String poiId, String saleId, String currency, BigDecimal amount) { + var messageHeader = new MessageHeader(); + messageHeader.setMessageCategory(MessageCategoryType.PAYMENT); + messageHeader.setMessageClass(MessageClassType.SERVICE); + messageHeader.setMessageType(MessageType.REQUEST); + messageHeader.setPOIID(poiId); + messageHeader.setSaleID(saleId); + messageHeader.setServiceID(serviceId); + + var saleTransactionIdentification = new TransactionIdentification(); + saleTransactionIdentification.setTransactionID(UUID.randomUUID().toString()); + saleTransactionIdentification.setTimeStamp(dataTypeFactory.newXMLGregorianCalendar(new GregorianCalendar())); + + var saleData = new SaleData(); + saleData.setSaleTransactionID(saleTransactionIdentification); + + var amountsReq = new AmountsReq(); + amountsReq.setCurrency(currency); + amountsReq.setRequestedAmount(amount); + + var paymentTransaction = new PaymentTransaction(); + paymentTransaction.setAmountsReq(amountsReq); + + var paymentRequest = new PaymentRequest(); + paymentRequest.setSaleData(saleData); + paymentRequest.setPaymentTransaction(paymentTransaction); + + var saleToPOIRequest = new SaleToPOIRequest(); + saleToPOIRequest.setMessageHeader(messageHeader); + saleToPOIRequest.setPaymentRequest(paymentRequest); + + var terminalAPIRequest = new TerminalAPIRequest(); + terminalAPIRequest.setSaleToPOIRequest(saleToPOIRequest); + + return terminalAPIRequest; + } +} \ No newline at end of file diff --git a/in-person-payments-example/src/main/java/com/adyen/ipp/service/PosReversalService.java b/in-person-payments-example/src/main/java/com/adyen/ipp/service/PosReversalService.java new file mode 100644 index 00000000..a1480dcf --- /dev/null +++ b/in-person-payments-example/src/main/java/com/adyen/ipp/service/PosReversalService.java @@ -0,0 +1,71 @@ +package com.adyen.ipp.service; + +import com.adyen.ipp.util.IdUtility; +import com.adyen.model.nexo.*; +import com.adyen.model.terminal.TerminalAPIRequest; +import com.adyen.model.terminal.TerminalAPIResponse; +import com.adyen.service.exception.ApiException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import javax.xml.datatype.DatatypeConfigurationException; +import javax.xml.datatype.DatatypeFactory; +import java.io.IOException; +import java.util.GregorianCalendar; + +@Service +public class PosReversalService { + @Autowired + private TerminalCloudApiService terminalCloudAPIService; + + private DatatypeFactory dataTypeFactory; + + public PosReversalService() throws DatatypeConfigurationException { + dataTypeFactory = DatatypeFactory.newInstance(); + } + + public TerminalAPIResponse sendReversalRequest(ReversalReasonType reversalReasonType, String saleTransactionId, String poiTransactionId, String poiId, String saleId) throws IOException, ApiException { + TerminalAPIRequest request = getReversalRequest(reversalReasonType, saleTransactionId, poiTransactionId, poiId, saleId); + return terminalCloudAPIService.getTerminalCloudApi().sync(request); + } + + private TerminalAPIRequest getReversalRequest(ReversalReasonType reversalReasonType, String saleTransactionId, String poiTransactionId, String poiId, String saleId) { + var messageHeader = new MessageHeader(); + messageHeader.setMessageCategory(MessageCategoryType.REVERSAL); + messageHeader.setMessageClass(MessageClassType.SERVICE); + messageHeader.setMessageType(MessageType.REQUEST); + messageHeader.setPOIID(poiId); + messageHeader.setSaleID(saleId); + messageHeader.setServiceID(IdUtility.getRandomAlphanumericId(10)); + + var poiTransactionIdentification = new TransactionIdentification(); + poiTransactionIdentification.setTransactionID(poiTransactionId); + poiTransactionIdentification.setTimeStamp(dataTypeFactory.newXMLGregorianCalendar(new GregorianCalendar())); + + var originalPOITransaction = new OriginalPOITransaction(); + originalPOITransaction.setPOIID(poiId); + originalPOITransaction.setSaleID(saleId); + originalPOITransaction.setPOITransactionID(poiTransactionIdentification); + + var saleTransactionIdentification = new TransactionIdentification(); + saleTransactionIdentification.setTransactionID(saleTransactionId); + saleTransactionIdentification.setTimeStamp(dataTypeFactory.newXMLGregorianCalendar(new GregorianCalendar())); + + var saleData = new SaleData(); + saleData.setSaleTransactionID(saleTransactionIdentification); + + var reversalRequest = new ReversalRequest(); + reversalRequest.setReversalReason(reversalReasonType); + reversalRequest.setOriginalPOITransaction(originalPOITransaction); + reversalRequest.setSaleData(saleData); + + var saleToPOIRequest = new SaleToPOIRequest(); + saleToPOIRequest.setMessageHeader(messageHeader); + saleToPOIRequest.setReversalRequest(reversalRequest); + + var terminalAPIRequest = new TerminalAPIRequest(); + terminalAPIRequest.setSaleToPOIRequest(saleToPOIRequest); + + return terminalAPIRequest; + } +} \ No newline at end of file diff --git a/in-person-payments-example/src/main/java/com/adyen/ipp/service/PosTransactionStatusService.java b/in-person-payments-example/src/main/java/com/adyen/ipp/service/PosTransactionStatusService.java new file mode 100644 index 00000000..b369617a --- /dev/null +++ b/in-person-payments-example/src/main/java/com/adyen/ipp/service/PosTransactionStatusService.java @@ -0,0 +1,52 @@ +package com.adyen.ipp.service; + +import com.adyen.ipp.util.IdUtility; +import com.adyen.model.nexo.*; +import com.adyen.model.terminal.TerminalAPIRequest; +import com.adyen.model.terminal.TerminalAPIResponse; +import com.adyen.service.exception.ApiException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.io.IOException; + +@Service +public class PosTransactionStatusService { + @Autowired + private TerminalCloudApiService terminalCloudAPIService; + + public TerminalAPIResponse sendTransactionStatusRequest(String serviceId, String poiId, String saleId) throws IOException, ApiException { + TerminalAPIRequest request = getTransactionStatusRequest(serviceId, poiId, saleId); + return terminalCloudAPIService.getTerminalCloudApi().sync(request); + } + + private TerminalAPIRequest getTransactionStatusRequest(String serviceId, String poiId, String saleId) { + var messageHeader = new MessageHeader(); + messageHeader.setMessageCategory(MessageCategoryType.TRANSACTION_STATUS); + messageHeader.setMessageClass(MessageClassType.SERVICE); + messageHeader.setMessageType(MessageType.REQUEST); + messageHeader.setPOIID(poiId); + messageHeader.setSaleID(saleId); + messageHeader.setServiceID(IdUtility.getRandomAlphanumericId(10)); + + var messageReference = new MessageReference(); + messageReference.setMessageCategory(MessageCategoryType.PAYMENT); + messageReference.setServiceID(serviceId); + messageReference.setSaleID(saleId); + + var transactionStatusRequest = new TransactionStatusRequest(); + transactionStatusRequest.setMessageReference(messageReference); + transactionStatusRequest.setReceiptReprintFlag(true); + transactionStatusRequest.getDocumentQualifier().add(DocumentQualifierType.CASHIER_RECEIPT); + transactionStatusRequest.getDocumentQualifier().add(DocumentQualifierType.CUSTOMER_RECEIPT); + + var saleToPOIRequest = new SaleToPOIRequest(); + saleToPOIRequest.setMessageHeader(messageHeader); + saleToPOIRequest.setTransactionStatusRequest(transactionStatusRequest); + + var terminalAPIRequest = new TerminalAPIRequest(); + terminalAPIRequest.setSaleToPOIRequest(saleToPOIRequest); + + return terminalAPIRequest; + } +} \ No newline at end of file diff --git a/in-person-payments-example/src/main/java/com/adyen/ipp/service/TableService.java b/in-person-payments-example/src/main/java/com/adyen/ipp/service/TableService.java new file mode 100644 index 00000000..1f3658b9 --- /dev/null +++ b/in-person-payments-example/src/main/java/com/adyen/ipp/service/TableService.java @@ -0,0 +1,31 @@ +package com.adyen.ipp.service; + +import com.adyen.ipp.model.*; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.util.ArrayList; + +@Service +public class TableService { + private ArrayList tables; + + public TableService() { + tables = new ArrayList<>(); + + for (int i = 0; i < 4; i++) { + int tableNumber = i + 1; + var table = new Table(); + table.setTableName("Table " + tableNumber); + table.setAmount(BigDecimal.valueOf(22.22).multiply(BigDecimal.valueOf(tableNumber))); + table.setCurrency("EUR"); + table.setPaymentStatus(PaymentStatus.NotPaid); + table.setPaymentStatusDetails(new PaymentStatusDetails()); + tables.add(table); + } + } + + public ArrayList
getTables() { + return tables; + } +} \ No newline at end of file diff --git a/in-person-payments-example/src/main/java/com/adyen/ipp/service/TerminalCloudApiService.java b/in-person-payments-example/src/main/java/com/adyen/ipp/service/TerminalCloudApiService.java new file mode 100644 index 00000000..bef2e276 --- /dev/null +++ b/in-person-payments-example/src/main/java/com/adyen/ipp/service/TerminalCloudApiService.java @@ -0,0 +1,31 @@ +package com.adyen.ipp.service; + +import com.adyen.Client; +import com.adyen.enums.Environment; +import com.adyen.ipp.ApplicationProperty; +import com.adyen.service.TerminalCloudAPI; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +@Service +public class TerminalCloudApiService { + private ApplicationProperty applicationProperty; + private Client client; + private TerminalCloudAPI terminalCloudAPI; + + @Autowired + public TerminalCloudApiService(ApplicationProperty applicationProperty) { + this.applicationProperty = applicationProperty; + + if(applicationProperty.getApiKey() == null) { + throw new RuntimeException("ADYEN_API_KEY is UNDEFINED"); + } + + client = new Client(applicationProperty.getApiKey(), Environment.TEST); + terminalCloudAPI = new TerminalCloudAPI(client); + } + + public TerminalCloudAPI getTerminalCloudApi() { + return terminalCloudAPI; + } +} diff --git a/in-person-payments-example/src/main/java/com/adyen/ipp/util/IdUtility.java b/in-person-payments-example/src/main/java/com/adyen/ipp/util/IdUtility.java new file mode 100644 index 00000000..0a10b3f3 --- /dev/null +++ b/in-person-payments-example/src/main/java/com/adyen/ipp/util/IdUtility.java @@ -0,0 +1,24 @@ +package com.adyen.ipp.util; + +import java.security.SecureRandom; + +public class IdUtility { + private static final String ALPHANUMERIC_CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + private static final SecureRandom RANDOM = new SecureRandom(); + + /** + * Gets a random selection from any {@code ALPHANUMERIC_CHARACTERS} with the specified {@code length}. + * + * @param length Length of the generated id. + * @return Alphanumeric Id. + */ + public static String getRandomAlphanumericId(int length) { + StringBuilder sb = new StringBuilder(); + + for (int i = 0; i < length; i++) { + sb.append(ALPHANUMERIC_CHARACTERS.charAt(RANDOM.nextInt(ALPHANUMERIC_CHARACTERS.length()))); + } + + return sb.toString(); + } +} diff --git a/in-person-payments-example/src/main/java/com/adyen/ipp/web/InPersonPaymentsWebController.java b/in-person-payments-example/src/main/java/com/adyen/ipp/web/InPersonPaymentsWebController.java new file mode 100644 index 00000000..c3b517ff --- /dev/null +++ b/in-person-payments-example/src/main/java/com/adyen/ipp/web/InPersonPaymentsWebController.java @@ -0,0 +1,97 @@ +package com.adyen.ipp.web; + +import com.adyen.ipp.ApplicationProperty; +import com.adyen.ipp.model.Table; +import com.adyen.ipp.service.PosTransactionStatusService; +import com.adyen.ipp.service.TableService; +import com.adyen.service.exception.ApiException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; + +import java.io.IOException; + +@Controller +public class InPersonPaymentsWebController { + private final Logger log = LoggerFactory.getLogger(InPersonPaymentsWebController.class); + + private final ApplicationProperty applicationProperty; + + @Autowired + private TableService tableService; + + @Autowired + private PosTransactionStatusService posTransactionStatusService; + + @Autowired + public InPersonPaymentsWebController(ApplicationProperty applicationProperty) { + this.applicationProperty = applicationProperty; + } + + @GetMapping("/") + public String index() { + return "index"; + } + + @GetMapping("/cashregister") + public String cashregister(Model model) { + model.addAttribute("poiId", applicationProperty.getPoiId()); + model.addAttribute("saleId", applicationProperty.getSaleId()); + model.addAttribute("tables", tableService.getTables()); + return "cashregister"; + } + + @GetMapping("/transaction-status/{tableName}") + public String transactionstatus(@PathVariable String tableName, Model model) throws IOException, ApiException { + Table table = tableService.getTables().stream() + .filter(t -> t.getTableName().equals(tableName)) + .findFirst() + .orElse(null); + + if (table == null || table.getPaymentStatusDetails() == null || table.getPaymentStatusDetails().getServiceId() == null) { + model.addAttribute("errorMessage", "table not found"); + return "transactionstatus"; + } + + var response = posTransactionStatusService.sendTransactionStatusRequest(table.getPaymentStatusDetails().getServiceId(), applicationProperty.getPoiId(), applicationProperty.getSaleId()); + + if (response == null || + response.getSaleToPOIResponse() == null || + response.getSaleToPOIResponse().getTransactionStatusResponse() == null) { + model.addAttribute("errorMessage", "transaction status response is empty"); + return "transactionstatus"; + } + + var transactionStatusResponse = response.getSaleToPOIResponse().getTransactionStatusResponse(); + + if (transactionStatusResponse.getRepeatedMessageResponse() == null || + transactionStatusResponse.getRepeatedMessageResponse().getRepeatedResponseMessageBody() == null || + transactionStatusResponse.getRepeatedMessageResponse().getRepeatedResponseMessageBody().getPaymentResponse() == null) { + model.addAttribute("errorMessage", "repeated message response is empty"); + return "transactionstatus"; + } + + var paymentResponse = transactionStatusResponse.getRepeatedMessageResponse().getRepeatedResponseMessageBody().getPaymentResponse(); + + model.addAttribute("paymentResponse", paymentResponse); + + return "transactionstatus"; + } + + @GetMapping("/result/{type}/{refusalReason}") + public String result(@PathVariable String type, @PathVariable(required = false) String refusalReason, Model model) { + model.addAttribute("type", type); + model.addAttribute("refusalReason", refusalReason); + return "result"; + } + + @GetMapping("/result/{type}") + public String result(@PathVariable String type, Model model) { + model.addAttribute("type", type); + return "result"; + } +} diff --git a/in-person-payments-example/src/main/resources/application.properties b/in-person-payments-example/src/main/resources/application.properties new file mode 100644 index 00000000..9869363e --- /dev/null +++ b/in-person-payments-example/src/main/resources/application.properties @@ -0,0 +1,4 @@ +spring.jackson.default-property-inclusion=non_null +spring.jackson.serialization.indent_output = true + +server.port=8080 \ No newline at end of file diff --git a/in-person-payments-example/src/main/resources/static/cashregister-bindings.js b/in-person-payments-example/src/main/resources/static/cashregister-bindings.js new file mode 100644 index 00000000..4ed38c44 --- /dev/null +++ b/in-person-payments-example/src/main/resources/static/cashregister-bindings.js @@ -0,0 +1,250 @@ +var abortController; + +// Sends POST request to url +async function sendPostRequest(url, data) { + abortController = new AbortController(); // Used for cancelling the request + const res = await fetch(url, { + method: "POST", + body: data ? JSON.stringify(data) : "", + headers: { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Keep-Alive": "timeout=180, max=180" + }, + signal: abortController.signal + }); + + return await res.json(); +} + +// Sends GET request to URL +async function sendGetRequest(url) { + const res = await fetch(url, { + method: "Get", + headers: { + "Content-Type": "application/json", + "Connection": "keep-alive", + "Keep-Alive": "timeout=180, max=180" + } + }); + + return await res.json(); +} + +// Sends abort request to cancel an on-going transaction for the table +async function sendAbortRequest(tableName) { + try { + var response = await sendGetRequest("/api/abort/" + tableName); + } + catch(error) { + console.warn(error); + } +} + +// Shows loading animation component and deactivates the table selection +function showLoadingComponent() { + document.getElementById('loading-grid').classList.remove('disabled'); + document.getElementById('tables-section').classList.add('disabled'); +} + +// Hides loading animation component and shows table selection selection +function hideLoadingComponent() { + document.getElementById('loading-grid').classList.add('disabled'); + document.getElementById('tables-section').classList.remove('disabled'); +} + +// Bind table selection buttons and the `pay/reversal/transaction-status` submit-buttons +function bindButtons() { + // Bind `payment-request-form` submit-button + const paymentRequestForm = document.getElementById('payment-request-form'); + paymentRequestForm.addEventListener('submit', async function(event) { + event.preventDefault(); + + var formData = new FormData(event.target); + var amount = formData.get('amount'); + var currency = formData.get('currency'); + var tableName = formData.get('tableName'); + + if (amount && currency && tableName) { + try { + // Show loading animation component which doesn't allow users to select any tables + showLoadingComponent(); + + // Send payment request + var response = await sendPostRequest("/api/create-payment", { tableName: tableName, amount: amount, currency: currency }); + console.log(response); + + // Handle response + switch (response.result) { + case "success": + window.location.href = "result/success"; + break; + case "failure": + window.location.href = "result/failure/" + response.refusalReason; + break; + default: + throw Error('Unknown response result'); + } + } + catch (error) { + console.warn(error); + + // Sends an abort request to the terminal + await sendAbortRequest(tableName); + + // Hides loading animation component and allow user to select tables again + hideLoadingComponent(); + } + } + }); + + // Bind `reversal-request-form` submit-button + const reversalForm = document.getElementById('reversal-request-form'); + reversalForm.addEventListener('submit', async function(event) { + event.preventDefault(); + + var formData = new FormData(event.target); + var reversalTableName = formData.get('reversalTableName'); + + if (reversalTableName) { + try { + // Show loading animation component and don't allow user to select any tables + showLoadingComponent(); + + // Send reversal request + var response = await sendPostRequest("/api/create-reversal", { tableName: reversalTableName }); + console.log(response); + + // Handle response + switch (response.result) { + case "success": + window.location.href = "result/success"; + break; + case "failure": + window.location.href = "result/failure/" + response.refusalReason; + break; + default: + throw Error('Unknown response result'); + } + } + catch (error) { + console.warn(error); + + // Hides loading animation component and allow user to select tables again + hideLoadingComponent(); + } + } + }); + + // Bind `cancel-operation-button` + const cancelOperationButton = document.getElementById('cancel-operation-button'); + cancelOperationButton.addEventListener('click', () => { + // Abort sending post request + abortController.abort(); + + // Hide loading animation component + hideLoadingComponent(); + }); + + // Bind `transaction-status-button` + const transactionStatusButton = document.getElementById('transaction-status-button'); + transactionStatusButton.addEventListener('click', async () => { + const tableNameElement = document.getElementById('tableName'); + if (!tableNameElement.value) { + return; + } + + // Go to transaction status page for the given tableNameElement.value + window.location.href = "transaction-status/" + tableNameElement.value; + }); + + // Allows user to select a table by binding all tables to a click event + const tables = document.querySelectorAll('.tables-grid-item'); + tables.forEach(table => { + table.addEventListener('click', function() { + // Remove the 'current-selection' class from all `table-grid-items` + tables.forEach(item => item.classList.remove('current-selection')); + + // Add the 'current-selection' class to the currently selected `tables-grid-item` + table.classList.add('current-selection'); + + // Copies 'amount' value to the `payment-request-form` + const amountElement = document.getElementById('amount'); + amountElement.value = table.querySelector('.tables-grid-item-amount').textContent; + + // Copies 'currency' value to the `payment-request-form` + const currencyElement = document.getElementById('currency'); + currencyElement.value = table.querySelector('.tables-grid-item-currency').textContent; + + // Copies 'table name' value to the `payment-request-form` + const tableNameElement = document.getElementById('tableName'); + tableNameElement.value = table.querySelector('.tables-grid-item-title').textContent; + + // Copies 'table name' value to the `reversal-request-form` + const reversalTableNameElement = document.getElementById('reversalTableName'); + reversalTableNameElement.value = table.querySelector('.tables-grid-item-title').textContent; + + // Show/hides the `payment-request-button` and `reversal-request-button` according to the `PaymentStatus` of currently selected table + const currentActiveTable = document.getElementsByClassName('current-selection')[0]; + var statusValue = currentActiveTable.querySelector('.tables-grid-item-status').textContent; + switch (statusValue) { + case 'NotPaid': + enablePaymentRequestButton(); + disableReversalRequestButton(); + disableTransactionStatusButton(); + break; + case 'Paid': + disablePaymentRequestButton(); + enableReversalRequestButton(); + enableTransactionStatusButton(); + break; + case 'RefundFailed': + disablePaymentRequestButton(); + enableReversalRequestButton(); + enableTransactionStatusButton(); + break; + case 'RefundInProgress': + case 'PaymentInProgress': + case 'Refunded': + case 'RefundedReversed': + default: + disablePaymentRequestButton(); + disableReversalRequestButton(); + enableTransactionStatusButton(); + break; + } + }); + }); +} + +// Enable `payment-request-button` +function enablePaymentRequestButton() { + document.getElementById('payment-request-button').classList.remove('disabled'); +} + +// Disable `payment-request-button` +function disablePaymentRequestButton() { + document.getElementById('payment-request-button').classList.add('disabled'); +} + +// Enable `reversal-request-button` +function enableReversalRequestButton() { + document.getElementById('reversal-request-button').classList.remove('disabled'); +} + +// Disable `reversal-request-button` +function disableReversalRequestButton() { + document.getElementById('reversal-request-button').classList.add('disabled'); +} + +// Enable `transaction-status-button` +function enableTransactionStatusButton() { + document.getElementById('transaction-status-button').classList.remove('disabled'); +} + +// Disable `transaction-status-button` +function disableTransactionStatusButton() { + document.getElementById('transaction-status-button').classList.add('disabled'); +} + +bindButtons(); \ No newline at end of file diff --git a/in-person-payments-example/src/main/resources/static/css/application.css b/in-person-payments-example/src/main/resources/static/css/application.css new file mode 100644 index 00000000..7da9bbc2 --- /dev/null +++ b/in-person-payments-example/src/main/resources/static/css/application.css @@ -0,0 +1,652 @@ +/* General page body */ + +html, +body { + width: auto; + margin: 0; + font-family: "Fakt", sans-serif, Helvetica, Arial; +} + +*, +:after, +:before { + box-sizing: border-box; +} + +a, +u { + text-decoration: none; + color: #0abf53; +} + +a:hover { + text-decoration: none; + color: #00903a; +} + +.hidden { + display: none; +} + +#header { + background: #fff; + border-bottom: 1px solid #e6e9eb; + height: 44px; + left: 0; + margin-bottom: 24px; + padding: 14px 26px; + position: fixed; + text-align: center; + top: 0; + width: 100%; + z-index: 2; + box-sizing: border-box; +} + +/* Buttons */ + +.button { + background: #00112c; + border: 0; + border-radius: 6px; + color: #fff; + cursor: pointer; + display: inline-block; + font-size: 1em; + font-weight: 500; + margin: 0; + padding: 15px; + text-align: center; + transition: background 0.3s ease-out, box-shadow 0.3s ease-out; + width: 100%; +} + +.button:hover { + background: #00230e; + box-shadow: 0 3px 4px rgba(0, 15, 45, 0.2); +} + +.button:active { + background: #3a4a5c; +} + +.button:disabled { + background: #e6e9eb; + box-shadow: none; + cursor: not-allowed; + -webkit-user-select: all; + -moz-user-select: all; + -ms-user-select: all; + user-select: all; +} + +/* end General page body */ + +/* Index page */ + +.main-container { + flex-direction: column; + display: flex; +} + +.integration-list { + display: inline-block; + width: 100%; + margin-left: -16px; +} + +.integration-list-item { + background: #f0f0f0; + border-radius: 8px; + position: relative; + display: flex; + justify-content: center; + align-items: center; + margin: 32px 0px 16px 0px; + transition: transform 0.15s ease-in-out, background 0.15s ease-in-out, border 0.15s ease-in-out; +} + +.integration-list-item:hover { + text-decoration: none; + background: #f2f2f2; + transform: scale(1.02); + border: 1px dashed #0abf53; +} + +.integration-list-item-link { + padding: 20px; + width: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +@media (min-width: 768px) { + .integration-list-item-link { + padding-left: 28px; + padding-bottom: 28px; + padding-right: 28px; + padding-top: 28px; + } +} + +.integration-list-item-title { + margin: 0; + text-align: center; + font-weight: 700; + color: black; +} + +@media (min-width: 768px) { + .integration-list-item-title { + font-size: 24px; + margin-left: 0; + margin-right: 0; + } +} + +.title-container { + display: flex; + flex-direction: column; + align-items: center; +} + +.info { + margin-top: 10%; + color: #00112c; +} + +/* end Index page */ + + +/* Tables-grid start */ + +.tables-section { + text-align: center; + display: flex; + transition: opacity 0.4s ease-in; +} + +.tables-section.disabled { + opacity: 0.2; /* Reduce opacity */ + pointer-events: none; /* Disable pointer events on elements */ +} + +.tables-grid-list { + display: grid; + grid-template-columns: repeat(2, minmax(376px, 1fr)); + gap: 16px; + padding: 16px; + background: #e6e9eb; + margin: 0px; + border-radius: 8px; + border-top-right-radius: 0px; + border-bottom-right-radius: 0px; + border-right: 2px solid #0abf53; +} + +.tables-grid-item { + border: 1px solid #e6e9eb; + border-radius: 8px; + background-color: #f7f7f7; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 0 32px; + cursor: pointer; + transition: background-color 0.3s, transform 0.2s; + width: 100%; + min-height: 320px; +} + +.tables-grid-item:hover { + background-color: #e0e0e0; + transform: scale(1.03); + border-radius: 8px; +} + +.tables-grid-item.current-selection { + background-color: #fff8cd !important; + transform: scale(1.03); + font-weight: 600; + color: white; + border-radius: 16px; + transition: transform 0.3s ease, background-color 0.5s ease; +} + +.tables-grid-item.disabled { + opacity: 0.15; + background-color: #95999c; + pointer-events: none; +} + +.tables-grid-item-title { + display: block; + font-weight: 600; + margin-top: 10px; + font-size: 1.2rem; + color: black; +} + +.tables-grid-item-status { + font-weight: 800; + border: 1px dashed #000000; + padding: 4px 16px 4px 16px; + opacity: 0.7; + color: #000000; + background-color: #f0f0f0; + border-radius: 16px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + transition: all 0.3s ease; +} + +.tables-grid-item-status.not-paid { + background-color: #ffe374 !important; +} + +.tables-grid-item-status.paid { + background-color: #00903a !important; +} + +.tables-grid-item-status.refund-failed { + background-color: #ff0000 !important; +} + +.tables-grid-item-amount { + font-size: 1.2rem; + margin-left: 4px; +} + +.tables-grid-item-currency { + font-size: 1.2rem; +} + +.tables-grid-item-other { + font-weight: 400 !important; + font-size: 1rem; + color: black; +} + +.tables-grid-item-currency-amount { + display: flex; + align-items: center; + border-radius: 16px; + border: 2px dotted black; + padding: 16px 24px 0px 24px; + margin: 0px 8px 8px 8px; + color: black; +} + +/* Tables-grid end */ + +/* Info-grid start */ +.info-grid-list { + padding: 4px; +} + +.info-grid { + text-align: left; + display: grid; + padding: 20px; + background: #e6e9e9; + border-radius: 8px; + border-top-left-radius: 0px; + border-bottom-left-radius: 0px; +} + +.info-grid-item { + display: block; + list-style: none; +} + +.info-grid-item-title { + font-weight: 600; + font-size: 1rem; +} + +.info-grid-item-description { + font-weight: 600; + color: #687282; + font-size: 1rem; +} + +/* Info-grid end */ + + +/* `Payment request` button start */ + +#payment-request-form { + font-weight: 600; +} + +.payment-request-button { + background-color: #0abf53; + color: white; + padding: 8px 64px 8px 64px; + cursor: pointer; + font-weight: bold; + border-radius: 8px; + border: none; + transition: transform 0.15s ease-in-out, background-color 0.3s ease-in-out; + width: 100%; + height: 50%; +} + +.payment-request-button:hover { + background-color: #0abf53; + border: none; + transform: scale(1.04); +} + +.payment-request-button:focus { + outline: none; +} + +.payment-request-button:disabled, +.payment-request-button[disabled=disabled], +.payment-request-button.disabled { + background-color: #cccccc; + color: #999999; + pointer-events: none; +} + +/* Payment request button end */ + +/* Reversal request button start */ + +#reversal-request-form { + font-weight: 600; +} + +.reversal-request-button { + background-color: #0abf53; + color: white; + padding: 8px 64px 8px 64px; + cursor: pointer; + font-weight: bold; + border-radius: 8px; + border: none; + transition: transform 0.15s ease-in-out, background-color 0.3s ease-in-out; + width: 100%; + height: 50%; +} + +.reversal-request-button:hover { + background-color: #0abf53; + border: none; + transform: scale(1.04); +} + +.reversal-request-button:focus { + outline: none; +} + +.reversal-request-button:disabled, +.reversal-request-button[disabled=disabled], +.reversal-request-button.disabled { + background-color: #cccccc; + color: #999999; + pointer-events: none; +} + +/* Reversal request button end */ + +/* Transaction Status button start */ +.transaction-status-button { + background-color: #0abf53; + color: white; + padding: 8px 64px 8px 64px; + cursor: pointer; + font-weight: bold; + border-radius: 8px; + border: none; + transition: transform 0.15s ease-in-out, background-color 0.3s ease-in-out; + width: 100%; + height: 50%; +} + +.transaction-status-button:hover { + background-color: #0abf53; + border: none; + transform: scale(1.04); +} + +.transaction-status-button:focus { + outline: none; +} + +.transaction-status-button:disabled, +.transaction-status-button[disabled=disabled], +.transaction-status-button.disabled { + background-color: #cccccc; + color: #999999; + pointer-events: none; +} + +/* Transaction Status button end */ + +/* Payment page */ + +#payment-page { + display: flex; + flex-direction: column; + align-items: center; +} + +#payment-page .container { + margin-top: 100px; + display: flex; + flex-direction: row; + justify-content: space-between; + width: 100%; + max-width: 1110px; + padding-left: 8px; + padding-right: 8px; +} + +.checkout-component { + background: #f7f8f9; + border: 1px solid #e6e9eb; + border-radius: 12px; + margin: 8px 0; +} + + +/* Status page */ + +.status-container { + height: 100vh; + display: flex; + align-items: center; + justify-content: center; +} + +.status { + margin: 100px 0 126px; + text-align: center; +} + +.status .status-image { + display: block; + height: 100px; + margin: 32px auto 32px auto; + padding: 8px; +} + +.status .status-image-thank-you { + height: 66px; +} + +.status .status-message { + margin: 8px 0 24px; +} + +.status .button { + max-width: 236px; +} + +@media (min-width: 768px) { + .status .button { + max-width: 200px; + } +} + +/* end Status page */ + + +/* Loading grid start */ + +.loading-grid { + text-align: center; + display: block; + padding: 16px 8px 8px 8px; + background: #fff7ea; + border-radius: 8px; + margin: 10px 0px 16px 0px; + transition: opacity 0.4s ease-in; + height: 100%; + width: auto; +} + +.loading-grid.disabled { + opacity: 0; /* Reduce opacity */ + pointer-events: none; /* Disable pointer events on elements */ + height: 0; +} + +.loading-description { + font-weight: 700; + color: #0abf53; + margin: 8px 0px 8px 0px; +} + +/* Loading grid end */ + +/* Cancel operation button start */ +.cancel-operation-button { + background-color: #0abf53; + color: white; + padding: 6px 16px 6px 16px; + margin: 8px 0px 8px 0px; + cursor: pointer; + font-weight: bold; + border: 1px solid #0abf53; + border-radius: 16px; + transition: background-color 0.3s, transform 0.15s; +} + +.cancel-operation-button:hover { + background-color: #00bb4b; + transform: scale(1.04); + border-radius: 16px; +} + +/* Cancel operation button end */ + +/* Loading animation start */ + +.loading-animation { + display: inline-block; + position: relative; + width: 80px; + height: 80px; +} + +.loading-animation div { + animation: loading-animation 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; + transform-origin: 40px 40px; +} + +.loading-animation div:after { + content: " "; + display: block; + position: absolute; + width: 7px; + height: 7px; + border-radius: 50%; + background: #0abf53; + margin: -4px 0 0 -4px; +} + +.loading-animation div:nth-child(1) { + animation-delay: -0.036s; +} + +.loading-animation div:nth-child(1):after { + top: 63px; + left: 63px; +} + +.loading-animation div:nth-child(2) { + animation-delay: -0.072s; +} + +.loading-animation div:nth-child(2):after { + top: 68px; + left: 56px; +} + +.loading-animation div:nth-child(3) { + animation-delay: -0.108s; +} + +.loading-animation div:nth-child(3):after { + top: 71px; + left: 48px; +} + +.loading-animation div:nth-child(4) { + animation-delay: -0.144s; +} + +.loading-animation div:nth-child(4):after { + top: 72px; + left: 40px; +} + +.loading-animation div:nth-child(5) { + animation-delay: -0.18s; +} + +.loading-animation div:nth-child(5):after { + top: 71px; + left: 32px; +} + +.loading-animation div:nth-child(6) { + animation-delay: -0.216s; +} + +.loading-animation div:nth-child(6):after { + top: 68px; + left: 24px; +} + +.loading-animation div:nth-child(7) { + animation-delay: -0.252s; +} + +.loading-animation div:nth-child(7):after { + top: 63px; + left: 17px; +} + +.loading-animation div:nth-child(8) { + animation-delay: -0.288s; +} + +.loading-animation div:nth-child(8):after { + top: 56px; + left: 12px; +} + +@keyframes loading-animation { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} +/* Loading bar end */ \ No newline at end of file diff --git a/in-person-payments-example/src/main/resources/static/images/.keep b/in-person-payments-example/src/main/resources/static/images/.keep new file mode 100644 index 00000000..e69de29b diff --git a/in-person-payments-example/src/main/resources/static/images/cardinpersonpayments.gif b/in-person-payments-example/src/main/resources/static/images/cardinpersonpayments.gif new file mode 100644 index 00000000..deba2dfa Binary files /dev/null and b/in-person-payments-example/src/main/resources/static/images/cardinpersonpayments.gif differ diff --git a/in-person-payments-example/src/main/resources/static/images/error.svg b/in-person-payments-example/src/main/resources/static/images/error.svg new file mode 100644 index 00000000..4db9773b --- /dev/null +++ b/in-person-payments-example/src/main/resources/static/images/error.svg @@ -0,0 +1,4 @@ + + + + diff --git a/in-person-payments-example/src/main/resources/static/images/failure.svg b/in-person-payments-example/src/main/resources/static/images/failure.svg new file mode 100644 index 00000000..4db9773b --- /dev/null +++ b/in-person-payments-example/src/main/resources/static/images/failure.svg @@ -0,0 +1,4 @@ + + + + diff --git a/in-person-payments-example/src/main/resources/static/images/mystore-logo.svg b/in-person-payments-example/src/main/resources/static/images/mystore-logo.svg new file mode 100644 index 00000000..0418b242 --- /dev/null +++ b/in-person-payments-example/src/main/resources/static/images/mystore-logo.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/in-person-payments-example/src/main/resources/static/images/pending.svg b/in-person-payments-example/src/main/resources/static/images/pending.svg new file mode 100644 index 00000000..465c4b46 --- /dev/null +++ b/in-person-payments-example/src/main/resources/static/images/pending.svg @@ -0,0 +1,24 @@ + + + + Icon + Created with Sketch. + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/in-person-payments-example/src/main/resources/static/images/success.svg b/in-person-payments-example/src/main/resources/static/images/success.svg new file mode 100644 index 00000000..465c4b46 --- /dev/null +++ b/in-person-payments-example/src/main/resources/static/images/success.svg @@ -0,0 +1,24 @@ + + + + Icon + Created with Sketch. + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/in-person-payments-example/src/main/resources/static/images/thank-you.svg b/in-person-payments-example/src/main/resources/static/images/thank-you.svg new file mode 100644 index 00000000..587d3b5b --- /dev/null +++ b/in-person-payments-example/src/main/resources/static/images/thank-you.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/in-person-payments-example/src/main/resources/templates/cashregister.html b/in-person-payments-example/src/main/resources/templates/cashregister.html new file mode 100644 index 00000000..4bafc1dc --- /dev/null +++ b/in-person-payments-example/src/main/resources/templates/cashregister.html @@ -0,0 +1,90 @@ + + + Adyen Cash Register View + + +
+
+

In-Person Payments Demo

+
+ +
+ +
Waiting for terminal response ...
+ +
+
+
+
    +
  • +

    +
    +

    +

    +
    +

    + Sale Transaction ID:
    +

    +

    + Sale Transaction Timestamp:
    +

    +

    + POI Transaction ID:
    +

    +

    + POI Transaction Timestamp:
    +

    +
    +

    NotPaid

    +

    PaymentInProgress

    + +

    RefundInProgress

    +

    Refunded

    +

    RefundFailed

    +

    RefundedReversed

    +

    +
    +
  • +
+
+
+

Cash Register

+
    +
  • +

    + SALE ID: + +

    +
  • +
  • +

    + POI ID: + +

    +
  • +
+
+ + + + + + +
+ + + + + +
+
+ + + +
+
+ + diff --git a/in-person-payments-example/src/main/resources/templates/index.html b/in-person-payments-example/src/main/resources/templates/index.html new file mode 100644 index 00000000..6aa4de78 --- /dev/null +++ b/in-person-payments-example/src/main/resources/templates/index.html @@ -0,0 +1,65 @@ + + + Adyen In-person Payment Demo + + +
+ +
+
+

Adyen In-person Payment Demo

+

+ This demo shows developers how to use the Adyen Terminal API /terminal-api/sync to make requests to your connected terminal. +

+

+ There are typically two ways to integrate in-person payments: local or cloud communications. This demo focuses on a cloud communication integration /terminal-api/sync/ endpoint. + To find out which solution (or hybrid) suits your needs, go to this documentation page. +

+

+ This demo showcases four main functionalities: +

+

+

+ Make sure to add the payment method of your TEST card to your Customer Area. +

+

+ To learn more about our in-person payment integrations, check out our Terminal API integration checklist. +

+
+ + +
+
+ + diff --git a/in-person-payments-example/src/main/resources/templates/layout.html b/in-person-payments-example/src/main/resources/templates/layout.html new file mode 100644 index 00000000..7f1f0626 --- /dev/null +++ b/in-person-payments-example/src/main/resources/templates/layout.html @@ -0,0 +1,36 @@ + + + + + Adyen - In-person Payments Demo + + + + + + + + + + + + + +
+ +
+ + diff --git a/in-person-payments-example/src/main/resources/templates/result.html b/in-person-payments-example/src/main/resources/templates/result.html new file mode 100644 index 00000000..bffd9978 --- /dev/null +++ b/in-person-payments-example/src/main/resources/templates/result.html @@ -0,0 +1,25 @@ + + + + + +
+
+ + + +

+ Success! + + + Learn how to resolve Terminal API errors and handle declined payments in + our documentation. + +

+ Return +
+
+ diff --git a/in-person-payments-example/src/main/resources/templates/transactionstatus.html b/in-person-payments-example/src/main/resources/templates/transactionstatus.html new file mode 100644 index 00000000..1e7481c8 --- /dev/null +++ b/in-person-payments-example/src/main/resources/templates/transactionstatus.html @@ -0,0 +1,34 @@ + + + + + +
+
+

Transaction Status for [[${tableName}]]

+

+ ServiceId: [[${serviceId}]] +

+ +
+

+ Payment Response Result: [[${paymentResponse.response.result}]] +

+

+ POI Transaction ID: [[${paymentResponse.getPOIData().getPOITransactionID().getTransactionID()}]]
+ POI Transaction TimeStamp: [[${paymentResponse.getPOIData().getPOITransactionID().getTimeStamp()}]]
+ Sale Transaction ID: [[${paymentResponse.getSaleData().getSaleTransactionID().getTransactionID()}]]
+ Sale Transaction TimeStamp: [[${paymentResponse.getSaleData().getSaleTransactionID().getTimeStamp()}]]
+ Authorized amount: [[${paymentResponse.getPaymentResult().getAmountsResp().getCurrency()}]] [[${paymentResponse.getPaymentResult().getAmountsResp().getAuthorizedAmount()}]]
+

+
+

+ [[${errorMessage}]] +

+ Return +
+
+ \ No newline at end of file diff --git a/in-person-payments-example/src/test/java/com/adyen/ipp/InPersonPaymentsApplicationTests.java b/in-person-payments-example/src/test/java/com/adyen/ipp/InPersonPaymentsApplicationTests.java new file mode 100644 index 00000000..791c1263 --- /dev/null +++ b/in-person-payments-example/src/test/java/com/adyen/ipp/InPersonPaymentsApplicationTests.java @@ -0,0 +1,21 @@ +package com.adyen.ipp; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.event.annotation.BeforeTestClass; + +@SpringBootTest +class InPersonPaymentsApplicationTests { + + @BeforeAll + public static void onceExecutedBeforeAll() { + System.setProperty("ADYEN_API_KEY", "testKey"); + } + + @AfterAll + public static void onceExecutedAfterAll(){ + System.clearProperty("ADYEN_API_KEY"); + } +} diff --git a/in-person-payments-example/startDocker.sh b/in-person-payments-example/startDocker.sh new file mode 100755 index 00000000..d5b22c61 --- /dev/null +++ b/in-person-payments-example/startDocker.sh @@ -0,0 +1,4 @@ +docker run \ +-e ADYEN_HMAC_KEY \ +-e ADYEN_API_KEY \ +-p8080:8080 adyen-java-spring-in-person-payments-example:latest diff --git a/settings.gradle.kts b/settings.gradle.kts index df5537e1..6f23f68a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -2,7 +2,8 @@ rootProject.name = "adyen-sample-multiproject" include("checkout-example") include("checkout-example-advanced") -include("gitcard-example") +include("giftcard-example") +include("in-person-payments-example") include("giving-example") include("paybylink-example") include("subscription-example") diff --git a/subscription-example/src/main/java/com/adyen/checkout/api/SubscriptionResource.java b/subscription-example/src/main/java/com/adyen/checkout/api/SubscriptionResource.java index 5df51346..70a4ff74 100644 --- a/subscription-example/src/main/java/com/adyen/checkout/api/SubscriptionResource.java +++ b/subscription-example/src/main/java/com/adyen/checkout/api/SubscriptionResource.java @@ -40,7 +40,7 @@ public SubscriptionResource(ApplicationProperty applicationProperty) { log.warn("ADYEN_KEY is UNDEFINED"); throw new RuntimeException("ADYEN_KEY is UNDEFINED"); } - + var client = new Client(applicationProperty.getApiKey(), Environment.TEST); this.paymentsApi = new PaymentsApi(client); } diff --git a/subscription-example/src/main/resources/templates/admin/index.html b/subscription-example/src/main/resources/templates/admin/index.html index 01c34cec..2816ee16 100644 --- a/subscription-example/src/main/resources/templates/admin/index.html +++ b/subscription-example/src/main/resources/templates/admin/index.html @@ -24,14 +24,16 @@

ADMIN PANEL

- ShopperReference: shopperReference here

+ ShopperReference: shopperReference here

  • - PaymentMethod: payment method here - RecurringDetailReference: token here - Make Payment | - Disable + PaymentMethod: payment method here
  • +
  • + RecurringDetailReference: token here +
  • + Make Payment | + Disable