Wave Apps provides accounting, invoicing, and payment processing for small businesses. This guide covers complete integration with Wave's GraphQL API in Java/Spring Boot.
1. Project Setup and Dependencies
Maven Dependencies (pom.xml)
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>wave-apps-integration</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring.boot.version>2.7.14</spring.boot.version>
<jackson.version>2.15.2</jackson.version>
<okhttp.version>4.11.0</okhttp.version>
<graphql.java.version>20.4</graphql.java.version>
</properties>
<dependencies>
<!-- Spring Boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>${spring.boot.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
<version>${spring.boot.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
<version>${spring.boot.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
<version>${spring.boot.version}</version>
</dependency>
<!-- HTTP Client -->
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>${okhttp.version}</version>
</dependency>
<!-- JSON Processing -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
<version>${jackson.version}</version>
</dependency>
<!-- GraphQL Java -->
<dependency>
<groupId>com.graphql-java</groupId>
<artifactId>graphql-java</artifactId>
<version>${graphql.java.version}</version>
</dependency>
<!-- Database -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Cache -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>3.1.6</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring.boot.version}</version>
</plugin>
</plugins>
</build>
</project>
2. Configuration Classes
Application Properties (application.yml)
# Wave Apps Configuration
wave:
api:
base-url: ${WAVE_API_URL:https://gql.waveapps.com/graphql/public}
access-token: ${WAVE_ACCESS_TOKEN:}
business-id: ${WAVE_BUSINESS_ID:}
timeout: 30000
max-retries: 3
webhook-secret: ${WAVE_WEBHOOK_SECRET:}
# Cache Configuration
cache:
wave:
customers:
ttl: 3600 # 1 hour
products:
ttl: 3600
# Server Configuration
server:
port: 8080
# Database
spring:
datasource:
url: jdbc:h2:mem:testdb
driverClassName: org.h2.Driver
username: sa
password:
jpa:
database-platform: org.hibernate.dialect.H2Dialect
hibernate:
ddl-auto: create-drop
show-sql: true
h2:
console:
enabled: true
path: /h2-console
# Logging
logging:
level:
com.example.wave: DEBUG
Wave Configuration Class
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "wave.api")
public class WaveConfig {
private String baseUrl;
private String accessToken;
private String businessId;
private int timeout = 30000;
private int maxRetries = 3;
private String webhookSecret;
}
3. Database Entities
Invoice Entity
import lombok.Data;
import javax.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
@Entity
@Table(name = "invoices")
@Data
public class Invoice {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "wave_invoice_id", unique = true)
private String waveInvoiceId;
@Column(name = "invoice_number")
private String invoiceNumber;
@Column(name = "customer_id")
private String customerId;
@Column(name = "customer_name")
private String customerName;
@Column(name = "invoice_date")
private LocalDateTime invoiceDate;
@Column(name = "due_date")
private LocalDateTime dueDate;
@Column(name = "status")
@Enumerated(EnumType.STRING)
private InvoiceStatus status;
@Column(name = "sub_total", precision = 15, scale = 2)
private BigDecimal subTotal;
@Column(name = "total_tax", precision = 15, scale = 2)
private BigDecimal totalTax;
@Column(name = "total_amount", precision = 15, scale = 2)
private BigDecimal totalAmount;
@Column(name = "amount_due", precision = 15, scale = 2)
private BigDecimal amountDue;
@Column(name = "currency")
private String currency = "USD";
@Column(name = "memo")
private String memo;
@Column(name = "footer")
private String footer;
@Column(name = "payment_terms")
private String paymentTerms;
@Column(name = "created_at")
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@OneToMany(mappedBy = "invoice", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<InvoiceItem> items;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
public enum InvoiceStatus {
DRAFT, SAVED, SENT, PARTIALLY_PAID, PAID, OVERDUE, VOIDED
}
}
Customer Entity
import lombok.Data;
import javax.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "customers")
@Data
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "wave_customer_id", unique = true)
private String waveCustomerId;
@Column(name = "name")
private String name;
@Column(name = "email")
private String email;
@Column(name = "phone")
private String phone;
@Column(name = "address_line1")
private String addressLine1;
@Column(name = "address_line2")
private String addressLine2;
@Column(name = "city")
private String city;
@Column(name = "province")
private String province;
@Column(name = "country")
private String country;
@Column(name = "postal_code")
private String postalCode;
@Column(name = "currency")
private String currency = "USD";
@Column(name = "created_at")
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}
Product/Service Entity
import lombok.Data;
import javax.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Entity
@Table(name = "products")
@Data
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "wave_product_id", unique = true)
private String waveProductId;
@Column(name = "name")
private String name;
@Column(name = "description")
private String description;
@Column(name = "unit_price", precision = 15, scale = 2)
private BigDecimal unitPrice;
@Column(name = "currency")
private String currency = "USD";
@Column(name = "income_account_id")
private String incomeAccountId;
@Column(name = "expense_account_id")
private String expenseAccountId;
@Column(name = "is_sold")
private Boolean isSold = true;
@Column(name = "is_bought")
private Boolean isBought = false;
@Column(name = "created_at")
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}
4. Data Transfer Objects (DTOs)
Invoice Request DTO
import lombok.Data;
import javax.validation.constraints.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
@Data
public class InvoiceRequestDTO {
@NotBlank(message = "Customer ID is required")
private String customerId;
private LocalDateTime invoiceDate;
private LocalDateTime dueDate;
@NotBlank(message = "Currency is required")
private String currency = "USD";
private String memo;
private String footer;
private String paymentTerms;
@NotNull(message = "Items are required")
@Size(min = 1, message = "At least one item is required")
private List<InvoiceItemDTO> items;
@Data
public static class InvoiceItemDTO {
@NotBlank(message = "Product ID is required")
private String productId;
private String description;
@NotNull(message = "Quantity is required")
@DecimalMin(value = "0.01", message = "Quantity must be greater than 0")
private BigDecimal quantity;
@NotNull(message = "Unit price is required")
@DecimalMin(value = "0.00", message = "Unit price must be non-negative")
private BigDecimal unitPrice;
private BigDecimal discountAmount = BigDecimal.ZERO;
private BigDecimal discountRate = BigDecimal.ZERO;
@NotBlank(message = "Account ID is required")
private String accountId;
private List<InvoiceItemTaxDTO> taxes;
}
@Data
public static class InvoiceItemTaxDTO {
@NotBlank(message = "Tax ID is required")
private String taxId;
private BigDecimal amount;
private BigDecimal rate;
}
}
Customer Request DTO
import lombok.Data;
import javax.validation.constraints.NotBlank;
@Data
public class CustomerRequestDTO {
@NotBlank(message = "Customer name is required")
private String name;
private String email;
private String phone;
private String addressLine1;
private String addressLine2;
private String city;
private String province;
private String country = "US";
private String postalCode;
private String currency = "USD";
}
GraphQL Query DTO
import lombok.Data;
@Data
public class GraphQLQueryDTO {
private String query;
private Object variables;
private String operationName;
public GraphQLQueryDTO(String query) {
this.query = query;
}
public GraphQLQueryDTO(String query, Object variables) {
this.query = query;
this.variables = variables;
}
public GraphQLQueryDTO(String query, Object variables, String operationName) {
this.query = query;
this.variables = variables;
this.operationName = operationName;
}
}
5. GraphQL Query Templates
GraphQL Query Constants
public class WaveGraphQLQueries {
// Customer Queries
public static final String GET_CUSTOMERS = """
query GetCustomers($businessId: ID!, $page: Int, $pageSize: Int) {
business(id: $businessId) {
id
customers(page: $page, pageSize: $pageSize) {
pageInfo {
currentPage
totalPages
totalCount
}
edges {
node {
id
name
firstName
lastName
email
phone
address {
addressLine1
addressLine2
city
province {
code
name
}
country {
code
name
}
postalCode
}
currency {
code
}
createdAt
modifiedAt
}
}
}
}
}
""";
public static final String GET_CUSTOMER_BY_ID = """
query GetCustomer($businessId: ID!, $customerId: ID!) {
business(id: $businessId) {
id
customer(id: $customerId) {
id
name
firstName
lastName
email
phone
address {
addressLine1
addressLine2
city
province {
code
name
}
country {
code
name
}
postalCode
}
currency {
code
}
createdAt
modifiedAt
}
}
}
""";
public static final String CREATE_CUSTOMER = """
mutation CreateCustomer($input: CustomerCreateInput!) {
customerCreate(input: $input) {
didSucceed
inputErrors {
code
message
path
}
customer {
id
name
email
phone
address {
addressLine1
addressLine2
city
province {
code
}
country {
code
}
postalCode
}
currency {
code
}
}
}
}
""";
// Invoice Queries
public static final String GET_INVOICES = """
query GetInvoices($businessId: ID!, $page: Int, $pageSize: Int, $status: InvoiceStatus) {
business(id: $businessId) {
id
invoices(page: $page, pageSize: $pageSize, status: $status) {
pageInfo {
currentPage
totalPages
totalCount
}
edges {
node {
id
invoiceNumber
createdAt
modifiedAt
invoiceDate
dueDate
status
customer {
id
name
}
currency {
code
}
subTotal {
value
}
totalTax {
value
}
totalAmount {
value
}
amountDue {
value
}
memo
footer
items {
product {
id
name
}
description
quantity
unitPrice
subtotal {
value
}
totalTax {
value
}
total {
value
}
}
}
}
}
}
}
""";
public static final String GET_INVOICE_BY_ID = """
query GetInvoice($businessId: ID!, $invoiceId: ID!) {
business(id: $businessId) {
id
invoice(id: $invoiceId) {
id
invoiceNumber
createdAt
modifiedAt
invoiceDate
dueDate
status
customer {
id
name
email
phone
address {
addressLine1
addressLine2
city
province {
code
name
}
country {
code
name
}
postalCode
}
}
currency {
code
}
subTotal {
value
}
totalTax {
value
}
totalAmount {
value
}
amountDue {
value
}
memo
footer
items {
product {
id
name
}
description
quantity
unitPrice
subtotal {
value
}
totalTax {
value
}
total {
value
}
taxes {
amount {
value
}
salesTax {
id
name
rate
}
}
}
}
}
}
""";
public static final String CREATE_INVOICE = """
mutation CreateInvoice($input: InvoiceCreateInput!) {
invoiceCreate(input: $input) {
didSucceed
inputErrors {
code
message
path
}
invoice {
id
invoiceNumber
invoiceDate
dueDate
status
customer {
id
name
}
subTotal {
value
}
totalTax {
value
}
totalAmount {
value
}
amountDue {
value
}
items {
product {
id
name
}
description
quantity
unitPrice
subtotal {
value
}
totalTax {
value
}
total {
value
}
}
}
}
}
""";
public static final String SEND_INVOICE = """
mutation SendInvoice($input: InvoiceSendInput!) {
invoiceSend(input: $input) {
didSucceed
inputErrors {
code
message
path
}
invoice {
id
status
lastSentAt
lastSentVia
}
}
}
""";
// Product Queries
public static final String GET_PRODUCTS = """
query GetProducts($businessId: ID!, $page: Int, $pageSize: Int) {
business(id: $businessId) {
id
products(page: $page, pageSize: $pageSize, isSold: true) {
pageInfo {
currentPage
totalPages
totalCount
}
edges {
node {
id
name
description
unitPrice
currency {
code
}
incomeAccount {
id
name
}
expenseAccount {
id
name
}
isSold
isBought
createdAt
modifiedAt
}
}
}
}
}
""";
public static final String CREATE_PRODUCT = """
mutation CreateProduct($input: ProductCreateInput!) {
productCreate(input: $input) {
didSucceed
inputErrors {
code
message
path
}
product {
id
name
description
unitPrice
currency {
code
}
incomeAccount {
id
name
}
expenseAccount {
id
name
}
isSold
isBought
}
}
}
""";
// Business Info Query
public static final String GET_BUSINESS_INFO = """
query GetBusiness($businessId: ID!) {
business(id: $businessId) {
id
name
isPersonal
currency {
code
}
createdAt
modifiedAt
addresses {
addressLine1
addressLine2
city
province {
code
name
}
country {
code
name
}
postalCode
}
}
}
""";
}
6. Wave API Client Service
Main Wave Service
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import okhttp3.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
public class WaveApiClient {
private final WaveConfig config;
private final OkHttpClient httpClient;
private final ObjectMapper objectMapper;
@Autowired
public WaveApiClient(WaveConfig config, ObjectMapper objectMapper) {
this.config = config;
this.objectMapper = objectMapper;
this.httpClient = new OkHttpClient.Builder()
.connectTimeout(config.getTimeout(), TimeUnit.MILLISECONDS)
.readTimeout(config.getTimeout(), TimeUnit.MILLISECONDS)
.writeTimeout(config.getTimeout(), TimeUnit.MILLISECONDS)
.addInterceptor(new AuthInterceptor(config.getAccessToken()))
.addInterceptor(new RetryInterceptor(config.getMaxRetries(), 1000))
.build();
}
/**
* Execute GraphQL query
*/
public Map<String, Object> executeQuery(String query, Map<String, Object> variables) throws WaveApiException {
try {
GraphQLQueryDTO graphQLQuery = new GraphQLQueryDTO(query, variables);
String jsonBody = objectMapper.writeValueAsString(graphQLQuery);
RequestBody body = RequestBody.create(
jsonBody,
MediaType.parse("application/json")
);
Request request = new Request.Builder()
.url(config.getBaseUrl())
.post(body)
.build();
String responseBody = executeRequest(request);
Map<String, Object> response = objectMapper.readValue(responseBody, new TypeReference<Map<String, Object>>() {});
// Check for GraphQL errors
if (response.containsKey("errors")) {
log.error("GraphQL errors: {}", response.get("errors"));
throw new WaveApiException("GraphQL query failed: " + response.get("errors"));
}
return response;
} catch (IOException e) {
log.error("Error executing GraphQL query: {}", e.getMessage(), e);
throw new WaveApiException("Failed to execute GraphQL query", e);
}
}
/**
* Get business information
*/
public Map<String, Object> getBusinessInfo() throws WaveApiException {
Map<String, Object> variables = Map.of("businessId", config.getBusinessId());
return executeQuery(WaveGraphQLQueries.GET_BUSINESS_INFO, variables);
}
/**
* Get customers with pagination
*/
@Cacheable(value = "waveCustomers", key = "{#page, #pageSize}")
public Map<String, Object> getCustomers(Integer page, Integer pageSize) throws WaveApiException {
Map<String, Object> variables = new HashMap<>();
variables.put("businessId", config.getBusinessId());
if (page != null) variables.put("page", page);
if (pageSize != null) variables.put("pageSize", pageSize);
return executeQuery(WaveGraphQLQueries.GET_CUSTOMERS, variables);
}
/**
* Get customer by ID
*/
@Cacheable(value = "waveCustomer", key = "#customerId")
public Map<String, Object> getCustomerById(String customerId) throws WaveApiException {
Map<String, Object> variables = Map.of(
"businessId", config.getBusinessId(),
"customerId", customerId
);
return executeQuery(WaveGraphQLQueries.GET_CUSTOMER_BY_ID, variables);
}
/**
* Create customer
*/
public Map<String, Object> createCustomer(Map<String, Object> customerInput) throws WaveApiException {
Map<String, Object> variables = Map.of("input", customerInput);
return executeQuery(WaveGraphQLQueries.CREATE_CUSTOMER, variables);
}
/**
* Get invoices with pagination and filters
*/
public Map<String, Object> getInvoices(Integer page, Integer pageSize, String status) throws WaveApiException {
Map<String, Object> variables = new HashMap<>();
variables.put("businessId", config.getBusinessId());
if (page != null) variables.put("page", page);
if (pageSize != null) variables.put("pageSize", pageSize);
if (status != null) variables.put("status", status);
return executeQuery(WaveGraphQLQueries.GET_INVOICES, variables);
}
/**
* Get invoice by ID
*/
public Map<String, Object> getInvoiceById(String invoiceId) throws WaveApiException {
Map<String, Object> variables = Map.of(
"businessId", config.getBusinessId(),
"invoiceId", invoiceId
);
return executeQuery(WaveGraphQLQueries.GET_INVOICE_BY_ID, variables);
}
/**
* Create invoice
*/
public Map<String, Object> createInvoice(Map<String, Object> invoiceInput) throws WaveApiException {
Map<String, Object> variables = Map.of("input", invoiceInput);
return executeQuery(WaveGraphQLQueries.CREATE_INVOICE, variables);
}
/**
* Send invoice
*/
public Map<String, Object> sendInvoice(String invoiceId, String deliveryMethod) throws WaveApiException {
Map<String, Object> input = Map.of(
"invoiceId", invoiceId,
"deliveryMethod", deliveryMethod != null ? deliveryMethod : "EMAIL"
);
Map<String, Object> variables = Map.of("input", input);
return executeQuery(WaveGraphQLQueries.SEND_INVOICE, variables);
}
/**
* Get products with pagination
*/
@Cacheable(value = "waveProducts", key = "{#page, #pageSize}")
public Map<String, Object> getProducts(Integer page, Integer pageSize) throws WaveApiException {
Map<String, Object> variables = new HashMap<>();
variables.put("businessId", config.getBusinessId());
if (page != null) variables.put("page", page);
if (pageSize != null) variables.put("pageSize", pageSize);
return executeQuery(WaveGraphQLQueries.GET_PRODUCTS, variables);
}
/**
* Create product
*/
public Map<String, Object> createProduct(Map<String, Object> productInput) throws WaveApiException {
Map<String, Object> variables = Map.of("input", productInput);
return executeQuery(WaveGraphQLQueries.CREATE_PRODUCT, variables);
}
/**
* Delete invoice (Void)
*/
public Map<String, Object> deleteInvoice(String invoiceId) throws WaveApiException {
String deleteInvoiceMutation = """
mutation DeleteInvoice($input: InvoiceDeleteInput!) {
invoiceDelete(input: $input) {
didSucceed
inputErrors {
code
message
path
}
}
}
""";
Map<String, Object> input = Map.of("invoiceId", invoiceId);
Map<String, Object> variables = Map.of("input", input);
return executeQuery(deleteInvoiceMutation, variables);
}
private String executeRequest(Request request) throws IOException, WaveApiException {
try (Response response = httpClient.newCall(request).execute()) {
if (!response.isSuccessful()) {
String errorBody = response.body().string();
log.error("Wave API error: {} - {}", response.code(), errorBody);
throw new WaveApiException("Wave API error: " + response.code() + " - " + errorBody);
}
return response.body().string();
}
}
}
Authentication Interceptor
import okhttp3.*;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
public class AuthInterceptor implements Interceptor {
private final String accessToken;
public AuthInterceptor(String accessToken) {
this.accessToken = accessToken;
}
@NotNull
@Override
public Response intercept(@NotNull Chain chain) throws IOException {
Request originalRequest = chain.request();
Request authenticatedRequest = originalRequest.newBuilder()
.header("Authorization", "Bearer " + accessToken)
.header("Content-Type", "application/json")
.build();
return chain.proceed(authenticatedRequest);
}
}
Retry Interceptor
import okhttp3.*;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.concurrent.TimeUnit;
public class RetryInterceptor implements Interceptor {
private static final Logger logger = LoggerFactory.getLogger(RetryInterceptor.class);
private final int maxRetries;
private final long backoffDelay;
public RetryInterceptor(int maxRetries, long backoffDelay) {
this.maxRetries = maxRetries;
this.backoffDelay = backoffDelay;
}
@NotNull
@Override
public Response intercept(@NotNull Chain chain) throws IOException {
Request request = chain.request();
Response response = null;
IOException exception = null;
for (int attempt = 1; attempt <= maxRetries; attempt++) {
try {
response = chain.proceed(request);
if (response.isSuccessful()) {
return response;
}
// Only retry on server errors (5xx) or rate limiting
if (response.code() < 500 && response.code() != 429) {
return response;
}
logger.warn("Wave API request failed with status {} on attempt {}/{}",
response.code(), attempt, maxRetries);
} catch (IOException e) {
exception = e;
logger.warn("Wave API request failed with IOException on attempt {}/{}: {}",
attempt, maxRetries, e.getMessage());
}
if (attempt < maxRetries) {
try {
long delay = backoffDelay * (long) Math.pow(2, attempt - 1);
TimeUnit.MILLISECONDS.sleep(delay);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Request interrupted", e);
}
}
if (response != null) {
response.close();
}
}
if (exception != null) {
throw exception;
}
return response;
}
}
7. Wave Processing Service
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
@Slf4j
@Service
public class WaveProcessingService {
@Autowired
private WaveApiClient waveApiClient;
@Autowired
private InvoiceRepository invoiceRepository;
@Autowired
private CustomerRepository customerRepository;
@Autowired
private ProductRepository productRepository;
/**
* Create customer in Wave
*/
@Transactional
public Map<String, Object> createCustomer(CustomerRequestDTO customerRequest) throws WaveApiException {
try {
Map<String, Object> customerInput = new HashMap<>();
customerInput.put("businessId", waveApiClient.getConfig().getBusinessId());
customerInput.put("name", customerRequest.getName());
customerInput.put("email", customerRequest.getEmail());
customerInput.put("phone", customerRequest.getPhone());
customerInput.put("currency", customerRequest.getCurrency());
// Address
if (customerRequest.getAddressLine1() != null) {
Map<String, Object> address = new HashMap<>();
address.put("addressLine1", customerRequest.getAddressLine1());
address.put("addressLine2", customerRequest.getAddressLine2());
address.put("city", customerRequest.getCity());
address.put("provinceCode", customerRequest.getProvince());
address.put("countryCode", customerRequest.getCountry());
address.put("postalCode", customerRequest.getPostalCode());
customerInput.put("address", address);
}
Map<String, Object> response = waveApiClient.createCustomer(customerInput);
// Save customer to local database
saveCustomerToDatabase(response, customerRequest);
log.info("Customer created successfully: {}", customerRequest.getName());
return response;
} catch (Exception e) {
log.error("Error creating customer: {}", e.getMessage(), e);
throw new WaveApiException("Customer creation failed: " + e.getMessage(), e);
}
}
/**
* Create invoice in Wave
*/
@Transactional
public Map<String, Object> createInvoice(InvoiceRequestDTO invoiceRequest) throws WaveApiException {
try {
Map<String, Object> invoiceInput = prepareInvoiceInput(invoiceRequest);
Map<String, Object> response = waveApiClient.createInvoice(invoiceInput);
// Save invoice to local database
saveInvoiceToDatabase(response, invoiceRequest);
log.info("Invoice created successfully for customer: {}", invoiceRequest.getCustomerId());
return response;
} catch (Exception e) {
log.error("Error creating invoice: {}", e.getMessage(), e);
throw new WaveApiException("Invoice creation failed: " + e.getMessage(), e);
}
}
/**
* Send invoice via email
*/
public Map<String, Object> sendInvoice(String invoiceId, String deliveryMethod) throws WaveApiException {
try {
Map<String, Object> response = waveApiClient.sendInvoice(invoiceId, deliveryMethod);
// Update invoice status in local database
invoiceRepository.findByWaveInvoiceId(invoiceId).ifPresent(invoice -> {
invoice.setStatus(Invoice.InvoiceStatus.SENT);
invoiceRepository.save(invoice);
});
log.info("Invoice sent successfully: {}", invoiceId);
return response;
} catch (Exception e) {
log.error("Error sending invoice: {}", e.getMessage(), e);
throw new WaveApiException("Invoice sending failed: " + e.getMessage(), e);
}
}
/**
* Get customer by ID
*/
public Map<String, Object> getCustomer(String customerId) throws WaveApiException {
try {
return waveApiClient.getCustomerById(customerId);
} catch (Exception e) {
log.error("Error getting customer: {}", e.getMessage(), e);
throw new WaveApiException("Failed to get customer: " + e.getMessage(), e);
}
}
/**
* Get all customers with pagination
*/
public Map<String, Object> getCustomers(Integer page, Integer pageSize) throws WaveApiException {
try {
return waveApiClient.getCustomers(page, pageSize);
} catch (Exception e) {
log.error("Error getting customers: {}", e.getMessage(), e);
throw new WaveApiException("Failed to get customers: " + e.getMessage(), e);
}
}
/**
* Get all products with pagination
*/
public Map<String, Object> getProducts(Integer page, Integer pageSize) throws WaveApiException {
try {
return waveApiClient.getProducts(page, pageSize);
} catch (Exception e) {
log.error("Error getting products: {}", e.getMessage(), e);
throw new WaveApiException("Failed to get products: " + e.getMessage(), e);
}
}
/**
* Get business information
*/
public Map<String, Object> getBusinessInfo() throws WaveApiException {
try {
return waveApiClient.getBusinessInfo();
} catch (Exception e) {
log.error("Error getting business info: {}", e.getMessage(), e);
throw new WaveApiException("Failed to get business info: " + e.getMessage(), e);
}
}
/**
* Prepare invoice input for Wave API
*/
@SuppressWarnings("unchecked")
private Map<String, Object> prepareInvoiceInput(InvoiceRequestDTO invoiceRequest) {
Map<String, Object> invoiceInput = new HashMap<>();
invoiceInput.put("businessId", waveApiClient.getConfig().getBusinessId());
invoiceInput.put("customerId", invoiceRequest.getCustomerId());
invoiceInput.put("currency", invoiceRequest.getCurrency());
// Dates
DateTimeFormatter formatter = DateTimeFormatter.ISO_LOCAL_DATE;
if (invoiceRequest.getInvoiceDate() != null) {
invoiceInput.put("invoiceDate", invoiceRequest.getInvoiceDate().format(formatter));
} else {
invoiceInput.put("invoiceDate", LocalDateTime.now().format(formatter));
}
if (invoiceRequest.getDueDate() != null) {
invoiceInput.put("dueDate", invoiceRequest.getDueDate().format(formatter));
}
// Optional fields
if (invoiceRequest.getMemo() != null) {
invoiceInput.put("memo", invoiceRequest.getMemo());
}
if (invoiceRequest.getFooter() != null) {
invoiceInput.put("footer", invoiceRequest.getFooter());
}
if (invoiceRequest.getPaymentTerms() != null) {
invoiceInput.put("paymentTerms", invoiceRequest.getPaymentTerms());
}
// Items
List<Map<String, Object>> items = new ArrayList<>();
for (InvoiceRequestDTO.InvoiceItemDTO item : invoiceRequest.getItems()) {
Map<String, Object> itemInput = new HashMap<>();
itemInput.put("productId", item.getProductId());
itemInput.put("description", item.getDescription());
itemInput.put("quantity", item.getQuantity());
itemInput.put("unitPrice", item.getUnitPrice());
itemInput.put("accountId", item.getAccountId());
if (item.getDiscountAmount().compareTo(BigDecimal.ZERO) > 0) {
itemInput.put("discountAmount", item.getDiscountAmount());
} else if (item.getDiscountRate().compareTo(BigDecimal.ZERO) > 0) {
itemInput.put("discountRate", item.getDiscountRate());
}
// Taxes
if (item.getTaxes() != null && !item.getTaxes().isEmpty()) {
List<Map<String, Object>> taxes = new ArrayList<>();
for (InvoiceRequestDTO.InvoiceItemTaxDTO tax : item.getTaxes()) {
Map<String, Object> taxInput = new HashMap<>();
taxInput.put("salesTaxId", tax.getTaxId());
if (tax.getAmount() != null) {
taxInput.put("amount", tax.getAmount());
}
if (tax.getRate() != null) {
taxInput.put("rate", tax.getRate());
}
taxes.add(taxInput);
}
itemInput.put("taxes", taxes);
}
items.add(itemInput);
}
invoiceInput.put("items", items);
return invoiceInput;
}
/**
* Save customer to local database
*/
@SuppressWarnings("unchecked")
private void saveCustomerToDatabase(Map<String, Object> response, CustomerRequestDTO customerRequest) {
try {
Map<String, Object> data = (Map<String, Object>) response.get("data");
if (data != null) {
Map<String, Object> customerCreate = (Map<String, Object>) data.get("customerCreate");
if (customerCreate != null && Boolean.TRUE.equals(customerCreate.get("didSucceed"))) {
Map<String, Object> customerData = (Map<String, Object>) customerCreate.get("customer");
Customer customer = new Customer();
customer.setWaveCustomerId((String) customerData.get("id"));
customer.setName(customerRequest.getName());
customer.setEmail(customerRequest.getEmail());
customer.setPhone(customerRequest.getPhone());
customer.setAddressLine1(customerRequest.getAddressLine1());
customer.setAddressLine2(customerRequest.getAddressLine2());
customer.setCity(customerRequest.getCity());
customer.setProvince(customerRequest.getProvince());
customer.setCountry(customerRequest.getCountry());
customer.setPostalCode(customerRequest.getPostalCode());
customer.setCurrency(customerRequest.getCurrency());
customerRepository.save(customer);
log.info("Customer saved to local database: {}", customer.getWaveCustomerId());
}
}
} catch (Exception e) {
log.error("Error saving customer to local database: {}", e.getMessage(), e);
}
}
/**
* Save invoice to local database
*/
@SuppressWarnings("unchecked")
private void saveInvoiceToDatabase(Map<String, Object> response, InvoiceRequestDTO invoiceRequest) {
try {
Map<String, Object> data = (Map<String, Object>) response.get("data");
if (data != null) {
Map<String, Object> invoiceCreate = (Map<String, Object>) data.get("invoiceCreate");
if (invoiceCreate != null && Boolean.TRUE.equals(invoiceCreate.get("didSucceed"))) {
Map<String, Object> invoiceData = (Map<String, Object>) invoiceCreate.get("invoice");
Invoice invoice = new Invoice();
invoice.setWaveInvoiceId((String) invoiceData.get("id"));
invoice.setCustomerId(invoiceRequest.getCustomerId());
invoice.setInvoiceDate(invoiceRequest.getInvoiceDate() != null ?
invoiceRequest.getInvoiceDate() : LocalDateTime.now());
invoice.setDueDate(invoiceRequest.getDueDate());
invoice.setCurrency(invoiceRequest.getCurrency());
invoice.setMemo(invoiceRequest.getMemo());
invoice.setFooter(invoiceRequest.getFooter());
invoice.setPaymentTerms(invoiceRequest.getPaymentTerms());
invoice.setStatus(Invoice.InvoiceStatus.DRAFT);
// Set amounts from response
Map<String, Object> subTotal = (Map<String, Object>) invoiceData.get("subTotal");
Map<String, Object> totalTax = (Map<String, Object>) invoiceData.get("totalTax");
Map<String, Object> totalAmount = (Map<String, Object>) invoiceData.get("totalAmount");
Map<String, Object> amountDue = (Map<String, Object>) invoiceData.get("amountDue");
if (subTotal != null) {
invoice.setSubTotal(new BigDecimal(subTotal.get("value").toString()));
}
if (totalTax != null) {
invoice.setTotalTax(new BigDecimal(totalTax.get("value").toString()));
}
if (totalAmount != null) {
invoice.setTotalAmount(new BigDecimal(totalAmount.get("value").toString()));
}
if (amountDue != null) {
invoice.setAmountDue(new BigDecimal(amountDue.get("value").toString()));
}
invoiceRepository.save(invoice);
log.info("Invoice saved to local database: {}", invoice.getWaveInvoiceId());
}
}
} catch (Exception e) {
log.error("Error saving invoice to local database: {}", e.getMessage(), e);
}
}
}
8. REST Controllers
Wave Controller
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@RestController
@RequestMapping("/api/wave")
public class WaveController {
@Autowired
private WaveProcessingService waveProcessingService;
@Autowired
private WaveApiClient waveApiClient;
/**
* Get business information
*/
@GetMapping("/business")
public ResponseEntity<?> getBusinessInfo() {
try {
Map<String, Object> response = waveProcessingService.getBusinessInfo();
return ResponseEntity.ok(response);
} catch (WaveApiException e) {
log.error("Error getting business info: {}", e.getMessage(), e);
return createErrorResponse("Failed to get business information", e.getMessage());
}
}
/**
* Create customer
*/
@PostMapping("/customers")
public ResponseEntity<?> createCustomer(@Valid @RequestBody CustomerRequestDTO customerRequest) {
try {
Map<String, Object> response = waveProcessingService.createCustomer(customerRequest);
return ResponseEntity.ok(response);
} catch (WaveApiException e) {
log.error("Error creating customer: {}", e.getMessage(), e);
return createErrorResponse("Failed to create customer", e.getMessage());
}
}
/**
* Get customers
*/
@GetMapping("/customers")
public ResponseEntity<?> getCustomers(
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "50") Integer pageSize) {
try {
Map<String, Object> response = waveProcessingService.getCustomers(page, pageSize);
return ResponseEntity.ok(response);
} catch (WaveApiException e) {
log.error("Error getting customers: {}", e.getMessage(), e);
return createErrorResponse("Failed to get customers", e.getMessage());
}
}
/**
* Get customer by ID
*/
@GetMapping("/customers/{customerId}")
public ResponseEntity<?> getCustomer(@PathVariable String customerId) {
try {
Map<String, Object> response = waveProcessingService.getCustomer(customerId);
return ResponseEntity.ok(response);
} catch (WaveApiException e) {
log.error("Error getting customer: {}", e.getMessage(), e);
return createErrorResponse("Failed to get customer", e.getMessage());
}
}
/**
* Create invoice
*/
@PostMapping("/invoices")
public ResponseEntity<?> createInvoice(@Valid @RequestBody InvoiceRequestDTO invoiceRequest) {
try {
Map<String, Object> response = waveProcessingService.createInvoice(invoiceRequest);
return ResponseEntity.ok(response);
} catch (WaveApiException e) {
log.error("Error creating invoice: {}", e.getMessage(), e);
return createErrorResponse("Failed to create invoice", e.getMessage());
}
}
/**
* Send invoice
*/
@PostMapping("/invoices/{invoiceId}/send")
public ResponseEntity<?> sendInvoice(
@PathVariable String invoiceId,
@RequestParam(defaultValue = "EMAIL") String deliveryMethod) {
try {
Map<String, Object> response = waveProcessingService.sendInvoice(invoiceId, deliveryMethod);
return ResponseEntity.ok(response);
} catch (WaveApiException e) {
log.error("Error sending invoice: {}", e.getMessage(), e);
return createErrorResponse("Failed to send invoice", e.getMessage());
}
}
/**
* Get invoices
*/
@GetMapping("/invoices")
public ResponseEntity<?> getInvoices(
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "50") Integer pageSize,
@RequestParam(required = false) String status) {
try {
Map<String, Object> response = waveApiClient.getInvoices(page, pageSize, status);
return ResponseEntity.ok(response);
} catch (WaveApiException e) {
log.error("Error getting invoices: {}", e.getMessage(), e);
return createErrorResponse("Failed to get invoices", e.getMessage());
}
}
/**
* Get products
*/
@GetMapping("/products")
public ResponseEntity<?> getProducts(
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "50") Integer pageSize) {
try {
Map<String, Object> response = waveProcessingService.getProducts(page, pageSize);
return ResponseEntity.ok(response);
} catch (WaveApiException e) {
log.error("Error getting products: {}", e.getMessage(), e);
return createErrorResponse("Failed to get products", e.getMessage());
}
}
/**
* Delete invoice
*/
@DeleteMapping("/invoices/{invoiceId}")
public ResponseEntity<?> deleteInvoice(@PathVariable String invoiceId) {
try {
Map<String, Object> response = waveApiClient.deleteInvoice(invoiceId);
return ResponseEntity.ok(response);
} catch (WaveApiException e) {
log.error("Error deleting invoice: {}", e.getMessage(), e);
return createErrorResponse("Failed to delete invoice", e.getMessage());
}
}
private ResponseEntity<Map<String, Object>> createErrorResponse(String message, String errorDetail) {
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("error", true);
errorResponse.put("message", message);
errorResponse.put("error_detail", errorDetail);
return ResponseEntity.badRequest().body(errorResponse);
}
}
9. Custom Exception
public class WaveApiException extends Exception {
public WaveApiException(String message) {
super(message);
}
public WaveApiException(String message, Throwable cause) {
super(message, cause);
}
}
10. Repository Interfaces
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
@Repository
public interface InvoiceRepository extends JpaRepository<Invoice, Long> {
Optional<Invoice> findByWaveInvoiceId(String waveInvoiceId);
List<Invoice> findByCustomerId(String customerId);
List<Invoice> findByStatus(Invoice.InvoiceStatus status);
List<Invoice> findByInvoiceDateBetween(LocalDateTime start, LocalDateTime end);
@Query("SELECT SUM(i.totalAmount) FROM Invoice i WHERE i.status = 'PAID' AND i.invoiceDate BETWEEN :start AND :end")
Double getTotalRevenueByPeriod(@Param("start") LocalDateTime start, @Param("end") LocalDateTime end);
}
@Repository
public interface CustomerRepository extends JpaRepository<Customer, Long> {
Optional<Customer> findByWaveCustomerId(String waveCustomerId);
Optional<Customer> findByEmail(String email);
List<Customer> findByNameContainingIgnoreCase(String name);
List<Customer> findByCountry(String country);
}
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
Optional<Product> findByWaveProductId(String waveProductId);
List<Product> findByNameContainingIgnoreCase(String name);
List<Product> findByIsSoldTrue();
}
This comprehensive Wave Apps API integration provides complete functionality for managing customers, products, invoices, and business operations through Wave's GraphQL API in your Java application.