/****************************************************************************** * Copyright © 2013-2016 The Nxt Core Developers. * * * * See the AUTHORS.txt, DEVELOPER-AGREEMENT.txt and LICENSE.txt files at * * the top-level directory of this distribution for the individual copyright * * holder information and the developer policies on copyright and licensing. * * * * Unless otherwise agreed in a custom licensing agreement, no part of the * * Nxt software, including this file, may be copied, modified, propagated, * * or distributed except according to the terms contained in the LICENSE.txt * * file. * * * * Removal or modification of this copyright notice is prohibited. * * * ******************************************************************************/ package nxt; import nxt.AccountLedger.LedgerEvent; import nxt.crypto.EncryptedData; import nxt.db.DbClause; import nxt.db.DbIterator; import nxt.db.DbKey; import nxt.db.DbUtils; import nxt.db.VersionedEntityDbTable; import nxt.db.VersionedValuesDbTable; import nxt.util.Convert; import nxt.util.Listener; import nxt.util.Listeners; import nxt.util.Search; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Types; import java.util.ArrayList; import java.util.List; public final class DigitalGoodsStore { public enum Event { GOODS_LISTED, GOODS_DELISTED, GOODS_PRICE_CHANGE, GOODS_QUANTITY_CHANGE, PURCHASE, DELIVERY, REFUND, FEEDBACK } static { Nxt.getBlockchainProcessor().addListener(block -> { if (block.getHeight() <= Constants.DIGITAL_GOODS_STORE_BLOCK) { return; } List<Purchase> expiredPurchases = new ArrayList<>(); try (DbIterator<Purchase> iterator = Purchase.getExpiredPendingPurchases(block)) { while (iterator.hasNext()) { expiredPurchases.add(iterator.next()); } } for (Purchase purchase : expiredPurchases) { Account buyer = Account.getAccount(purchase.getBuyerId()); buyer.addToUnconfirmedBalanceNQT(LedgerEvent.DIGITAL_GOODS_PURCHASE_EXPIRED, purchase.getId(), Math.multiplyExact((long) purchase.getQuantity(), purchase.getPriceNQT())); Goods.getGoods(purchase.getGoodsId()).changeQuantity(purchase.getQuantity()); purchase.setPending(false); } }, BlockchainProcessor.Event.AFTER_BLOCK_APPLY); } private static final Listeners<Goods,Event> goodsListeners = new Listeners<>(); private static final Listeners<Purchase,Event> purchaseListeners = new Listeners<>(); public static boolean addGoodsListener(Listener<Goods> listener, Event eventType) { return goodsListeners.addListener(listener, eventType); } public static boolean removeGoodsListener(Listener<Goods> listener, Event eventType) { return goodsListeners.removeListener(listener, eventType); } public static boolean addPurchaseListener(Listener<Purchase> listener, Event eventType) { return purchaseListeners.addListener(listener, eventType); } public static boolean removePurchaseListener(Listener<Purchase> listener, Event eventType) { return purchaseListeners.removeListener(listener, eventType); } static void init() { Tag.init(); Goods.init(); Purchase.init(); } public static final class Tag { private static final DbKey.StringKeyFactory<Tag> tagDbKeyFactory = new DbKey.StringKeyFactory<Tag>("tag") { @Override public DbKey newKey(Tag tag) { return tag.dbKey; } }; private static final VersionedEntityDbTable<Tag> tagTable = new VersionedEntityDbTable<Tag>("tag", tagDbKeyFactory) { @Override protected Tag load(Connection con, ResultSet rs) throws SQLException { return new Tag(rs); } @Override protected void save(Connection con, Tag tag) throws SQLException { tag.save(con); } @Override public String defaultSort() { return " ORDER BY in_stock_count DESC, total_count DESC, tag ASC "; } }; public static int getCount() { return tagTable.getCount(); } private static final DbClause inStockOnlyClause = new DbClause.IntClause("in_stock_count", DbClause.Op.GT, 0); public static int getCountInStock() { return tagTable.getCount(inStockOnlyClause); } public static DbIterator<Tag> getAllTags(int from, int to) { return tagTable.getAll(from, to); } public static DbIterator<Tag> getInStockTags(int from, int to) { return tagTable.getManyBy(inStockOnlyClause, from, to); } public static DbIterator<Tag> getTagsLike(String prefix, boolean inStockOnly, int from, int to) { DbClause dbClause = new DbClause.LikeClause("tag", prefix); if (inStockOnly) { dbClause = dbClause.and(inStockOnlyClause); } return tagTable.getManyBy(dbClause, from, to, " ORDER BY tag "); } private static void init() {} private static void add(Goods goods) { for (String tagValue : goods.getParsedTags()) { Tag tag = tagTable.get(tagDbKeyFactory.newKey(tagValue)); if (tag == null) { tag = new Tag(tagValue); } tag.inStockCount += 1; tag.totalCount += 1; tagTable.insert(tag); } } private static void delist(Goods goods) { for (String tagValue : goods.getParsedTags()) { Tag tag = tagTable.get(tagDbKeyFactory.newKey(tagValue)); if (tag == null) { throw new IllegalStateException("Unknown tag " + tagValue); } tag.inStockCount -= 1; tagTable.insert(tag); } } private final String tag; private final DbKey dbKey; private int inStockCount; private int totalCount; private Tag(String tag) { this.tag = tag; this.dbKey = tagDbKeyFactory.newKey(this.tag); } private Tag(ResultSet rs) throws SQLException { this.tag = rs.getString("tag"); this.dbKey = tagDbKeyFactory.newKey(this.tag); this.inStockCount = rs.getInt("in_stock_count"); this.totalCount = rs.getInt("total_count"); } private void save(Connection con) throws SQLException { try (PreparedStatement pstmt = con.prepareStatement("MERGE INTO tag (tag, in_stock_count, total_count, height, latest) " + "KEY (tag, height) VALUES (?, ?, ?, ?, TRUE)")) { int i = 0; pstmt.setString(++i, this.tag); pstmt.setInt(++i, this.inStockCount); pstmt.setInt(++i, this.totalCount); pstmt.setInt(++i, Nxt.getBlockchain().getHeight()); pstmt.executeUpdate(); } } public String getTag() { return tag; } public int getInStockCount() { return inStockCount; } public int getTotalCount() { return totalCount; } } public static final class Goods { private static final DbKey.LongKeyFactory<Goods> goodsDbKeyFactory = new DbKey.LongKeyFactory<Goods>("id") { @Override public DbKey newKey(Goods goods) { return goods.dbKey; } }; private static final VersionedEntityDbTable<Goods> goodsTable = new VersionedEntityDbTable<Goods>("goods", goodsDbKeyFactory, "name,description,tags") { @Override protected Goods load(Connection con, ResultSet rs) throws SQLException { return new Goods(rs); } @Override protected void save(Connection con, Goods goods) throws SQLException { goods.save(con); } @Override protected String defaultSort() { return " ORDER BY timestamp DESC, id ASC "; } }; private static final DbClause inStockClause = new DbClause.BooleanClause("goods.delisted", false) .and(new DbClause.LongClause("goods.quantity", DbClause.Op.GT, 0)); public static int getCount() { return goodsTable.getCount(); } public static int getCountInStock() { return goodsTable.getCount(inStockClause); } public static Goods getGoods(long goodsId) { return goodsTable.get(goodsDbKeyFactory.newKey(goodsId)); } public static DbIterator<Goods> getAllGoods(int from, int to) { return goodsTable.getAll(from, to); } public static DbIterator<Goods> getGoodsInStock(int from, int to) { return goodsTable.getManyBy(inStockClause, from, to); } public static DbIterator<Goods> getSellerGoods(final long sellerId, final boolean inStockOnly, int from, int to) { return goodsTable.getManyBy(new SellerDbClause(sellerId, inStockOnly), from, to, " ORDER BY name ASC, timestamp DESC, id ASC "); } public static int getSellerGoodsCount(long sellerId, boolean inStockOnly) { return goodsTable.getCount(new SellerDbClause(sellerId, inStockOnly)); } public static DbIterator<Goods> searchGoods(String query, boolean inStockOnly, int from, int to) { return goodsTable.search(query, inStockOnly ? inStockClause : DbClause.EMPTY_CLAUSE, from, to, " ORDER BY ft.score DESC, goods.timestamp DESC "); } public static DbIterator<Goods> searchSellerGoods(String query, long sellerId, boolean inStockOnly, int from, int to) { return goodsTable.search(query, new SellerDbClause(sellerId, inStockOnly), from, to, " ORDER BY ft.score DESC, goods.name ASC, goods.timestamp DESC "); } private static void init() {} private final long id; private final DbKey dbKey; private final long sellerId; private final String name; private final String description; private final String tags; private final String[] parsedTags; private final int timestamp; private int quantity; private long priceNQT; private boolean delisted; private Goods(Transaction transaction, Attachment.DigitalGoodsListing attachment) { this.id = transaction.getId(); this.dbKey = goodsDbKeyFactory.newKey(this.id); this.sellerId = transaction.getSenderId(); this.name = attachment.getName(); this.description = attachment.getDescription(); this.tags = attachment.getTags(); this.parsedTags = Search.parseTags(this.tags, 3, 20, 3); this.quantity = attachment.getQuantity(); this.priceNQT = attachment.getPriceNQT(); this.delisted = false; this.timestamp = Nxt.getBlockchain().getLastBlockTimestamp(); } private Goods(ResultSet rs) throws SQLException { this.id = rs.getLong("id"); this.dbKey = goodsDbKeyFactory.newKey(this.id); this.sellerId = rs.getLong("seller_id"); this.name = rs.getString("name"); this.description = rs.getString("description"); this.tags = rs.getString("tags"); this.parsedTags = DbUtils.getArray(rs, "parsed_tags", String[].class); this.quantity = rs.getInt("quantity"); this.priceNQT = rs.getLong("price"); this.delisted = rs.getBoolean("delisted"); this.timestamp = rs.getInt("timestamp"); } private void save(Connection con) throws SQLException { try (PreparedStatement pstmt = con.prepareStatement("MERGE INTO goods (id, seller_id, name, " + "description, tags, parsed_tags, timestamp, quantity, price, delisted, height, latest) KEY (id, height) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, TRUE)")) { int i = 0; pstmt.setLong(++i, this.id); pstmt.setLong(++i, this.sellerId); pstmt.setString(++i, this.name); pstmt.setString(++i, this.description); pstmt.setString(++i, this.tags); DbUtils.setArray(pstmt, ++i, this.parsedTags); pstmt.setInt(++i, this.timestamp); pstmt.setInt(++i, this.quantity); pstmt.setLong(++i, this.priceNQT); pstmt.setBoolean(++i, this.delisted); pstmt.setInt(++i, Nxt.getBlockchain().getHeight()); pstmt.executeUpdate(); } } public long getId() { return id; } public long getSellerId() { return sellerId; } public String getName() { return name; } public String getDescription() { return description; } public String getTags() { return tags; } public int getTimestamp() { return timestamp; } public int getQuantity() { return quantity; } private void changeQuantity(int deltaQuantity) { if (quantity == 0 && deltaQuantity > 0) { Tag.add(this); } quantity += deltaQuantity; if (quantity < 0) { quantity = 0; } else if (quantity > Constants.MAX_DGS_LISTING_QUANTITY) { quantity = Constants.MAX_DGS_LISTING_QUANTITY; } if (quantity == 0) { Tag.delist(this); } goodsTable.insert(this); } public long getPriceNQT() { return priceNQT; } private void changePrice(long priceNQT) { this.priceNQT = priceNQT; goodsTable.insert(this); } public boolean isDelisted() { return delisted; } private void setDelisted(boolean delisted) { this.delisted = delisted; if (this.quantity > 0) { Tag.delist(this); } goodsTable.insert(this); } public String[] getParsedTags() { return parsedTags; } } public static final class Purchase { private static final DbKey.LongKeyFactory<Purchase> purchaseDbKeyFactory = new DbKey.LongKeyFactory<Purchase>("id") { @Override public DbKey newKey(Purchase purchase) { return purchase.dbKey; } }; private static final VersionedEntityDbTable<Purchase> purchaseTable = new VersionedEntityDbTable<Purchase>("purchase", purchaseDbKeyFactory) { @Override protected Purchase load(Connection con, ResultSet rs) throws SQLException { return new Purchase(rs); } @Override protected void save(Connection con, Purchase purchase) throws SQLException { purchase.save(con); } @Override protected String defaultSort() { return " ORDER BY timestamp DESC, id ASC "; } }; private static final DbKey.LongKeyFactory<Purchase> feedbackDbKeyFactory = new DbKey.LongKeyFactory<Purchase>("id") { @Override public DbKey newKey(Purchase purchase) { return purchase.dbKey; } }; private static final VersionedValuesDbTable<Purchase, EncryptedData> feedbackTable = new VersionedValuesDbTable<Purchase, EncryptedData>("purchase_feedback", feedbackDbKeyFactory) { @Override protected EncryptedData load(Connection con, ResultSet rs) throws SQLException { byte[] data = rs.getBytes("feedback_data"); byte[] nonce = rs.getBytes("feedback_nonce"); return new EncryptedData(data, nonce); } @Override protected void save(Connection con, Purchase purchase, EncryptedData encryptedData) throws SQLException { try (PreparedStatement pstmt = con.prepareStatement("INSERT INTO purchase_feedback (id, feedback_data, feedback_nonce, " + "height, latest) VALUES (?, ?, ?, ?, TRUE)")) { int i = 0; pstmt.setLong(++i, purchase.getId()); setEncryptedData(pstmt, encryptedData, ++i); ++i; pstmt.setInt(++i, Nxt.getBlockchain().getHeight()); pstmt.executeUpdate(); } } }; private static final DbKey.LongKeyFactory<Purchase> publicFeedbackDbKeyFactory = new DbKey.LongKeyFactory<Purchase>("id") { @Override public DbKey newKey(Purchase purchase) { return purchase.dbKey; } }; private static final VersionedValuesDbTable<Purchase, String> publicFeedbackTable = new VersionedValuesDbTable<Purchase, String>("purchase_public_feedback", publicFeedbackDbKeyFactory) { @Override protected String load(Connection con, ResultSet rs) throws SQLException { return rs.getString("public_feedback"); } @Override protected void save(Connection con, Purchase purchase, String publicFeedback) throws SQLException { try (PreparedStatement pstmt = con.prepareStatement("INSERT INTO purchase_public_feedback (id, public_feedback, " + "height, latest) VALUES (?, ?, ?, TRUE)")) { int i = 0; pstmt.setLong(++i, purchase.getId()); pstmt.setString(++i, publicFeedback); pstmt.setInt(++i, Nxt.getBlockchain().getHeight()); pstmt.executeUpdate(); } } }; private static class PurchasesClause extends DbClause { private PurchasesClause(String clause, boolean withPublicFeedbacksOnly, boolean completedOnly) { super(clause + (completedOnly ? " AND goods IS NOT NULL " : " ") + (withPublicFeedbacksOnly ? " AND has_public_feedbacks = TRUE " : " ")); } @Override protected int set(PreparedStatement pstmt, int index) throws SQLException { return index; } } private static final class LongPurchasesClause extends PurchasesClause { private final long value; private LongPurchasesClause(String columnName, long value, boolean withPublicFeedbacksOnly, boolean completedOnly) { super(columnName + " = ? ", withPublicFeedbacksOnly, completedOnly); this.value = value; } @Override protected int set(PreparedStatement pstmt, int index) throws SQLException { pstmt.setLong(index++, value); return index; } } private static final class SellerBuyerPurchasesClause extends PurchasesClause { private final long sellerId; private final long buyerId; private SellerBuyerPurchasesClause(long sellerId, long buyerId, boolean withPublicFeedbacksOnly, boolean completedOnly) { super(" seller_id = ? AND buyer_id = ? ", withPublicFeedbacksOnly, completedOnly); this.sellerId = sellerId; this.buyerId = buyerId; } @Override protected int set(PreparedStatement pstmt, int index) throws SQLException { pstmt.setLong(index++, sellerId); pstmt.setLong(index++, buyerId); return index; } } public static int getCount() { return purchaseTable.getCount(); } public static int getCount(boolean withPublicFeedbacksOnly, boolean completedOnly) { return purchaseTable.getCount(new PurchasesClause(" TRUE ", withPublicFeedbacksOnly, completedOnly)); } public static DbIterator<Purchase> getAllPurchases(int from, int to) { return purchaseTable.getAll(from, to); } public static DbIterator<Purchase> getPurchases(boolean withPublicFeedbacksOnly, boolean completedOnly, int from, int to) { return purchaseTable.getManyBy(new PurchasesClause(" TRUE ", withPublicFeedbacksOnly, completedOnly), from, to); } public static DbIterator<Purchase> getSellerPurchases(long sellerId, boolean withPublicFeedbacksOnly, boolean completedOnly, int from, int to) { return purchaseTable.getManyBy(new LongPurchasesClause("seller_id", sellerId, withPublicFeedbacksOnly, completedOnly), from, to); } public static int getSellerPurchaseCount(long sellerId, boolean withPublicFeedbacksOnly, boolean completedOnly) { return purchaseTable.getCount(new LongPurchasesClause("seller_id", sellerId, withPublicFeedbacksOnly, completedOnly)); } public static DbIterator<Purchase> getBuyerPurchases(long buyerId, boolean withPublicFeedbacksOnly, boolean completedOnly, int from, int to) { return purchaseTable.getManyBy(new LongPurchasesClause("buyer_id", buyerId, withPublicFeedbacksOnly, completedOnly), from, to); } public static int getBuyerPurchaseCount(long buyerId, boolean withPublicFeedbacksOnly, boolean completedOnly) { return purchaseTable.getCount(new LongPurchasesClause("buyer_id", buyerId, withPublicFeedbacksOnly, completedOnly)); } public static DbIterator<Purchase> getSellerBuyerPurchases(final long sellerId, final long buyerId, boolean withPublicFeedbacksOnly, boolean completedOnly, int from, int to) { return purchaseTable.getManyBy(new SellerBuyerPurchasesClause(sellerId, buyerId, withPublicFeedbacksOnly, completedOnly), from, to); } public static int getSellerBuyerPurchaseCount(final long sellerId, final long buyerId, boolean withPublicFeedbacksOnly, boolean completedOnly) { return purchaseTable.getCount(new SellerBuyerPurchasesClause(sellerId, buyerId, withPublicFeedbacksOnly, completedOnly)); } public static DbIterator<Purchase> getGoodsPurchases(long goodsId, long buyerId, boolean withPublicFeedbacksOnly, boolean completedOnly, int from, int to) { DbClause clause = new LongPurchasesClause("goods_id", goodsId, withPublicFeedbacksOnly, completedOnly); if (buyerId != 0) { clause = clause.and(new DbClause.LongClause("buyer_id", buyerId)); } return purchaseTable.getManyBy(clause, from, to); } public static int getGoodsPurchaseCount(final long goodsId, boolean withPublicFeedbacksOnly, boolean completedOnly) { return purchaseTable.getCount(new LongPurchasesClause("goods_id", goodsId, withPublicFeedbacksOnly, completedOnly)); } public static Purchase getPurchase(long purchaseId) { return purchaseTable.get(purchaseDbKeyFactory.newKey(purchaseId)); } public static DbIterator<Purchase> getPendingSellerPurchases(final long sellerId, int from, int to) { DbClause dbClause = new DbClause.LongClause("seller_id", sellerId).and(new DbClause.BooleanClause("pending", true)); return purchaseTable.getManyBy(dbClause, from, to); } public static DbIterator<Purchase> getExpiredSellerPurchases(final long sellerId, int from, int to) { DbClause dbClause = new DbClause.LongClause("seller_id", sellerId) .and(new DbClause.BooleanClause("pending", false)) .and(new DbClause.NullClause("goods")); return purchaseTable.getManyBy(dbClause, from, to); } static Purchase getPendingPurchase(long purchaseId) { Purchase purchase = getPurchase(purchaseId); return purchase == null || ! purchase.isPending() ? null : purchase; } private static DbIterator<Purchase> getExpiredPendingPurchases(Block block) { final int timestamp = block.getTimestamp(); final int previousTimestamp = Nxt.getBlockchain().getBlock(block.getPreviousBlockId()).getTimestamp(); DbClause dbClause = new DbClause.LongClause("deadline", DbClause.Op.LT, timestamp) .and(new DbClause.LongClause("deadline", DbClause.Op.GTE, previousTimestamp)) .and(new DbClause.BooleanClause("pending", true)); return purchaseTable.getManyBy(dbClause, 0, -1); } private static void init() {} private final long id; private final DbKey dbKey; private final long buyerId; private final long goodsId; private final long sellerId; private final int quantity; private final long priceNQT; private final int deadline; private final EncryptedData note; private final int timestamp; private boolean isPending; private EncryptedData encryptedGoods; private boolean goodsIsText; private EncryptedData refundNote; private boolean hasFeedbackNotes; private List<EncryptedData> feedbackNotes; private boolean hasPublicFeedbacks; private List<String> publicFeedbacks; private long discountNQT; private long refundNQT; private Purchase(Transaction transaction, Attachment.DigitalGoodsPurchase attachment, long sellerId) { this.id = transaction.getId(); this.dbKey = purchaseDbKeyFactory.newKey(this.id); this.buyerId = transaction.getSenderId(); this.goodsId = attachment.getGoodsId(); this.sellerId = sellerId; this.quantity = attachment.getQuantity(); this.priceNQT = attachment.getPriceNQT(); this.deadline = attachment.getDeliveryDeadlineTimestamp(); this.note = transaction.getEncryptedMessage() == null ? null : transaction.getEncryptedMessage().getEncryptedData(); this.timestamp = Nxt.getBlockchain().getLastBlockTimestamp(); this.isPending = true; } private Purchase(ResultSet rs) throws SQLException { this.id = rs.getLong("id"); this.dbKey = purchaseDbKeyFactory.newKey(this.id); this.buyerId = rs.getLong("buyer_id"); this.goodsId = rs.getLong("goods_id"); this.sellerId = rs.getLong("seller_id"); this.quantity = rs.getInt("quantity"); this.priceNQT = rs.getLong("price"); this.deadline = rs.getInt("deadline"); this.note = loadEncryptedData(rs, "note", "nonce"); this.timestamp = rs.getInt("timestamp"); this.isPending = rs.getBoolean("pending"); this.encryptedGoods = loadEncryptedData(rs, "goods", "goods_nonce"); this.refundNote = loadEncryptedData(rs, "refund_note", "refund_nonce"); this.hasFeedbackNotes = rs.getBoolean("has_feedback_notes"); this.hasPublicFeedbacks = rs.getBoolean("has_public_feedbacks"); this.discountNQT = rs.getLong("discount"); this.refundNQT = rs.getLong("refund"); } private void save(Connection con) throws SQLException { try (PreparedStatement pstmt = con.prepareStatement("MERGE INTO purchase (id, buyer_id, goods_id, seller_id, " + "quantity, price, deadline, note, nonce, timestamp, pending, goods, goods_nonce, refund_note, " + "refund_nonce, has_feedback_notes, has_public_feedbacks, discount, refund, height, latest) KEY (id, height) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, TRUE)")) { int i = 0; pstmt.setLong(++i, this.id); pstmt.setLong(++i, this.buyerId); pstmt.setLong(++i, this.goodsId); pstmt.setLong(++i, this.sellerId); pstmt.setInt(++i, this.quantity); pstmt.setLong(++i, this.priceNQT); pstmt.setInt(++i, this.deadline); setEncryptedData(pstmt, this.note, ++i); ++i; pstmt.setInt(++i, this.timestamp); pstmt.setBoolean(++i, this.isPending); setEncryptedData(pstmt, this.encryptedGoods, ++i); ++i; setEncryptedData(pstmt, this.refundNote, ++i); ++i; pstmt.setBoolean(++i, this.hasFeedbackNotes); pstmt.setBoolean(++i, this.hasPublicFeedbacks); pstmt.setLong(++i, this.discountNQT); pstmt.setLong(++i, this.refundNQT); pstmt.setInt(++i, Nxt.getBlockchain().getHeight()); pstmt.executeUpdate(); } } public long getId() { return id; } public long getBuyerId() { return buyerId; } public long getGoodsId() { return goodsId; } public long getSellerId() { return sellerId; } public int getQuantity() { return quantity; } public long getPriceNQT() { return priceNQT; } public int getDeliveryDeadlineTimestamp() { return deadline; } public EncryptedData getNote() { return note; } public boolean isPending() { return isPending; } private void setPending(boolean isPending) { this.isPending = isPending; purchaseTable.insert(this); } public int getTimestamp() { return timestamp; } public String getName() { return Goods.getGoods(goodsId).getName(); } public EncryptedData getEncryptedGoods() { return encryptedGoods; } public boolean goodsIsText() { return goodsIsText; } private void setEncryptedGoods(EncryptedData encryptedGoods, boolean goodsIsText) { this.encryptedGoods = encryptedGoods; this.goodsIsText = goodsIsText; purchaseTable.insert(this); } public EncryptedData getRefundNote() { return refundNote; } private void setRefundNote(EncryptedData refundNote) { this.refundNote = refundNote; purchaseTable.insert(this); } public boolean hasFeedbackNotes() { return hasFeedbackNotes; } public List<EncryptedData> getFeedbackNotes() { if (!hasFeedbackNotes) { return null; } feedbackNotes = feedbackTable.get(feedbackDbKeyFactory.newKey(this)); return feedbackNotes; } private void addFeedbackNote(EncryptedData feedbackNote) { if (getFeedbackNotes() == null) { feedbackNotes = new ArrayList<>(); } feedbackNotes.add(feedbackNote); if (!this.hasFeedbackNotes) { this.hasFeedbackNotes = true; purchaseTable.insert(this); } feedbackTable.insert(this, feedbackNotes); } public boolean hasPublicFeedbacks() { return hasPublicFeedbacks; } public List<String> getPublicFeedbacks() { if (!hasPublicFeedbacks) { return null; } publicFeedbacks = publicFeedbackTable.get(publicFeedbackDbKeyFactory.newKey(this)); return publicFeedbacks; } private void addPublicFeedback(String publicFeedback) { if (getPublicFeedbacks() == null) { publicFeedbacks = new ArrayList<>(); } publicFeedbacks.add(publicFeedback); if (!this.hasPublicFeedbacks) { this.hasPublicFeedbacks = true; purchaseTable.insert(this); } publicFeedbackTable.insert(this, publicFeedbacks); } public long getDiscountNQT() { return discountNQT; } private void setDiscountNQT(long discountNQT) { this.discountNQT = discountNQT; purchaseTable.insert(this); } public long getRefundNQT() { return refundNQT; } private void setRefundNQT(long refundNQT) { this.refundNQT = refundNQT; purchaseTable.insert(this); } } private static final class SellerDbClause extends DbClause { private final long sellerId; private SellerDbClause(long sellerId, boolean inStockOnly) { super(" seller_id = ? " + (inStockOnly ? "AND delisted = FALSE AND quantity > 0" : "")); this.sellerId = sellerId; } @Override public int set(PreparedStatement pstmt, int index) throws SQLException { pstmt.setLong(index++, sellerId); return index; } } static void listGoods(Transaction transaction, Attachment.DigitalGoodsListing attachment) { Goods goods = new Goods(transaction, attachment); Tag.add(goods); Goods.goodsTable.insert(goods); goodsListeners.notify(goods, Event.GOODS_LISTED); } static void delistGoods(long goodsId) { Goods goods = Goods.goodsTable.get(Goods.goodsDbKeyFactory.newKey(goodsId)); if (! goods.isDelisted()) { goods.setDelisted(true); goodsListeners.notify(goods, Event.GOODS_DELISTED); } else { throw new IllegalStateException("Goods already delisted"); } } static void changePrice(long goodsId, long priceNQT) { Goods goods = Goods.goodsTable.get(Goods.goodsDbKeyFactory.newKey(goodsId)); if (! goods.isDelisted()) { goods.changePrice(priceNQT); goodsListeners.notify(goods, Event.GOODS_PRICE_CHANGE); } else { throw new IllegalStateException("Can't change price of delisted goods"); } } static void changeQuantity(long goodsId, int deltaQuantity) { Goods goods = Goods.goodsTable.get(Goods.goodsDbKeyFactory.newKey(goodsId)); if (! goods.isDelisted()) { goods.changeQuantity(deltaQuantity); goodsListeners.notify(goods, Event.GOODS_QUANTITY_CHANGE); } else { throw new IllegalStateException("Can't change quantity of delisted goods"); } } static void purchase(Transaction transaction, Attachment.DigitalGoodsPurchase attachment) { Goods goods = Goods.goodsTable.get(Goods.goodsDbKeyFactory.newKey(attachment.getGoodsId())); if (! goods.isDelisted() && attachment.getQuantity() <= goods.getQuantity() && attachment.getPriceNQT() == goods.getPriceNQT()) { goods.changeQuantity(-attachment.getQuantity()); Purchase purchase = new Purchase(transaction, attachment, goods.getSellerId()); Purchase.purchaseTable.insert(purchase); purchaseListeners.notify(purchase, Event.PURCHASE); } else { Account buyer = Account.getAccount(transaction.getSenderId()); buyer.addToUnconfirmedBalanceNQT(LedgerEvent.DIGITAL_GOODS_DELISTED, transaction.getId(), Math.multiplyExact((long) attachment.getQuantity(), attachment.getPriceNQT())); // restoring the unconfirmed balance if purchase not successful, however buyer still lost the transaction fees } } static void deliver(Transaction transaction, Attachment.DigitalGoodsDelivery attachment) { Purchase purchase = Purchase.getPendingPurchase(attachment.getPurchaseId()); purchase.setPending(false); long totalWithoutDiscount = Math.multiplyExact((long) purchase.getQuantity(), purchase.getPriceNQT()); Account buyer = Account.getAccount(purchase.getBuyerId()); long transactionId = transaction.getId(); buyer.addToBalanceNQT(LedgerEvent.DIGITAL_GOODS_DELIVERY, transactionId, Math.subtractExact(attachment.getDiscountNQT(), totalWithoutDiscount)); buyer.addToUnconfirmedBalanceNQT(LedgerEvent.DIGITAL_GOODS_DELIVERY, transactionId, attachment.getDiscountNQT()); Account seller = Account.getAccount(transaction.getSenderId()); seller.addToBalanceAndUnconfirmedBalanceNQT(LedgerEvent.DIGITAL_GOODS_DELIVERY, transactionId, Math.subtractExact(totalWithoutDiscount, attachment.getDiscountNQT())); purchase.setEncryptedGoods(attachment.getGoods(), attachment.goodsIsText()); purchase.setDiscountNQT(attachment.getDiscountNQT()); purchaseListeners.notify(purchase, Event.DELIVERY); } static void refund(LedgerEvent event, long eventId, long sellerId, long purchaseId, long refundNQT, Appendix.EncryptedMessage encryptedMessage) { Purchase purchase = Purchase.purchaseTable.get(Purchase.purchaseDbKeyFactory.newKey(purchaseId)); Account seller = Account.getAccount(sellerId); seller.addToBalanceNQT(event, eventId, -refundNQT); Account buyer = Account.getAccount(purchase.getBuyerId()); buyer.addToBalanceAndUnconfirmedBalanceNQT(event, eventId, refundNQT); if (encryptedMessage != null) { purchase.setRefundNote(encryptedMessage.getEncryptedData()); } purchase.setRefundNQT(refundNQT); purchaseListeners.notify(purchase, Event.REFUND); } static void feedback(long purchaseId, Appendix.EncryptedMessage encryptedMessage, Appendix.Message message) { Purchase purchase = Purchase.purchaseTable.get(Purchase.purchaseDbKeyFactory.newKey(purchaseId)); if (encryptedMessage != null) { purchase.addFeedbackNote(encryptedMessage.getEncryptedData()); } if (message != null) { purchase.addPublicFeedback(Convert.toString(message.getMessage())); } purchaseListeners.notify(purchase, Event.FEEDBACK); } private static EncryptedData loadEncryptedData(ResultSet rs, String dataColumn, String nonceColumn) throws SQLException { byte[] data = rs.getBytes(dataColumn); if (data == null) { return null; } return new EncryptedData(data, rs.getBytes(nonceColumn)); } private static void setEncryptedData(PreparedStatement pstmt, EncryptedData encryptedData, int i) throws SQLException { if (encryptedData == null) { pstmt.setNull(i, Types.VARBINARY); pstmt.setNull(i + 1, Types.VARBINARY); } else { pstmt.setBytes(i, encryptedData.getData()); pstmt.setBytes(i + 1, encryptedData.getNonce()); } } }