Java tutorial
/* * Copyright 2010-2014 Ning, Inc. * Copyright 2014-2015 The Billing Project, LLC * * The Billing Project licenses this file to you under the Apache License, version 2.0 * (the "License"); you may not use this file except in compliance with the * License. You may obtain a copy of the License at: * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations * under the License. */ package com.ning.billing.recurly; import java.io.IOException; import java.io.InputStream; import java.io.Reader; import java.math.BigDecimal; import java.net.URL; import java.nio.charset.Charset; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import java.util.List; import java.util.NoSuchElementException; import java.util.Properties; import java.util.Scanner; import java.util.concurrent.ExecutionException; import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.annotation.Nullable; import javax.xml.bind.DatatypeConverter; import com.ning.billing.recurly.model.Account; import com.ning.billing.recurly.model.AccountBalance; import com.ning.billing.recurly.model.Accounts; import com.ning.billing.recurly.model.AddOn; import com.ning.billing.recurly.model.AddOns; import com.ning.billing.recurly.model.Adjustment; import com.ning.billing.recurly.model.AdjustmentRefund; import com.ning.billing.recurly.model.Adjustments; import com.ning.billing.recurly.model.BillingInfo; import com.ning.billing.recurly.model.Coupon; import com.ning.billing.recurly.model.Coupons; import com.ning.billing.recurly.model.Errors; import com.ning.billing.recurly.model.GiftCard; import com.ning.billing.recurly.model.GiftCards; import com.ning.billing.recurly.model.Invoice; import com.ning.billing.recurly.model.InvoiceRefund; import com.ning.billing.recurly.model.InvoiceState; import com.ning.billing.recurly.model.Invoices; import com.ning.billing.recurly.model.Plan; import com.ning.billing.recurly.model.Plans; import com.ning.billing.recurly.model.RecurlyAPIError; import com.ning.billing.recurly.model.RecurlyObject; import com.ning.billing.recurly.model.RecurlyObjects; import com.ning.billing.recurly.model.Redemption; import com.ning.billing.recurly.model.Redemptions; import com.ning.billing.recurly.model.RefundApplyOrder; import com.ning.billing.recurly.model.RefundOption; import com.ning.billing.recurly.model.ShippingAddresses; import com.ning.billing.recurly.model.Subscription; import com.ning.billing.recurly.model.SubscriptionState; import com.ning.billing.recurly.model.SubscriptionUpdate; import com.ning.billing.recurly.model.SubscriptionNotes; import com.ning.billing.recurly.model.Subscriptions; import com.ning.billing.recurly.model.Transaction; import com.ning.billing.recurly.model.TransactionState; import com.ning.billing.recurly.model.TransactionType; import com.ning.billing.recurly.model.Transactions; import com.ning.billing.recurly.model.Usage; import com.ning.billing.recurly.model.MeasuredUnit; import com.ning.billing.recurly.model.MeasuredUnits; import com.ning.billing.recurly.util.http.SslUtils; import org.joda.time.DateTime; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.ning.http.client.AsyncHttpClient; import com.ning.http.client.AsyncHttpClientConfig; import com.ning.http.client.Response; import com.fasterxml.jackson.dataformat.xml.XmlMapper; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.MoreObjects; import com.google.common.base.StandardSystemProperty; import com.google.common.io.CharSource; import com.google.common.io.Resources; import com.google.common.net.HttpHeaders; public class RecurlyClient { private static final Logger log = LoggerFactory.getLogger(RecurlyClient.class); public static final String RECURLY_DEBUG_KEY = "recurly.debug"; public static final String RECURLY_API_VERSION = "2.5"; private static final String X_RECORDS_HEADER_NAME = "X-Records"; private static final String LINK_HEADER_NAME = "Link"; private static final String GIT_PROPERTIES_FILE = "com/ning/billing/recurly/git.properties"; @VisibleForTesting static final String GIT_COMMIT_ID_DESCRIBE_SHORT = "git.commit.id.describe-short"; private static final Pattern TAG_FROM_GIT_DESCRIBE_PATTERN = Pattern .compile("recurly-java-library-([0-9]*\\.[0-9]*\\.[0-9]*)(-[0-9]*)?"); public static final String FETCH_RESOURCE = "/recurly_js/result"; /** * Checks a system property to see if debugging output is * required. Used internally by the client to decide whether to * generate debug output */ private static boolean debug() { return Boolean.getBoolean(RECURLY_DEBUG_KEY); } // TODO: should we make it static? private final XmlMapper xmlMapper; private final String userAgent; private final String key; private final String baseUrl; private AsyncHttpClient client; public RecurlyClient(final String apiKey) { this(apiKey, "api"); } public RecurlyClient(final String apiKey, final String subDomain) { this(apiKey, subDomain + ".recurly.com", 443, "v2"); } public RecurlyClient(final String apiKey, final String host, final int port, final String version) { this(apiKey, "https", host, port, version); } public RecurlyClient(final String apiKey, final String scheme, final String host, final int port, final String version) { this.key = DatatypeConverter.printBase64Binary(apiKey.getBytes()); this.baseUrl = String.format("%s://%s:%d/%s", scheme, host, port, version); this.xmlMapper = RecurlyObject.newXmlMapper(); this.userAgent = buildUserAgent(); } /** * Open the underlying http client */ public synchronized void open() throws NoSuchAlgorithmException, KeyManagementException { client = createHttpClient(); } /** * Close the underlying http client */ public synchronized void close() { if (client != null) { client.close(); } } /** * Create Account * <p> * Creates a new account. You may optionally include billing information. * * @param account account object * @return the newly created account object on success, null otherwise */ public Account createAccount(final Account account) { return doPOST(Account.ACCOUNT_RESOURCE, account, Account.class); } /** * Get Accounts * <p> * Returns information about all accounts. * * @return Accounts on success, null otherwise */ public Accounts getAccounts() { return doGET(Accounts.ACCOUNTS_RESOURCE, Accounts.class); } /** * Get Accounts given query params * <p> * Returns information about all accounts. * * @param params {@link QueryParams} * @return Accounts on success, null otherwise */ public Accounts getAccounts(final QueryParams params) { return doGET(Accounts.ACCOUNTS_RESOURCE, Accounts.class, params); } /** * Get Coupons * <p> * Returns information about all accounts. * * @return Coupons on success, null otherwise */ public Coupons getCoupons() { return doGET(Coupons.COUPONS_RESOURCE, Coupons.class); } /** * Get Coupons given query params * <p> * Returns information about all accounts. * * @param params {@link QueryParams} * @return Coupons on success, null otherwise */ public Coupons getCoupons(final QueryParams params) { return doGET(Coupons.COUPONS_RESOURCE, Coupons.class, params); } /** * Get Account * <p> * Returns information about a single account. * * @param accountCode recurly account id * @return account object on success, null otherwise */ public Account getAccount(final String accountCode) { return doGET(Account.ACCOUNT_RESOURCE + "/" + accountCode, Account.class); } /** * Update Account * <p> * Updates an existing account. * * @param accountCode recurly account id * @param account account object * @return the updated account object on success, null otherwise */ public Account updateAccount(final String accountCode, final Account account) { return doPUT(Account.ACCOUNT_RESOURCE + "/" + accountCode, account, Account.class); } /** * Get Account Balance * <p> * Retrieves the remaining balance on the account * * @param accountCode recurly account id * @return the updated AccountBalance if success, null otherwise */ public AccountBalance getAccountBalance(final String accountCode) { return doGET(Account.ACCOUNT_RESOURCE + "/" + accountCode + "/" + AccountBalance.ACCOUNT_BALANCE_RESOURCE, AccountBalance.class); } /** * Close Account * <p> * Marks an account as closed and cancels any active subscriptions. Any saved billing information will also be * permanently removed from the account. * * @param accountCode recurly account id */ public void closeAccount(final String accountCode) { doDELETE(Account.ACCOUNT_RESOURCE + "/" + accountCode); } //////////////////////////////////////////////////////////////////////////////////////// // Account adjustments /** * Get Account Adjustments * <p> * * @param accountCode recurly account id * @return the adjustments on the account */ public Adjustments getAccountAdjustments(final String accountCode) { return getAccountAdjustments(accountCode, null, null, new QueryParams()); } /** * Get Account Adjustments * <p> * * @param accountCode recurly account id * @param type {@link Adjustments.AdjustmentType} * @return the adjustments on the account */ public Adjustments getAccountAdjustments(final String accountCode, final Adjustments.AdjustmentType type) { return getAccountAdjustments(accountCode, type, null, new QueryParams()); } /** * Get Account Adjustments * <p> * * @param accountCode recurly account id * @param type {@link Adjustments.AdjustmentType} * @param state {@link Adjustments.AdjustmentState} * @return the adjustments on the account */ public Adjustments getAccountAdjustments(final String accountCode, final Adjustments.AdjustmentType type, final Adjustments.AdjustmentState state) { return getAccountAdjustments(accountCode, type, state, new QueryParams()); } /** * Get Account Adjustments * <p> * * @param accountCode recurly account id * @param type {@link Adjustments.AdjustmentType} * @param state {@link Adjustments.AdjustmentState} * @param params {@link QueryParams} * @return the adjustments on the account */ public Adjustments getAccountAdjustments(final String accountCode, final Adjustments.AdjustmentType type, final Adjustments.AdjustmentState state, final QueryParams params) { final String url = Account.ACCOUNT_RESOURCE + "/" + accountCode + Adjustments.ADJUSTMENTS_RESOURCE; if (type != null) params.put("type", type.getType()); if (state != null) params.put("state", state.getState()); return doGET(url, Adjustments.class, params); } public Adjustment getAdjustment(final String adjustmentUuid) { return doGET(Adjustments.ADJUSTMENTS_RESOURCE + "/" + adjustmentUuid, Adjustment.class); } public Adjustment createAccountAdjustment(final String accountCode, final Adjustment adjustment) { return doPOST(Account.ACCOUNT_RESOURCE + "/" + accountCode + Adjustments.ADJUSTMENTS_RESOURCE, adjustment, Adjustment.class); } public void deleteAccountAdjustment(final String accountCode) { doDELETE(Account.ACCOUNT_RESOURCE + "/" + accountCode + Adjustments.ADJUSTMENTS_RESOURCE); } public void deleteAdjustment(final String adjustmentUuid) { doDELETE(Adjustments.ADJUSTMENTS_RESOURCE + "/" + adjustmentUuid); } //////////////////////////////////////////////////////////////////////////////////////// /** * Create a subscription * <p> * Creates a subscription for an account. * * @param subscription Subscription object * @return the newly created Subscription object on success, null otherwise */ public Subscription createSubscription(final Subscription subscription) { return doPOST(Subscription.SUBSCRIPTION_RESOURCE, subscription, Subscription.class); } /** * Preview a subscription * <p> * Previews a subscription for an account. * * @param subscription Subscription object * @return the newly created Subscription object on success, null otherwise */ public Subscription previewSubscription(final Subscription subscription) { return doPOST(Subscription.SUBSCRIPTION_RESOURCE + "/preview", subscription, Subscription.class); } /** * Get a particular {@link Subscription} by it's UUID * <p> * Returns information about a single subscription. * * @param uuid UUID of the subscription to lookup * @return Subscription */ public Subscription getSubscription(final String uuid) { return doGET(Subscriptions.SUBSCRIPTIONS_RESOURCE + "/" + uuid, Subscription.class); } /** * Cancel a subscription * <p> * Cancel a subscription so it remains active and then expires at the end of the current bill cycle. * * @param subscription Subscription object * @return -?- */ public Subscription cancelSubscription(final Subscription subscription) { return doPUT(Subscription.SUBSCRIPTION_RESOURCE + "/" + subscription.getUuid() + "/cancel", subscription, Subscription.class); } /** * Postpone a subscription * <p> * postpone a subscription, setting a new renewal date. * * @param subscription Subscription object * @return -?- */ public Subscription postponeSubscription(final Subscription subscription, final DateTime renewaldate) { return doPUT(Subscription.SUBSCRIPTION_RESOURCE + "/" + subscription.getUuid() + "/postpone?next_renewal_date=" + renewaldate, subscription, Subscription.class); } /** * Terminate a particular {@link Subscription} by it's UUID * * @param subscription Subscription to terminate */ public void terminateSubscription(final Subscription subscription, final RefundOption refund) { doPUT(Subscription.SUBSCRIPTION_RESOURCE + "/" + subscription.getUuid() + "/terminate?refund=" + refund, subscription, Subscription.class); } /** * Reactivating a canceled subscription * <p> * Reactivate a canceled subscription so it renews at the end of the current bill cycle. * * @param subscription Subscription object * @return -?- */ public Subscription reactivateSubscription(final Subscription subscription) { return doPUT(Subscription.SUBSCRIPTION_RESOURCE + "/" + subscription.getUuid() + "/reactivate", subscription, Subscription.class); } /** * Update a particular {@link Subscription} by it's UUID * <p> * Returns information about a single subscription. * * @param uuid UUID of the subscription to update * @param subscriptionUpdate subscriptionUpdate object * @return Subscription the updated subscription */ public Subscription updateSubscription(final String uuid, final SubscriptionUpdate subscriptionUpdate) { return doPUT(Subscriptions.SUBSCRIPTIONS_RESOURCE + "/" + uuid, subscriptionUpdate, Subscription.class); } /** * Preview an update to a particular {@link Subscription} by it's UUID * <p> * Returns information about a single subscription. * * @param uuid UUID of the subscription to preview an update for * @return Subscription the updated subscription preview */ public Subscription updateSubscriptionPreview(final String uuid, final SubscriptionUpdate subscriptionUpdate) { return doPOST(Subscriptions.SUBSCRIPTIONS_RESOURCE + "/" + uuid + "/preview", subscriptionUpdate, Subscription.class); } /** * Update to a particular {@link Subscription}'s notes by it's UUID * <p> * Returns information about a single subscription. * * @param uuid UUID of the subscription to preview an update for * @param subscriptionNotes SubscriptionNotes object * @return Subscription the updated subscription */ public Subscription updateSubscriptionNotes(final String uuid, final SubscriptionNotes subscriptionNotes) { return doPUT(SubscriptionNotes.SUBSCRIPTION_RESOURCE + "/" + uuid + "/notes", subscriptionNotes, Subscription.class); } /** * Get the subscriptions for an {@link Account}. * <p> * Returns subscriptions associated with an account * * @param accountCode recurly account id * @return Subscriptions on the account */ public Subscriptions getAccountSubscriptions(final String accountCode) { return doGET(Account.ACCOUNT_RESOURCE + "/" + accountCode + Subscriptions.SUBSCRIPTIONS_RESOURCE, Subscriptions.class); } /** * Get all the subscriptions on the site * <p> * Returns all the subscriptions on the site * * @return Subscriptions on the site */ public Subscriptions getSubscriptions() { return doGET(Subscriptions.SUBSCRIPTIONS_RESOURCE, Subscriptions.class); } /** * Get the subscriptions for an {@link Account} given query params * <p> * Returns subscriptions associated with an account * * @param accountCode recurly account id * @param state {@link SubscriptionState} * @param params {@link QueryParams} * @return Subscriptions on the account */ public Subscriptions getAccountSubscriptions(final String accountCode, final SubscriptionState state, final QueryParams params) { if (state != null) params.put("state", state.getType()); return doGET(Account.ACCOUNT_RESOURCE + "/" + accountCode + Subscriptions.SUBSCRIPTIONS_RESOURCE, Subscriptions.class, params); } /** * Post usage to subscription * <p> * * @param subscriptionCode The recurly id of the {@link Subscription } * @param addOnCode recurly id of {@link AddOn} * @param usage the usage to post on recurly * @return the {@link Usage} object as identified by the passed in object */ public Usage postSubscriptionUsage(final String subscriptionCode, final String addOnCode, final Usage usage) { return doPOST(Subscription.SUBSCRIPTION_RESOURCE + "/" + subscriptionCode + AddOn.ADDONS_RESOURCE + "/" + addOnCode + Usage.USAGE_RESOURCE, usage, Usage.class); } /** * Get the subscriptions for an account. * This is deprecated. Please use getAccountSubscriptions(String, Subscriptions.State, QueryParams) * <p> * Returns information about a single account. * * @param accountCode recurly account id * @param status Only accounts in this status will be returned * @return Subscriptions on the account */ @Deprecated public Subscriptions getAccountSubscriptions(final String accountCode, final String status) { final QueryParams params = new QueryParams(); if (status != null) params.put("state", status); return doGET(Account.ACCOUNT_RESOURCE + "/" + accountCode + Subscriptions.SUBSCRIPTIONS_RESOURCE, Subscriptions.class, params); } //////////////////////////////////////////////////////////////////////////////////////// /** * Update an account's billing info * <p> * When new or updated credit card information is updated, the billing information is only saved if the credit card * is valid. If the account has a past due invoice, the outstanding balance will be collected to validate the * billing information. * <p> * If the account does not exist before the API request, the account will be created if the billing information * is valid. * <p> * Please note: this API end-point may be used to import billing information without security codes (CVV). * Recurly recommends requiring CVV from your customers when collecting new or updated billing information. * * @param billingInfo billing info object to create or update * @return the newly created or update billing info object on success, null otherwise */ public BillingInfo createOrUpdateBillingInfo(final BillingInfo billingInfo) { final String accountCode = billingInfo.getAccount().getAccountCode(); // Unset it to avoid confusing Recurly billingInfo.setAccount(null); return doPUT(Account.ACCOUNT_RESOURCE + "/" + accountCode + BillingInfo.BILLING_INFO_RESOURCE, billingInfo, BillingInfo.class); } /** * Lookup an account's billing info * <p> * Returns only the account's current billing information. * * @param accountCode recurly account id * @return the current billing info object associated with this account on success, null otherwise */ public BillingInfo getBillingInfo(final String accountCode) { return doGET(Account.ACCOUNT_RESOURCE + "/" + accountCode + BillingInfo.BILLING_INFO_RESOURCE, BillingInfo.class); } /** * Clear an account's billing info * <p> * You may remove any stored billing information for an account. If the account has a subscription, the renewal will * go into past due unless you update the billing info before the renewal occurs * * @param accountCode recurly account id */ public void clearBillingInfo(final String accountCode) { doDELETE(Account.ACCOUNT_RESOURCE + "/" + accountCode + BillingInfo.BILLING_INFO_RESOURCE); } /////////////////////////////////////////////////////////////////////////// // User transactions /** * Lookup an account's transactions history * <p> * Returns the account's transaction history * * @param accountCode recurly account id * @return the transaction history associated with this account on success, null otherwise */ public Transactions getAccountTransactions(final String accountCode) { return doGET(Accounts.ACCOUNTS_RESOURCE + "/" + accountCode + Transactions.TRANSACTIONS_RESOURCE, Transactions.class); } /** * Lookup an account's transactions history given query params * <p> * Returns the account's transaction history * * @param accountCode recurly account id * @param state {@link TransactionState} * @param type {@link TransactionType} * @param params {@link QueryParams} * @return the transaction history associated with this account on success, null otherwise */ public Transactions getAccountTransactions(final String accountCode, final TransactionState state, final TransactionType type, final QueryParams params) { if (state != null) params.put("state", state.getType()); if (type != null) params.put("type", type.getType()); return doGET(Accounts.ACCOUNTS_RESOURCE + "/" + accountCode + Transactions.TRANSACTIONS_RESOURCE, Transactions.class, params); } /** * Get site's transaction history * <p> * All transactions on the site * * @return the transaction history of the site on success, null otherwise */ public Transactions getTransactions() { return doGET(Transactions.TRANSACTIONS_RESOURCE, Transactions.class); } /** * Get site's transaction history * <p> * All transactions on the site * * @param state {@link TransactionState} * @param type {@link TransactionType} * @param params {@link QueryParams} * @return the transaction history of the site on success, null otherwise */ public Transactions getTransactions(final TransactionState state, final TransactionType type, final QueryParams params) { if (state != null) params.put("state", state.getType()); if (type != null) params.put("type", type.getType()); return doGET(Transactions.TRANSACTIONS_RESOURCE, Transactions.class, params); } /** * Lookup a transaction * * @param transactionId recurly transaction id * @return the transaction if found, null otherwise */ public Transaction getTransaction(final String transactionId) { return doGET(Transactions.TRANSACTIONS_RESOURCE + "/" + transactionId, Transaction.class); } /** * Creates a {@link Transaction} through the Recurly API. * * @param trans The {@link Transaction} to create * @return The created {@link Transaction} object */ public Transaction createTransaction(final Transaction trans) { return doPOST(Transactions.TRANSACTIONS_RESOURCE, trans, Transaction.class); } /** * Refund a transaction * * @param transactionId recurly transaction id * @param amount amount to refund, null for full refund */ public void refundTransaction(final String transactionId, @Nullable final BigDecimal amount) { String url = Transactions.TRANSACTIONS_RESOURCE + "/" + transactionId; if (amount != null) { url = url + "?amount_in_cents=" + (amount.intValue() * 100); } doDELETE(url); } /////////////////////////////////////////////////////////////////////////// // User invoices /** * Lookup an invoice * <p> * Returns the invoice given an integer id * * @deprecated Please switch to using a string for invoice ids * * @param invoiceId Recurly Invoice ID * @return the invoice */ @Deprecated public Invoice getInvoice(final Integer invoiceId) { return getInvoice(invoiceId.toString()); } /** * Lookup an invoice given an invoice id * * <p> * Returns the invoice given a string id. * The invoice may or may not have acountry code prefix (ex: IE1023). * For more information on invoicing and prefixes, see: * https://docs.recurly.com/docs/site-settings#section-invoice-prefixing * * @param invoiceId String Recurly Invoice ID * @return the invoice */ public Invoice getInvoice(final String invoiceId) { return doGET(Invoices.INVOICES_RESOURCE + "/" + invoiceId, Invoice.class); } /** * Fetch invoice pdf * <p> * Returns the invoice pdf as an inputStream * * @deprecated Prefer using Invoice#getId() as the id param (which is a String) * * @param invoiceId Recurly Invoice ID * @return the invoice pdf as an inputStream */ @Deprecated public InputStream getInvoicePdf(final Integer invoiceId) { return getInvoicePdf(invoiceId.toString()); } /** * Fetch invoice pdf * <p> * Returns the invoice pdf as an inputStream * * @param invoiceId String Recurly Invoice ID * @return the invoice pdf as an inputStream */ public InputStream getInvoicePdf(final String invoiceId) { return doGETPdf(Invoices.INVOICES_RESOURCE + "/" + invoiceId); } /** * Lookup an account's invoices * <p> * Returns the account's invoices * * @param accountCode recurly account id * @return the invoices associated with this account on success, null otherwise */ public Invoices getAccountInvoices(final String accountCode) { return doGET(Accounts.ACCOUNTS_RESOURCE + "/" + accountCode + Invoices.INVOICES_RESOURCE, Invoices.class); } /** * Refund an invoice given an open amount * <p/> * Returns the refunded invoice * * @param invoiceId The id of the invoice to refund * @param amountInCents The open amount to refund * @param order If credit line items exist on the invoice, this parameter specifies which refund method to use first * @return the refunded invoice */ public Invoice refundInvoice(final String invoiceId, final Integer amountInCents, final RefundApplyOrder order) { final InvoiceRefund invoiceRefund = new InvoiceRefund(); invoiceRefund.setRefundApplyOrder(order); invoiceRefund.setAmountInCents(amountInCents); return doPOST(Invoices.INVOICES_RESOURCE + "/" + invoiceId + "/refund", invoiceRefund, Invoice.class); } /** * Refund an invoice given some line items * <p/> * Returns the refunded invoice * * @param invoiceId The id of the invoice to refund * @param lineItems The list of adjustment refund objects * @param order If credit line items exist on the invoice, this parameter specifies which refund method to use first * @return the refunded invoice */ public Invoice refundInvoice(final String invoiceId, List<AdjustmentRefund> lineItems, final RefundApplyOrder order) { final InvoiceRefund invoiceRefund = new InvoiceRefund(); invoiceRefund.setRefundApplyOrder(order); invoiceRefund.setLineItems(lineItems); return doPOST(Invoices.INVOICES_RESOURCE + "/" + invoiceId + "/refund", invoiceRefund, Invoice.class); } /** * Lookup an account's shipping addresses * <p> * Returns the account's shipping addresses * * @param accountCode recurly account id * @return the shipping addresses associated with this account on success, null otherwise */ public ShippingAddresses getAccountShippingAddresses(final String accountCode) { return doGET(Accounts.ACCOUNTS_RESOURCE + "/" + accountCode + ShippingAddresses.SHIPPING_ADDRESSES_RESOURCE, ShippingAddresses.class); } /** * Lookup an account's invoices given query params * <p> * Returns the account's invoices * * @param accountCode recurly account id * @param state {@link InvoiceState} state of the invoices * @param params {@link QueryParams} * @return the invoices associated with this account on success, null otherwise */ public Invoices getAccountInvoices(final String accountCode, final InvoiceState state, final QueryParams params) { if (state != null) params.put("state", state.getType()); return doGET(Accounts.ACCOUNTS_RESOURCE + "/" + accountCode + Invoices.INVOICES_RESOURCE, Invoices.class, params); } /** * Post an invoice: invoice pending charges on an account * <p> * Returns an invoice * * @param accountCode * @return the invoice that was generated on success, null otherwise */ public Invoice postAccountInvoice(final String accountCode, final Invoice invoice) { return doPOST(Accounts.ACCOUNTS_RESOURCE + "/" + accountCode + Invoices.INVOICES_RESOURCE, invoice, Invoice.class); } /** * Mark an invoice as paid successfully - Recurly Enterprise Feature * * @deprecated Prefer using Invoice#getId() as the id param (which is a String) * * @param invoiceId Recurly Invoice ID */ @Deprecated public Invoice markInvoiceSuccessful(final Integer invoiceId) { return markInvoiceSuccessful(invoiceId.toString()); } /** * Mark an invoice as paid successfully - Recurly Enterprise Feature * * @param invoiceId String Recurly Invoice ID */ public Invoice markInvoiceSuccessful(final String invoiceId) { return doPUT(Invoices.INVOICES_RESOURCE + "/" + invoiceId + "/mark_successful", null, Invoice.class); } /** * Mark an invoice as failed collection * * @deprecated Prefer using Invoice#getId() as the id param (which is a String) * * @param invoiceId Recurly Invoice ID */ @Deprecated public Invoice markInvoiceFailed(final Integer invoiceId) { return markInvoiceFailed(invoiceId.toString()); } /** * Mark an invoice as failed collection * * @param invoiceId String Recurly Invoice ID */ public Invoice markInvoiceFailed(final String invoiceId) { return doPUT(Invoices.INVOICES_RESOURCE + "/" + invoiceId + "/mark_failed", null, Invoice.class); } /** * Force collect an invoice * * @param invoiceId String Recurly Invoice ID */ public Invoice forceCollectInvoice(final String invoiceId) { return doPUT(Invoices.INVOICES_RESOURCE + "/" + invoiceId + "/collect", null, Invoice.class); } /** * Enter an offline payment for a manual invoice (beta) - Recurly Enterprise Feature * * @deprecated Prefer using Invoice#getId() as the id param (which is a String) * * @param invoiceId Recurly Invoice ID * @param payment The external payment */ @Deprecated public Transaction enterOfflinePayment(final Integer invoiceId, final Transaction payment) { return enterOfflinePayment(invoiceId.toString(), payment); } /** * Enter an offline payment for a manual invoice (beta) - Recurly Enterprise Feature * * @param invoiceId String Recurly Invoice ID * @param payment The external payment */ public Transaction enterOfflinePayment(final String invoiceId, final Transaction payment) { return doPOST(Invoices.INVOICES_RESOURCE + "/" + invoiceId + "/transactions", payment, Transaction.class); } /////////////////////////////////////////////////////////////////////////// /** * Create a Plan's info * <p> * * @param plan The plan to create on recurly * @return the plan object as identified by the passed in ID */ public Plan createPlan(final Plan plan) { return doPOST(Plan.PLANS_RESOURCE, plan, Plan.class); } /** * Update a Plan's info * <p> * * @param plan The plan to update on recurly * @return the updated plan object */ public Plan updatePlan(final Plan plan) { return doPUT(Plan.PLANS_RESOURCE + "/" + plan.getPlanCode(), plan, Plan.class); } /** * Get a Plan's details * <p> * * @param planCode recurly id of plan * @return the plan object as identified by the passed in ID */ public Plan getPlan(final String planCode) { return doGET(Plan.PLANS_RESOURCE + "/" + planCode, Plan.class); } /** * Return all the plans * <p> * * @return the plan object as identified by the passed in ID */ public Plans getPlans() { return doGET(Plans.PLANS_RESOURCE, Plans.class); } /** * Return all the plans given query params * <p> * * @param params {@link QueryParams} * @return the plan object as identified by the passed in ID */ public Plans getPlans(final QueryParams params) { return doGET(Plans.PLANS_RESOURCE, Plans.class, params); } /** * Deletes a {@link Plan} * <p> * * @param planCode The {@link Plan} object to delete. */ public void deletePlan(final String planCode) { doDELETE(Plan.PLANS_RESOURCE + "/" + planCode); } /////////////////////////////////////////////////////////////////////////// /** * Create an AddOn to a Plan * <p> * * @param planCode The planCode of the {@link Plan } to create within recurly * @param addOn The {@link AddOn} to create within recurly * @return the {@link AddOn} object as identified by the passed in object */ public AddOn createPlanAddOn(final String planCode, final AddOn addOn) { return doPOST(Plan.PLANS_RESOURCE + "/" + planCode + AddOn.ADDONS_RESOURCE, addOn, AddOn.class); } /** * Get an AddOn's details * <p> * * @param addOnCode recurly id of {@link AddOn} * @param planCode recurly id of {@link Plan} * @return the {@link AddOn} object as identified by the passed in plan and add-on IDs */ public AddOn getAddOn(final String planCode, final String addOnCode) { return doGET(Plan.PLANS_RESOURCE + "/" + planCode + AddOn.ADDONS_RESOURCE + "/" + addOnCode, AddOn.class); } /** * Return all the {@link AddOn} for a {@link Plan} * <p> * * @param planCode * @return the {@link AddOn} objects as identified by the passed plan ID */ public AddOns getAddOns(final String planCode) { return doGET(Plan.PLANS_RESOURCE + "/" + planCode + AddOn.ADDONS_RESOURCE, AddOns.class); } /** * Return all the {@link AddOn} for a {@link Plan} * <p> * * @param planCode * @param params {@link QueryParams} * @return the {@link AddOn} objects as identified by the passed plan ID */ public AddOns getAddOns(final String planCode, final QueryParams params) { return doGET(Plan.PLANS_RESOURCE + "/" + planCode + AddOn.ADDONS_RESOURCE, AddOns.class, params); } /** * Deletes a {@link AddOn} for a Plan * <p> * * @param planCode The {@link Plan} object. * @param addOnCode The {@link AddOn} object to delete. */ public void deleteAddOn(final String planCode, final String addOnCode) { doDELETE(Plan.PLANS_RESOURCE + "/" + planCode + AddOn.ADDONS_RESOURCE + "/" + addOnCode); } /////////////////////////////////////////////////////////////////////////// /** * Create a {@link Coupon} * <p> * * @param coupon The coupon to create on recurly * @return the {@link Coupon} object */ public Coupon createCoupon(final Coupon coupon) { return doPOST(Coupon.COUPON_RESOURCE, coupon, Coupon.class); } /** * Get a Coupon * <p> * * @param couponCode The code for the {@link Coupon} * @return The {@link Coupon} object as identified by the passed in code */ public Coupon getCoupon(final String couponCode) { return doGET(Coupon.COUPON_RESOURCE + "/" + couponCode, Coupon.class); } /** * Delete a {@link Coupon} * <p> * * @param couponCode The code for the {@link Coupon} */ public void deleteCoupon(final String couponCode) { doDELETE(Coupon.COUPON_RESOURCE + "/" + couponCode); } /////////////////////////////////////////////////////////////////////////// /** * Redeem a {@link Coupon} on an account. * * @param couponCode redeemed coupon id * @return the {@link Coupon} object */ public Redemption redeemCoupon(final String couponCode, final Redemption redemption) { return doPOST(Coupon.COUPON_RESOURCE + "/" + couponCode + Redemption.REDEEM_RESOURCE, redemption, Redemption.class); } /** * Lookup the first coupon redemption on an account. * * @param accountCode recurly account id * @return the coupon redemption for this account on success, null otherwise */ public Redemption getCouponRedemptionByAccount(final String accountCode) { return doGET(Accounts.ACCOUNTS_RESOURCE + "/" + accountCode + Redemption.REDEMPTION_RESOURCE, Redemption.class); } /** * Lookup all coupon redemptions on an account. * * @param accountCode recurly account id * @return the coupon redemptions for this account on success, null otherwise */ public Redemptions getCouponRedemptionsByAccount(final String accountCode) { return doGET(Accounts.ACCOUNTS_RESOURCE + "/" + accountCode + Redemption.REDEMPTIONS_RESOURCE, Redemptions.class); } /** * Lookup all coupon redemptions on an account given query params. * * @param accountCode recurly account id * @param params {@link QueryParams} * @return the coupon redemptions for this account on success, null otherwise */ public Redemptions getCouponRedemptionsByAccount(final String accountCode, final QueryParams params) { return doGET(Accounts.ACCOUNTS_RESOURCE + "/" + accountCode + Redemption.REDEMPTIONS_RESOURCE, Redemptions.class, params); } /** * Lookup the first coupon redemption on an invoice. * * @deprecated Prefer using Invoice#getId() as the id param (which is a String) * * @param invoiceNumber invoice number * @return the coupon redemption for this invoice on success, null otherwise */ @Deprecated public Redemption getCouponRedemptionByInvoice(final Integer invoiceNumber) { return getCouponRedemptionByInvoice(invoiceNumber.toString()); } /** * Lookup the first coupon redemption on an invoice. * * @param invoiceId String invoice id * @return the coupon redemption for this invoice on success, null otherwise */ public Redemption getCouponRedemptionByInvoice(final String invoiceId) { return doGET(Invoices.INVOICES_RESOURCE + "/" + invoiceId + Redemption.REDEMPTION_RESOURCE, Redemption.class); } /** * Lookup all coupon redemptions on an invoice. * * @deprecated Prefer using Invoice#getId() as the id param (which is a String) * * @param invoiceNumber invoice number * @return the coupon redemptions for this invoice on success, null otherwise */ @Deprecated public Redemptions getCouponRedemptionsByInvoice(final Integer invoiceNumber) { return getCouponRedemptionsByInvoice(invoiceNumber.toString(), new QueryParams()); } /** * Lookup all coupon redemptions on an invoice. * * @param invoiceId String invoice id * @return the coupon redemptions for this invoice on success, null otherwise */ public Redemptions getCouponRedemptionsByInvoice(final String invoiceId) { return getCouponRedemptionsByInvoice(invoiceId, new QueryParams()); } /** * Lookup all coupon redemptions on an invoice given query params. * * @deprecated Prefer using Invoice#getId() as the id param (which is a String) * * @param invoiceNumber invoice number * @param params {@link QueryParams} * @return the coupon redemptions for this invoice on success, null otherwise */ @Deprecated public Redemptions getCouponRedemptionsByInvoice(final Integer invoiceNumber, final QueryParams params) { return getCouponRedemptionsByInvoice(invoiceNumber.toString(), params); } /** * Lookup all coupon redemptions on an invoice given query params. * * @param invoiceId String invoice id * @param params {@link QueryParams} * @return the coupon redemptions for this invoice on success, null otherwise */ public Redemptions getCouponRedemptionsByInvoice(final String invoiceId, final QueryParams params) { return doGET(Invoices.INVOICES_RESOURCE + "/" + invoiceId + Redemption.REDEMPTION_RESOURCE, Redemptions.class, params); } /** * Deletes a coupon redemption from an account. * * @param accountCode recurly account id */ public void deleteCouponRedemption(final String accountCode) { doDELETE(Accounts.ACCOUNTS_RESOURCE + "/" + accountCode + Redemption.REDEMPTION_RESOURCE); } /** * Deletes a specific redemption. * * @param accountCode recurly account id * @param redemptionUuid recurly coupon redemption uuid */ public void deleteCouponRedemption(final String accountCode, final String redemptionUuid) { doDELETE(Accounts.ACCOUNTS_RESOURCE + "/" + accountCode + Redemption.REDEMPTIONS_RESOURCE + "/" + redemptionUuid); } /////////////////////////////////////////////////////////////////////////// // // Recurly.js API // /////////////////////////////////////////////////////////////////////////// /** * Fetch Subscription * <p> * Returns subscription from a recurly.js token. * * @param recurlyToken token given by recurly.js * @return subscription object on success, null otherwise */ public Subscription fetchSubscription(final String recurlyToken) { return fetch(recurlyToken, Subscription.class); } /** * Fetch BillingInfo * <p> * Returns billing info from a recurly.js token. * * @param recurlyToken token given by recurly.js * @return billing info object on success, null otherwise */ public BillingInfo fetchBillingInfo(final String recurlyToken) { return fetch(recurlyToken, BillingInfo.class); } /** * Fetch Invoice * <p> * Returns invoice from a recurly.js token. * * @param recurlyToken token given by recurly.js * @return invoice object on success, null otherwise */ public Invoice fetchInvoice(final String recurlyToken) { return fetch(recurlyToken, Invoice.class); } /** * Get Gift Cards given query params * <p> * Returns information about all gift cards. * * @param params {@link QueryParams} * @return gitfcards object on success, null otherwise */ public GiftCards getGiftCards(final QueryParams params) { return doGET(GiftCards.GIFT_CARDS_RESOURCE, GiftCards.class, params); } /** * Get Gift Cards * <p> * Returns information about all gift cards. * * @return gitfcards object on success, null otherwise */ public GiftCards getGiftCards() { return doGET(GiftCards.GIFT_CARDS_RESOURCE, GiftCards.class); } /** * Get a Gift Card * <p> * * @param giftCardId The id for the {@link GiftCard} * @return The {@link GiftCard} object as identified by the passed in id */ public GiftCard getGiftCard(final Long giftCardId) { return doGET(GiftCards.GIFT_CARDS_RESOURCE + "/" + Long.toString(giftCardId), GiftCard.class); } /** * Redeem a Gift Card * <p> * * @param redemptionCode The redemption code the {@link GiftCard} * @param accountCode The account code for the {@link Account} * @return The updated {@link GiftCard} object as identified by the passed in id */ public GiftCard redeemGiftCard(final String redemptionCode, final String accountCode) { final GiftCard.Redemption redemptionData = GiftCard.createRedemption(accountCode); final String url = GiftCards.GIFT_CARDS_RESOURCE + "/" + redemptionCode + "/redeem"; return doPOST(url, redemptionData, GiftCard.class); } /** * Purchase a GiftCard * <p> * * @param giftCard The giftCard data * @return the giftCard object */ public GiftCard purchaseGiftCard(final GiftCard giftCard) { return doPOST(GiftCards.GIFT_CARDS_RESOURCE, giftCard, GiftCard.class); } /** * Preview a GiftCard * <p> * * @param giftCard The giftCard data * @return the giftCard object */ public GiftCard previewGiftCard(final GiftCard giftCard) { return doPOST(GiftCards.GIFT_CARDS_RESOURCE + "/preview", giftCard, GiftCard.class); } /** * Return all the MeasuredUnits * <p> * * @return the MeasuredUnits object as identified by the passed in ID */ public MeasuredUnits getMeasuredUnits() { return doGET(MeasuredUnits.MEASURED_UNITS_RESOURCE, MeasuredUnits.class); } /** * Create a MeasuredUnit's info * <p> * * @param measuredUnit The measuredUnit to create on recurly * @return the measuredUnit object as identified by the passed in ID */ public MeasuredUnit createMeasuredUnit(final MeasuredUnit measuredUnit) { return doPOST(MeasuredUnit.MEASURED_UNITS_RESOURCE, measuredUnit, MeasuredUnit.class); } private <T> T fetch(final String recurlyToken, final Class<T> clazz) { return doGET(FETCH_RESOURCE + "/" + recurlyToken, clazz); } /////////////////////////////////////////////////////////////////////////// private InputStream doGETPdf(final String resource) { return doGETPdfWithFullURL(baseUrl + resource); } private <T> T doGET(final String resource, final Class<T> clazz) { return doGETWithFullURL(clazz, constructGetUrl(resource, new QueryParams())); } private <T> T doGET(final String resource, final Class<T> clazz, QueryParams params) { return doGETWithFullURL(clazz, constructGetUrl(resource, params)); } private String constructGetUrl(final String resource, QueryParams params) { return baseUrl + resource + params.toString(); } public <T> T doGETWithFullURL(final Class<T> clazz, final String url) { if (debug()) { log.info("Msg to Recurly API [GET] :: URL : {}", url); } return callRecurlySafeXmlContent(client.prepareGet(url), clazz); } private InputStream doGETPdfWithFullURL(final String url) { if (debug()) { log.info("Msg to Recurly API [GET] :: URL : {}", url); } return callRecurlySafeGetPdf(url); } private InputStream callRecurlySafeGetPdf(String url) { final Response response; final InputStream pdfInputStream; try { response = clientRequestBuilderCommon(client.prepareGet(url)).addHeader("Accept", "application/pdf") .addHeader("Content-Type", "application/pdf").execute().get(); pdfInputStream = response.getResponseBodyAsStream(); } catch (InterruptedException e) { log.error("Interrupted while calling recurly", e); return null; } catch (ExecutionException e) { log.error("Execution error", e); return null; } catch (IOException e) { log.error("Error retrieving response body", e); return null; } if (response.getStatusCode() != 200) { RecurlyAPIError recurlyAPIError = new RecurlyAPIError(); recurlyAPIError.setHttpStatusCode(response.getStatusCode()); throw new RecurlyAPIException(recurlyAPIError); } return pdfInputStream; } private <T> T doPOST(final String resource, final RecurlyObject payload, final Class<T> clazz) { final String xmlPayload; try { xmlPayload = xmlMapper.writeValueAsString(payload); if (debug()) { log.info("Msg to Recurly API [POST]:: URL : {}", baseUrl + resource); log.info("Payload for [POST]:: {}", xmlPayload); } } catch (IOException e) { log.warn("Unable to serialize {} object as XML: {}", clazz.getName(), payload.toString()); return null; } return callRecurlySafeXmlContent(client.preparePost(baseUrl + resource).setBody(xmlPayload), clazz); } private <T> T doPUT(final String resource, final RecurlyObject payload, final Class<T> clazz) { final String xmlPayload; try { if (payload != null) { xmlPayload = xmlMapper.writeValueAsString(payload); } else { xmlPayload = null; } if (debug()) { log.info("Msg to Recurly API [PUT]:: URL : {}", baseUrl + resource); log.info("Payload for [PUT]:: {}", xmlPayload); } } catch (IOException e) { log.warn("Unable to serialize {} object as XML: {}", clazz.getName(), payload.toString()); return null; } return callRecurlySafeXmlContent(client.preparePut(baseUrl + resource).setBody(xmlPayload), clazz); } private void doDELETE(final String resource) { callRecurlySafeXmlContent(client.prepareDelete(baseUrl + resource), null); } private <T> T callRecurlySafeXmlContent(final AsyncHttpClient.BoundRequestBuilder builder, @Nullable final Class<T> clazz) { try { return callRecurlyXmlContent(builder, clazz); } catch (IOException e) { log.warn("Error while calling Recurly", e); return null; } catch (ExecutionException e) { // Extract the errors exception, if any if (e.getCause() != null && e.getCause().getCause() != null && e.getCause().getCause() instanceof TransactionErrorException) { throw (TransactionErrorException) e.getCause().getCause(); } else if (e.getCause() != null && e.getCause() instanceof TransactionErrorException) { // See https://github.com/killbilling/recurly-java-library/issues/16 throw (TransactionErrorException) e.getCause(); } log.error("Execution error", e); return null; } catch (InterruptedException e) { log.error("Interrupted while calling Recurly", e); return null; } } private <T> T callRecurlyXmlContent(final AsyncHttpClient.BoundRequestBuilder builder, @Nullable final Class<T> clazz) throws IOException, ExecutionException, InterruptedException { final Response response = clientRequestBuilderCommon(builder).addHeader("Accept", "application/xml") .addHeader("Content-Type", "application/xml; charset=utf-8").execute().get(); final InputStream in = response.getResponseBodyAsStream(); try { final String payload = convertStreamToString(in); if (debug()) { log.info("Msg from Recurly API :: {}", payload); } // Handle errors payload if (response.getStatusCode() >= 300) { log.warn("Recurly error whilst calling: {}\n{}", response.getUri(), payload); RecurlyAPIError recurlyError = new RecurlyAPIError(); if (response.getStatusCode() == 422) { final Errors errors; try { errors = xmlMapper.readValue(payload, Errors.class); } catch (Exception e) { // 422 is returned for transaction errors (see https://recurly.readme.io/v2.0/page/transaction-errors) // as well as bad input payloads log.debug("Unable to extract error", e); return null; } throw new TransactionErrorException(errors); } else if (response.getStatusCode() == 401) { recurlyError.setSymbol("unauthorized"); recurlyError.setDescription( "We could not authenticate your request. Either your subdomain and private key are not set or incorrect"); throw new RecurlyAPIException(recurlyError); } else { try { recurlyError = xmlMapper.readValue(payload, RecurlyAPIError.class); } catch (Exception e) { log.debug("Unable to extract error", e); } recurlyError.setHttpStatusCode(response.getStatusCode()); throw new RecurlyAPIException(recurlyError); } } if (clazz == null) { return null; } final T obj = xmlMapper.readValue(payload, clazz); if (obj instanceof RecurlyObject) { ((RecurlyObject) obj).setRecurlyClient(this); } else if (obj instanceof RecurlyObjects) { final RecurlyObjects recurlyObjects = (RecurlyObjects) obj; recurlyObjects.setRecurlyClient(this); // Set the RecurlyClient on all objects for later use for (final Object object : recurlyObjects) { ((RecurlyObject) object).setRecurlyClient(this); } // Set the total number of records final String xRecords = response.getHeader(X_RECORDS_HEADER_NAME); if (xRecords != null) { recurlyObjects.setNbRecords(Integer.valueOf(xRecords)); } // Set links for pagination final String linkHeader = response.getHeader(LINK_HEADER_NAME); if (linkHeader != null) { final String[] links = PaginationUtils.getLinks(linkHeader); recurlyObjects.setStartUrl(links[0]); recurlyObjects.setNextUrl(links[1]); } } return obj; } finally { closeStream(in); } } private AsyncHttpClient.BoundRequestBuilder clientRequestBuilderCommon( AsyncHttpClient.BoundRequestBuilder requestBuilder) { return requestBuilder.addHeader("Authorization", "Basic " + key) .addHeader("X-Api-Version", RECURLY_API_VERSION).addHeader(HttpHeaders.USER_AGENT, userAgent) .setBodyEncoding("UTF-8"); } private String convertStreamToString(final java.io.InputStream is) { try { return new Scanner(is).useDelimiter("\\A").next(); } catch (final NoSuchElementException e) { return ""; } } private void closeStream(final InputStream in) { if (in != null) { try { in.close(); } catch (IOException e) { log.warn("Failed to close http-client - provided InputStream: {}", e.getLocalizedMessage()); } } } protected AsyncHttpClient createHttpClient() throws KeyManagementException, NoSuchAlgorithmException { final AsyncHttpClientConfig.Builder builder = new AsyncHttpClientConfig.Builder(); // Don't limit the number of connections per host // See https://github.com/ning/async-http-client/issues/issue/28 builder.setMaxConnectionsPerHost(-1); builder.setSSLContext(SslUtils.getInstance().getSSLContext()); return new AsyncHttpClient(builder.build()); } @VisibleForTesting String getUserAgent() { return userAgent; } private String buildUserAgent() { final String defaultVersion = "0.0.0"; final String defaultJavaVersion = "0.0.0"; try { final Properties gitRepositoryState = new Properties(); final URL resourceURL = Resources.getResource(GIT_PROPERTIES_FILE); final CharSource charSource = Resources.asCharSource(resourceURL, Charset.forName("UTF-8")); Reader reader = null; try { reader = charSource.openStream(); gitRepositoryState.load(reader); } finally { if (reader != null) { reader.close(); } } final String version = MoreObjects.firstNonNull(getVersionFromGitRepositoryState(gitRepositoryState), defaultVersion); final String javaVersion = MoreObjects.firstNonNull(StandardSystemProperty.JAVA_VERSION.value(), defaultJavaVersion); return String.format("KillBill/%s; %s", version, javaVersion); } catch (final Exception e) { return String.format("KillBill/%s; %s", defaultVersion, defaultJavaVersion); } } @VisibleForTesting String getVersionFromGitRepositoryState(final Properties gitRepositoryState) { final String gitDescribe = gitRepositoryState.getProperty(GIT_COMMIT_ID_DESCRIBE_SHORT); if (gitDescribe == null) { return null; } final Matcher matcher = TAG_FROM_GIT_DESCRIBE_PATTERN.matcher(gitDescribe); return matcher.find() ? matcher.group(1) : null; } }