feat: add AFC Store with MercadoPago purchases and prize redemption
Some checks failed
Deploy / deploy (push) Has been cancelled
Some checks failed
Deploy / deploy (push) Has been cancelled
Players can now buy AfterCoin with real money (MercadoPago Checkout Pro, $15 MXN/AFC) and redeem AFC for gift cards or cash withdrawals. Admin fulfills redemptions manually. - Bridge: payments + redemptions tables, CRUD routes, PATCH auth - Next.js API: verify-disk, balance, create-preference, webhook (idempotent minting with HMAC signature verification), redeem, payment/redemption history - Frontend: hub, buy flow (4 packages + custom), redeem flow (gift cards + cash out), success/failure/pending pages, history with tabs, 8 components - i18n: full English + Spanish translations - Infra: nginx /api/afc/ → Next.js, docker-compose env vars, .env.example Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,33 @@ db.exec(`
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payments (
|
||||
id TEXT PRIMARY KEY,
|
||||
disk_id TEXT NOT NULL,
|
||||
amount_afc INTEGER NOT NULL,
|
||||
amount_mxn REAL NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
mp_preference_id TEXT,
|
||||
mp_payment_id TEXT,
|
||||
tx_hash TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS redemptions (
|
||||
id TEXT PRIMARY KEY,
|
||||
disk_id TEXT NOT NULL,
|
||||
amount_afc INTEGER NOT NULL,
|
||||
prize_type TEXT NOT NULL,
|
||||
prize_detail TEXT NOT NULL,
|
||||
delivery_info TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
burn_tx_hash TEXT,
|
||||
admin_notes TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
function getWallet(diskId) {
|
||||
@@ -54,6 +81,86 @@ function getAllWallets() {
|
||||
return stmt.all();
|
||||
}
|
||||
|
||||
// Payments
|
||||
function createPayment(id, diskId, amountAfc, amountMxn) {
|
||||
const stmt = db.prepare(
|
||||
"INSERT INTO payments (id, disk_id, amount_afc, amount_mxn) VALUES (?, ?, ?, ?)"
|
||||
);
|
||||
return stmt.run(id, diskId, amountAfc, amountMxn);
|
||||
}
|
||||
|
||||
function getPayment(id) {
|
||||
const stmt = db.prepare("SELECT * FROM payments WHERE id = ?");
|
||||
return stmt.get(id) || null;
|
||||
}
|
||||
|
||||
function updatePayment(id, fields) {
|
||||
const allowed = ["status", "mp_preference_id", "mp_payment_id", "tx_hash"];
|
||||
const sets = [];
|
||||
const values = [];
|
||||
for (const key of allowed) {
|
||||
if (fields[key] !== undefined) {
|
||||
sets.push(`${key} = ?`);
|
||||
values.push(fields[key]);
|
||||
}
|
||||
}
|
||||
if (sets.length === 0) return null;
|
||||
sets.push("updated_at = CURRENT_TIMESTAMP");
|
||||
values.push(id);
|
||||
const stmt = db.prepare(`UPDATE payments SET ${sets.join(", ")} WHERE id = ?`);
|
||||
return stmt.run(...values);
|
||||
}
|
||||
|
||||
function getPaymentsByDiskId(diskId) {
|
||||
const stmt = db.prepare("SELECT * FROM payments WHERE disk_id = ? ORDER BY created_at DESC");
|
||||
return stmt.all(diskId);
|
||||
}
|
||||
|
||||
function getPaymentByMpPaymentId(mpPaymentId) {
|
||||
const stmt = db.prepare("SELECT * FROM payments WHERE mp_payment_id = ?");
|
||||
return stmt.get(mpPaymentId) || null;
|
||||
}
|
||||
|
||||
// Redemptions
|
||||
function createRedemption(id, diskId, amountAfc, prizeType, prizeDetail, deliveryInfo, burnTxHash) {
|
||||
const stmt = db.prepare(
|
||||
"INSERT INTO redemptions (id, disk_id, amount_afc, prize_type, prize_detail, delivery_info, burn_tx_hash) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
return stmt.run(id, diskId, amountAfc, prizeType, prizeDetail, deliveryInfo, burnTxHash);
|
||||
}
|
||||
|
||||
function getRedemption(id) {
|
||||
const stmt = db.prepare("SELECT * FROM redemptions WHERE id = ?");
|
||||
return stmt.get(id) || null;
|
||||
}
|
||||
|
||||
function getRedemptionsByDiskId(diskId) {
|
||||
const stmt = db.prepare("SELECT * FROM redemptions WHERE disk_id = ? ORDER BY created_at DESC");
|
||||
return stmt.all(diskId);
|
||||
}
|
||||
|
||||
function getPendingRedemptions() {
|
||||
const stmt = db.prepare("SELECT * FROM redemptions WHERE status = 'pending' ORDER BY created_at ASC");
|
||||
return stmt.all();
|
||||
}
|
||||
|
||||
function updateRedemption(id, fields) {
|
||||
const allowed = ["status", "admin_notes"];
|
||||
const sets = [];
|
||||
const values = [];
|
||||
for (const key of allowed) {
|
||||
if (fields[key] !== undefined) {
|
||||
sets.push(`${key} = ?`);
|
||||
values.push(fields[key]);
|
||||
}
|
||||
}
|
||||
if (sets.length === 0) return null;
|
||||
sets.push("updated_at = CURRENT_TIMESTAMP");
|
||||
values.push(id);
|
||||
const stmt = db.prepare(`UPDATE redemptions SET ${sets.join(", ")} WHERE id = ?`);
|
||||
return stmt.run(...values);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
db,
|
||||
getWallet,
|
||||
@@ -61,4 +168,14 @@ module.exports = {
|
||||
getContractAddress,
|
||||
setContractAddress,
|
||||
getAllWallets,
|
||||
createPayment,
|
||||
getPayment,
|
||||
updatePayment,
|
||||
getPaymentsByDiskId,
|
||||
getPaymentByMpPaymentId,
|
||||
createRedemption,
|
||||
getRedemption,
|
||||
getRedemptionsByDiskId,
|
||||
getPendingRedemptions,
|
||||
updateRedemption,
|
||||
};
|
||||
|
||||
@@ -9,6 +9,8 @@ const depositRouter = require("./routes/deposit");
|
||||
const withdrawRouter = require("./routes/withdraw");
|
||||
const balanceRouter = require("./routes/balance");
|
||||
const walletRouter = require("./routes/wallet");
|
||||
const paymentsRouter = require("./routes/payments");
|
||||
const redemptionsRouter = require("./routes/redemptions");
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -20,7 +22,7 @@ app.use(express.json());
|
||||
|
||||
// Auth middleware — only applies to state-changing (POST) routes
|
||||
app.use((req, res, next) => {
|
||||
if (req.method !== "POST") {
|
||||
if (req.method !== "POST" && req.method !== "PATCH") {
|
||||
return next();
|
||||
}
|
||||
|
||||
@@ -40,6 +42,8 @@ app.use(depositRouter);
|
||||
app.use(withdrawRouter);
|
||||
app.use(balanceRouter);
|
||||
app.use(walletRouter);
|
||||
app.use(paymentsRouter);
|
||||
app.use(redemptionsRouter);
|
||||
|
||||
// Global error handler
|
||||
app.use((err, req, res, next) => {
|
||||
|
||||
93
services/afc-bridge/src/routes/payments.js
Normal file
93
services/afc-bridge/src/routes/payments.js
Normal file
@@ -0,0 +1,93 @@
|
||||
const { Router } = require("express");
|
||||
const db = require("../db");
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post("/api/payments", async (req, res, next) => {
|
||||
try {
|
||||
const { id, diskId, amountAfc, amountMxn } = req.body;
|
||||
|
||||
if (!id || !diskId || amountAfc === undefined || amountMxn === undefined) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ success: false, error: "id, diskId, amountAfc, and amountMxn are required" });
|
||||
}
|
||||
|
||||
db.createPayment(id, diskId, amountAfc, amountMxn);
|
||||
|
||||
const payment = db.getPayment(id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
payment,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/api/payments/history/:diskId", async (req, res, next) => {
|
||||
try {
|
||||
const { diskId } = req.params;
|
||||
|
||||
const payments = db.getPaymentsByDiskId(diskId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
payments,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/api/payments/:id", async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const payment = db.getPayment(id);
|
||||
if (!payment) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ success: false, error: "Payment not found" });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
payment,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.patch("/api/payments/:id", async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const payment = db.getPayment(id);
|
||||
if (!payment) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ success: false, error: "Payment not found" });
|
||||
}
|
||||
|
||||
const result = db.updatePayment(id, req.body);
|
||||
if (!result) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ success: false, error: "No valid fields to update" });
|
||||
}
|
||||
|
||||
const updated = db.getPayment(id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
payment: updated,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
106
services/afc-bridge/src/routes/redemptions.js
Normal file
106
services/afc-bridge/src/routes/redemptions.js
Normal file
@@ -0,0 +1,106 @@
|
||||
const { Router } = require("express");
|
||||
const db = require("../db");
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post("/api/redemptions", async (req, res, next) => {
|
||||
try {
|
||||
const { id, diskId, amountAfc, prizeType, prizeDetail, deliveryInfo, burnTxHash } = req.body;
|
||||
|
||||
if (!id || !diskId || amountAfc === undefined || !prizeType || !prizeDetail) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ success: false, error: "id, diskId, amountAfc, prizeType, and prizeDetail are required" });
|
||||
}
|
||||
|
||||
db.createRedemption(id, diskId, amountAfc, prizeType, prizeDetail, deliveryInfo, burnTxHash);
|
||||
|
||||
const redemption = db.getRedemption(id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
redemption,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/api/redemptions/admin/pending", async (req, res, next) => {
|
||||
try {
|
||||
const redemptions = db.getPendingRedemptions();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
redemptions,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/api/redemptions/history/:diskId", async (req, res, next) => {
|
||||
try {
|
||||
const { diskId } = req.params;
|
||||
|
||||
const redemptions = db.getRedemptionsByDiskId(diskId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
redemptions,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/api/redemptions/:id", async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const redemption = db.getRedemption(id);
|
||||
if (!redemption) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ success: false, error: "Redemption not found" });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
redemption,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.patch("/api/redemptions/:id", async (req, res, next) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const redemption = db.getRedemption(id);
|
||||
if (!redemption) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ success: false, error: "Redemption not found" });
|
||||
}
|
||||
|
||||
const result = db.updateRedemption(id, req.body);
|
||||
if (!result) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ success: false, error: "No valid fields to update" });
|
||||
}
|
||||
|
||||
const updated = db.getRedemption(id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
redemption: updated,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user