Java tutorial
/* * Copyright 2010-2014 Ning, Inc. * Copyright 2014 The Billing Project, LLC * * Ning 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 org.killbill.billing.plugin.bitcoin.osgi.http; import java.io.IOException; import java.io.UnsupportedEncodingException; import java.math.BigDecimal; import java.util.LinkedList; import java.util.List; import java.util.UUID; import java.util.concurrent.ExecutionException; import javax.annotation.Nullable; import javax.servlet.ServletException; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.bitcoin.protocols.payments.Protos; import org.joda.time.DateTime; import org.joda.time.DateTimeZone; import org.joda.time.LocalDate; import org.killbill.billing.BillingExceptionBase; import org.killbill.billing.ObjectType; import org.killbill.billing.account.api.Account; import org.killbill.billing.account.api.AccountApiException; import org.killbill.billing.catalog.api.BillingPeriod; import org.killbill.billing.catalog.api.Catalog; import org.killbill.billing.catalog.api.CatalogApiException; import org.killbill.billing.catalog.api.CatalogUserApi; import org.killbill.billing.catalog.api.Currency; import org.killbill.billing.catalog.api.PhaseType; import org.killbill.billing.catalog.api.Plan; import org.killbill.billing.catalog.api.PlanPhase; import org.killbill.billing.catalog.api.PlanPhaseSpecifier; import org.killbill.billing.entitlement.api.Entitlement; import org.killbill.billing.entitlement.api.EntitlementApiException; import org.killbill.billing.entitlement.api.Subscription; import org.killbill.billing.entitlement.api.SubscriptionApiException; import org.killbill.billing.entitlement.api.SubscriptionBundle; import org.killbill.billing.entitlement.api.SubscriptionEvent; import org.killbill.billing.entitlement.api.SubscriptionEventType; import org.killbill.billing.payment.api.Payment; import org.killbill.billing.payment.api.PaymentApiException; import org.killbill.billing.plugin.bitcoin.osgi.BitcoinActivator; import org.killbill.billing.plugin.bitcoin.osgi.BitcoinCallContext; import org.killbill.billing.plugin.bitcoin.osgi.BitcoinManager; import org.killbill.billing.plugin.bitcoin.osgi.BitcoinSubscriptionId; import org.killbill.billing.plugin.bitcoin.osgi.Contract; import org.killbill.billing.plugin.bitcoin.osgi.PendingPayment; import org.killbill.billing.plugin.bitcoin.osgi.TransactionLog; import org.killbill.billing.plugin.bitcoin.osgi.dao.ContractDao; import org.killbill.billing.plugin.bitcoin.osgi.dao.PendingPaymentDao; import org.killbill.billing.plugin.bitcoin.osgi.dao.TransactionLogDao; import org.killbill.billing.tenant.api.Tenant; import org.killbill.billing.util.api.CustomFieldApiException; import org.killbill.billing.util.api.TagApiException; import org.killbill.billing.util.callcontext.CallContext; import org.killbill.billing.util.callcontext.TenantContext; import org.killbill.killbill.osgi.libs.killbill.OSGIKillbillAPI; import com.google.bitcoin.core.ECKey; import com.google.bitcoin.core.Transaction; import com.google.bitcoin.script.ScriptBuilder; import com.google.common.base.Objects; import com.google.common.base.Preconditions; import com.google.common.base.Predicate; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Iterables; import com.google.common.collect.Lists; import com.google.protobuf.ByteString; public class PaymentRequestServlet extends HttpServlet { // TODO configurable (multi-tenant?) public static final String DEFAULT_MERCHANT_ID = "org.killbill"; private final static String BTC_SERVLET_BASE_PATH = "/plugins/" + BitcoinActivator.PLUGIN_NAME; private final static String BTC_SUBSCRIPTION_CONTRACT = "/contract"; private final static String BTC_SUBSCRIPTION_POLLING = "/polling"; private final static String BTC_SUBSCRIPTION_PAYMENT = "/payment"; private final static String BTC_WALLET = "/wallet"; private final static String BTC_SUBSCRIPTION_CONTRACT_PATH = BTC_SERVLET_BASE_PATH + BTC_SUBSCRIPTION_CONTRACT; private final static String BTC_SUBSCRIPTION_POLLING_PATH = BTC_SERVLET_BASE_PATH + BTC_SUBSCRIPTION_POLLING; private final static String BTC_SUBSCRIPTION_PAYMENT_PATH = BTC_SERVLET_BASE_PATH + BTC_SUBSCRIPTION_PAYMENT; private final static long BTC_TO_SATOSHIS = (1000L * 1000L * 100L); private static final String HDR_CREATED_BY = "X-Killbill-CreatedBy"; private static final String HDR_REASON = "X-Killbill-Reason"; private static final String HDR_COMMENT = "X-Killbill-Comment"; private final OSGIKillbillAPI killbillAPI; private final ContractDao contractDao; private final PendingPaymentDao paymentDao; private final BitcoinManager bitcoinManager; private final TransactionLogDao transactionLogDao; public PaymentRequestServlet(OSGIKillbillAPI killbillAPI, ContractDao contractDao, PendingPaymentDao paymentDao, TransactionLogDao transactionLogDao, BitcoinManager bitcoinManager) { this.killbillAPI = killbillAPI; this.contractDao = contractDao; this.paymentDao = paymentDao; this.transactionLogDao = transactionLogDao; this.bitcoinManager = bitcoinManager; } @Override protected void doGet(final HttpServletRequest req, final HttpServletResponse resp) throws ServletException, IOException { try { final String pathInfo = req.getPathInfo(); if (pathInfo.equals(BTC_SUBSCRIPTION_CONTRACT)) { createContract(req, resp); } else if (pathInfo.equals(BTC_SUBSCRIPTION_POLLING)) { pollForPayment(req, resp); } else if (pathInfo.equals(BTC_WALLET)) { dumpWallet(req, resp); } else { resp.setStatus(HttpServletResponse.SC_NOT_FOUND); } } catch (BillingExceptionBase e) { resp.setStatus(HttpServletResponse.SC_BAD_REQUEST); } } private void dumpWallet(HttpServletRequest req, HttpServletResponse resp) throws IOException { resp.getOutputStream().write(bitcoinManager.walletAsString().getBytes("UTF-8")); resp.setStatus(HttpServletResponse.SC_OK); } @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { try { final String pathInfo = req.getPathInfo(); if (pathInfo.equals(BTC_SUBSCRIPTION_PAYMENT)) { createPayment(req, resp); } else { resp.setStatus(HttpServletResponse.SC_NOT_FOUND); } } catch (BillingExceptionBase e) { resp.setStatus(HttpServletResponse.SC_BAD_REQUEST); } catch (InterruptedException e) { resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); } catch (ExecutionException e) { resp.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); } } private void createPayment(HttpServletRequest req, HttpServletResponse resp) throws IOException, BillingExceptionBase, ExecutionException, InterruptedException { final Protos.Payment payment = Protos.Payment.parseFrom(req.getInputStream()); final UUID contractId = UUID.fromString(new String(payment.getMerchantData().toByteArray())); // TODO for now just accept one transaction per payment Preconditions.checkState(payment.getTransactionsCount() == 1, "Single output transactions for now"); final List<PendingPayment> pendingPayments = paymentDao.getByBtcContractId(contractId); // For now, we take the first one final PendingPayment pendingPayment = pendingPayments.size() > 0 ? pendingPayments.get(0) : null; if (pendingPayment == null) { // Nothing to pay resp.setStatus(HttpServletResponse.SC_GONE); return; } transactionLogDao.insertTransactionLog(new TransactionLog(new DateTime(DateTimeZone.UTC), "createPayment", pendingPayment.getAccountId(), null, contractId)); final List<ByteString> transactionList = payment.getTransactionsList(); //Collection<TransactionOutput> outputs = bitcoinManager.isMine(transactionList.get(0)); //Preconditions.checkState(outputs.size() == 1, "Expecting one"); final Transaction broadcastedTransaction = bitcoinManager.broadcastTransaction(transactionList.get(0)); bitcoinManager.commitTransaction(broadcastedTransaction); // PIERRE TODO We cannot solely rely on transactionId because of Malleability issues -- https://en.bitcoin.it/wiki/Transaction_Malleability // We should really track transaction with inputs, outputs -- which contain the address paymentDao.update(pendingPayment.getRecordId(), broadcastedTransaction.getHash().toString()); final Protos.PaymentACK paymentAck = Protos.PaymentACK.newBuilder().setPayment(payment) .setMemo("Kill Bill payment id " + pendingPayment.getPaymentId()).build(); paymentAck.writeTo(resp.getOutputStream()); resp.setContentType("application/bitcoin-paymentack"); resp.setStatus(HttpServletResponse.SC_OK); } private void createContract(final HttpServletRequest req, final HttpServletResponse resp) throws ServletException, IOException, CatalogApiException, AccountApiException, PaymentApiException, EntitlementApiException, CustomFieldApiException, TagApiException, SubscriptionApiException { final String networkArg = Objects.firstNonNull(req.getParameter("network"), "main"); final String contractIdArg = req.getParameter("contractId"); // TODO The request parameter should be a subscriptionId, but the wallet should be given btcSubscriptionId. final String bitcoinSubscriptionIdArg = req.getParameter("subscriptionId"); final DateTime nowDateTime = new DateTime(DateTimeZone.UTC); final LocalDate now = new LocalDate(nowDateTime); final CallContext callContext = createCallContext(req, resp); final BitcoinSubscriptionId bitcoinSubscriptionId = BitcoinSubscriptionId .fromString(bitcoinSubscriptionIdArg); // TODO Works only for subscription aligned - exercise for the reader for bundle / account aligned // Depending on the alignment, the contracts should contain one contract for each entity that can end up in one invoice. // For example, all subscriptions on a given bundle, or all account aligned subscriptions Preconditions.checkState(ObjectType.SUBSCRIPTION.equals(bitcoinSubscriptionId.getAlignment())); final UUID subscriptionId = bitcoinSubscriptionId.getEntityId(); final Subscription subscription = killbillAPI.getSubscriptionApi() .getSubscriptionForEntitlementId(subscriptionId, callContext); final SubscriptionBundle bundle = killbillAPI.getSubscriptionApi() .getSubscriptionBundle(subscription.getBundleId(), callContext); SubscriptionEvent currentEvent = null; for (final SubscriptionEvent subscriptionEvent : Lists .reverse(bundle.getTimeline().getSubscriptionEvents())) { if (subscriptionEvent.getEntitlementId().equals(subscription.getId()) && subscriptionEvent.getEffectiveDate().compareTo(now) <= 0 && (SubscriptionEventType.START_BILLING.equals(subscriptionEvent.getSubscriptionEventType()) || SubscriptionEventType.CHANGE.equals(subscriptionEvent.getSubscriptionEventType()))) { currentEvent = subscriptionEvent; break; } } final SubscriptionEvent futureChangeOrCancelEvent = Iterables.<SubscriptionEvent>tryFind( bundle.getTimeline().getSubscriptionEvents(), new Predicate<SubscriptionEvent>() { @Override public boolean apply(SubscriptionEvent input) { return input.getEntitlementId().equals(subscription.getId()) && (SubscriptionEventType.CHANGE.equals(input.getSubscriptionEventType()) || SubscriptionEventType.STOP_BILLING .equals(input.getSubscriptionEventType())) && // TODO clock input.getEffectiveDate().compareTo(new LocalDate(new DateTime(DateTimeZone.UTC))) > 0; } }).orNull(); final boolean isCancelled = (subscription.getBillingEndDate() != null && subscription.getBillingEndDate().compareTo(new LocalDate(now)) <= 0); final long maxPayment = isCancelled ? 0L : getMaxPaymentAmount(currentEvent.getNextPlan()); final Protos.PaymentFrequencyType frequencyType = isCancelled ? null : getPaymentFrequencyType(subscription.getLastActivePlan().getBillingPeriod()); final List<Protos.RecurringPaymentContract> contracts = new LinkedList<Protos.RecurringPaymentContract>(); final UUID contractId = contractIdArg == null ? UUID.randomUUID() : UUID.fromString(contractIdArg); Protos.RecurringPaymentContract.Builder currentContractBuilder = Protos.RecurringPaymentContract .newBuilder().setContractId(uuidToByteString(contractId)) .setStarts(localDateToMillis(currentEvent.getEffectiveDate())) .setPollingUrl(createURL(req, BTC_SUBSCRIPTION_POLLING_PATH, ImmutableMap.<String, String>of("merchantId", DEFAULT_MERCHANT_ID, "subscriptionId", bitcoinSubscriptionId.toString(), "contractId", contractId.toString(), "network", networkArg))) .setPaymentFrequencyType(frequencyType).setMaxPaymentPerPeriod(maxPayment) .setMaxPaymentAmount(maxPayment); if (futureChangeOrCancelEvent != null) { currentContractBuilder.setEnds(localDateToMillis(futureChangeOrCancelEvent.getEffectiveDate())); // TODO check it may exist! final UUID nextContractId = UUID.randomUUID(); transactionLogDao.insertTransactionLog(new TransactionLog(new DateTime(DateTimeZone.UTC), "createContract", subscription.getAccountId(), subscription.getId(), nextContractId)); final long nextMaxAmount = getMaxPaymentAmount(futureChangeOrCancelEvent.getNextPlan()); Protos.RecurringPaymentContract.Builder nextContractBuilder = Protos.RecurringPaymentContract .newBuilder().setContractId(uuidToByteString(nextContractId)) .setStarts(localDateToMillis(futureChangeOrCancelEvent.getEffectiveDate())) .setEnds(localDateToMillis(subscription.getBillingEndDate())) .setPollingUrl(createURL(req, BTC_SUBSCRIPTION_POLLING_PATH, ImmutableMap.<String, String>of("merchantId", DEFAULT_MERCHANT_ID, "subscriptionId", bitcoinSubscriptionId.toString(), "contractId", nextContractId.toString(), "network", networkArg))) .setMaxPaymentPerPeriod(nextMaxAmount).setMaxPaymentAmount(nextMaxAmount); if (futureChangeOrCancelEvent.getNextPlan() != null) { nextContractBuilder.setPaymentFrequencyType( getPaymentFrequencyType(futureChangeOrCancelEvent.getNextPlan().getBillingPeriod())); } contracts.add(nextContractBuilder.build()); } contracts.add(currentContractBuilder.build()); if (contractIdArg == null) { contractDao .insertContract(new Contract(new BitcoinSubscriptionId(ObjectType.SUBSCRIPTION, subscriptionId), currentEvent.getEffectiveDate(), futureChangeOrCancelEvent == null ? null : futureChangeOrCancelEvent.getEffectiveDate(), contractId)); transactionLogDao.insertTransactionLog(new TransactionLog(new DateTime(DateTimeZone.UTC), "createContract", subscription.getAccountId(), subscription.getId(), contractId)); } Protos.RecurringPaymentDetails recurringPaymentDetails = Protos.RecurringPaymentDetails.newBuilder() .setMerchantId(DEFAULT_MERCHANT_ID).setSubscriptionId(uuidToByteString(subscription.getId())) .addAllContracts(contracts).build(); Protos.PaymentDetails details = Protos.PaymentDetails.newBuilder().setNetwork(networkArg) .setTime(nowDateTime.getMillis()).setExpires(nowDateTime.plusDays(1).getMillis()) .setMemo("Kill Bill subscription " + subscription.getLastActivePlan().getName()) .setPaymentUrl(createURL(req, BTC_SUBSCRIPTION_PAYMENT_PATH)) .setMerchantData(ByteString.copyFrom(contractId.toString().getBytes())) .setSerializedRecurringPaymentDetails(recurringPaymentDetails.toByteString()).build(); Protos.PaymentRequest result = Protos.PaymentRequest.newBuilder().setPaymentDetailsVersion(1) .setPkiType("none") //.setPkiData(null) .setSerializedPaymentDetails(details.toByteString()) //.setSignature(null) .build(); result.writeTo(resp.getOutputStream()); resp.setContentType("application/bitcoin-paymentrequest"); resp.setStatus(HttpServletResponse.SC_OK); } private long localDateToMillis(LocalDate localDate) { return localDate.toDateTimeAtStartOfDay(DateTimeZone.UTC).getMillis(); } private ByteString uuidToByteString(UUID id) throws UnsupportedEncodingException { return ByteString.copyFrom(id.toString(), "UTF-8"); } private void pollForPayment(final HttpServletRequest req, final HttpServletResponse resp) throws ServletException, IOException, CatalogApiException, PaymentApiException { final String contractIdString = req.getParameter("contractId"); final String accountIdString = req.getParameter("accountId"); Preconditions.checkNotNull(contractIdString); final UUID contractId = UUID.fromString(contractIdString); final String network = Objects.firstNonNull(req.getParameter("network"), "main"); final List<PendingPayment> pendingPayments = paymentDao.getByBtcContractId(contractId); // TODO PIERRE combine multiple pending payments as long as this is within contract bounds. final PendingPayment pendingPayment = pendingPayments.size() > 0 ? pendingPayments.get(0) : null; final Payment payment = pendingPayment != null ? killbillAPI.getPaymentApi().getPayment(pendingPayment.getPaymentId(), false, createCallContext(req, resp)) : null; Preconditions.checkState(payment == null || payment.getCurrency() == Currency.BTC); final UUID accountId = UUID.fromString(accountIdString); transactionLogDao.insertTransactionLog( new TransactionLog(new DateTime(DateTimeZone.UTC), "pollForPayment", accountId, null, contractId)); final long paymentAmountInSatochi = payment != null ? payment.getAmount().longValue() * BTC_TO_SATOSHIS : 0L; final DateTime now = new DateTime(DateTimeZone.UTC); final String memo = payment != null ? "Kill Bill payment " + payment.getId() : "No invoice to pay"; final Protos.PaymentDetails.Builder detailsBuilder = Protos.PaymentDetails.newBuilder(); detailsBuilder.setNetwork(network).setTime(now.getMillis()).setExpires(now.plusDays(1).getMillis()) .setMemo(memo).setPaymentUrl(createURL(req, BTC_SUBSCRIPTION_PAYMENT_PATH)) .setMerchantData(ByteString.copyFrom(contractId.toString().getBytes())); if (paymentAmountInSatochi > 0) { final Protos.Output.Builder outputBuilder = Protos.Output.newBuilder(); outputBuilder.setAmount(paymentAmountInSatochi); final ECKey newPaymentKey = bitcoinManager.addKey(); outputBuilder .setScript(ByteString.copyFrom(ScriptBuilder.createOutputScript(newPaymentKey).getProgram())); final Protos.Output output = outputBuilder.build(); detailsBuilder.addOutputs(output); } final Protos.PaymentDetails details = detailsBuilder.build(); final Protos.PaymentRequest result = Protos.PaymentRequest.newBuilder().setPaymentDetailsVersion(1) .setPkiType("none") //.setPkiData(null) .setSerializedPaymentDetails(details.toByteString()) //.setSignature(null) .build(); result.writeTo(resp.getOutputStream()); resp.setContentType("application/bitcoin-paymentrequest"); resp.setStatus(HttpServletResponse.SC_OK); } private String createURL(final HttpServletRequest req, final String path) { return createURL(req, path, ImmutableMap.<String, String>of()); } private String createURL(HttpServletRequest req, String path, ImmutableMap<String, String> params) { final StringBuilder queryParamsBuilder = new StringBuilder("?"); for (final String key : params.keySet()) { queryParamsBuilder.append(key).append("=").append(params.get(key)).append("&"); } return req.getScheme() + "://" + req.getServerName() + ":" + req.getServerPort() + path + queryParamsBuilder.toString(); } private Protos.PaymentFrequencyType getPaymentFrequencyType(BillingPeriod term) { switch (term) { case MONTHLY: return Protos.PaymentFrequencyType.MONTHLY; case ANNUAL: return Protos.PaymentFrequencyType.ANNUAL; case QUARTERLY: return Protos.PaymentFrequencyType.QUARTERLY; default: throw new RuntimeException("Unsupported billing period " + term); } } private long getMaxPaymentAmount(@Nullable final Plan plan) throws CatalogApiException { if (plan == null) { return 0; } BigDecimal maxPaymentAmount = BigDecimal.ZERO; for (PlanPhase ph : plan.getAllPhases()) { BigDecimal phaseAmount = ph.getRecurringPrice() != null ? ph.getRecurringPrice().getPrice(Currency.BTC) : BigDecimal.ZERO; if (maxPaymentAmount.compareTo(phaseAmount) < 0) { maxPaymentAmount = phaseAmount; } } return maxPaymentAmount.longValue() * BTC_TO_SATOSHIS; } private CallContext createCallContext(final HttpServletRequest req, final HttpServletResponse resp) throws IOException { final String createdBy = Objects.firstNonNull(req.getHeader(HDR_CREATED_BY), req.getRemoteAddr()); final String reason = req.getHeader(HDR_REASON); final String comment = Objects.firstNonNull(req.getHeader(HDR_COMMENT), req.getRequestURI()); // Set by the TenantFilter final Tenant tenant = (Tenant) req.getAttribute("killbill_tenant"); UUID tenantId = null; if (tenant != null) { tenantId = tenant.getId(); } return new BitcoinCallContext(tenantId, reason, comment); } private Catalog getCatalog(final TenantContext context) { final CatalogUserApi catalogUserApi = killbillAPI.getCatalogUserApi(); Preconditions.checkNotNull(catalogUserApi); return catalogUserApi.getCatalog(null, context); } private UUID createSubscription(final Account account, final Plan plan, final String externalKey, final String priceList, final PhaseType phaseType, final DateTime now, final CallContext callContext) throws EntitlementApiException, TagApiException, CustomFieldApiException { final PlanPhaseSpecifier spec = new PlanPhaseSpecifier(plan.getProduct().getName(), plan.getProduct().getCategory(), plan.getBillingPeriod(), priceList, phaseType); final Entitlement entitlement = killbillAPI.getEntitlementApi().createBaseEntitlement(account.getId(), spec, externalKey, now.toLocalDate(), callContext); return entitlement.getId(); } }