File size: 6,947 Bytes
59b509f
 
77cc094
59b509f
 
 
 
77cc094
59b509f
 
 
 
 
 
 
77cc094
 
59b509f
 
072db80
59b509f
 
fd68981
 
77cc094
59b509f
 
77cc094
 
7ccf1cc
59b509f
77cc094
59b509f
 
 
 
 
 
 
 
b977bfa
59b509f
 
 
 
 
 
 
 
 
77cc094
 
 
59b509f
 
 
 
 
 
 
 
 
 
 
 
 
77cc094
 
 
 
 
59b509f
 
 
 
 
 
 
 
 
 
 
77cc094
 
 
59b509f
 
 
 
 
 
 
 
67d31ad
59b509f
 
 
 
 
 
 
 
 
 
 
77cc094
 
59b509f
 
 
 
 
 
 
 
77cc094
59b509f
77cc094
 
 
 
 
59b509f
 
 
 
 
7fe989f
6d4d78a
77cc094
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59b509f
 
 
 
 
 
77cc094
59b509f
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
// paystack-webhook.js
// Node >= 14, install dependencies:
// npm install express firebase-admin axios

const express = require('express');
const crypto = require('crypto');
const admin = require('firebase-admin');
const axios = require('axios');

const app = express();

// -------------------------------------------------
// Environment variables (set these in your env/secrets)
// -------------------------------------------------
// - FIREBASE_CREDENTIALS: stringified JSON service account
// - PAYSTACK_DEMO_SECRET
// - PAYSTACK_LIVE_SECRET
// - PORT (optional)
// -------------------------------------------------

const {
  FIREBASE_CREDENTIALS,
  PAYSTACK_DEMO_SECRET,
  PAYSTACK_LIVE_SECRET,
  PORT = 3000,
} = process.env;

// choose which secret to use (dev/demo by default)
const PAYSTACK_SECRET = PAYSTACK_DEMO_SECRET || PAYSTACK_LIVE_SECRET;

if (!PAYSTACK_SECRET) {
  console.warn('WARNING: PAYSTACK_SECRET is not set. Outgoing Paystack calls and webhook verification will fail.');
}

// Initialize Firebase Admin using credentials read from env
if (FIREBASE_CREDENTIALS) {
  try {
    const serviceAccount = JSON.parse(FIREBASE_CREDENTIALS);
    admin.initializeApp({
      credential: admin.credential.cert(serviceAccount),
    });
    console.log('Firebase admin initialized from FIREBASE_CREDENTIALS env var.');
  } catch (err) {
    console.error('Failed to parse FIREBASE_CREDENTIALS JSON:', err);
    process.exit(1);
  }
} else {
  console.warn('FIREBASE_CREDENTIALS not provided. Firebase admin not initialized.');
}

// -------------------------------------------------
// Webhook raw parser (required to verify Paystack signature)
// -------------------------------------------------
app.use(
  express.raw({
    type: 'application/json',
    limit: '1mb',
  })
);

// Utility: verify x-paystack-signature
function verifyPaystackSignature(rawBodyBuffer, signatureHeader) {
  if (!PAYSTACK_SECRET) return false;
  const hmac = crypto.createHmac('sha512', PAYSTACK_SECRET);
  hmac.update(rawBodyBuffer);
  const expected = hmac.digest('hex');
  try {
    return crypto.timingSafeEqual(Buffer.from(expected, 'utf8'), Buffer.from(signatureHeader || '', 'utf8'));
  } catch (e) {
    return false;
  }
}

// Helper: safe JSON parse
function safeParseJSON(raw) {
  try {
    return JSON.parse(raw);
  } catch (err) {
    return null;
  }
}

// ------------------------
// Existing webhook endpoint
// ------------------------
app.post('/webhook/paystack', (req, res) => {
  const raw = req.body; // Buffer because we used express.raw
  const signature = req.get('x-paystack-signature') || req.get('X-Paystack-Signature');

  // Verify signature
  if (!signature || !verifyPaystackSignature(raw, signature)) {
    console.warn('Paystack webhook signature verification failed. Signature header:', signature);
    return res.status(400).json({ ok: false, message: 'Invalid signature' });
  }

  // Parse payload
  const bodyStr = raw.toString('utf8');
  const payload = safeParseJSON(bodyStr);

  if (!payload) {
    console.warn('Could not parse webhook JSON payload.');
    return res.status(400).json({ ok: false, message: 'Invalid JSON' });
  }

  const event = (payload.event || payload.type || '').toString();
  const isRefund = /refund/i.test(event);
  const isChargeSuccess = /charge\.success/i.test(event);
  const isInvoiceOrSubscription = /invoice\.(payment_succeeded|paid)|subscription\./i.test(event);
  const isPayment = isChargeSuccess || isInvoiceOrSubscription;

  if (isRefund || isPayment) {
    console.log('--- PAYSTACK WEBHOOK (INTERESTING EVENT) ---');
    console.log('event:', event);
    console.log('payload:', JSON.stringify(payload, null, 2));

    // Example: persist webhook to Firestore (uncomment if you want)
    // const db = admin.firestore();
    // db.collection('paystack-webhooks').add({
    //   event,
    //   payload,
    //   receivedAt: admin.firestore.FieldValue.serverTimestamp(),
    // });
  } else {
    console.log(`Received Paystack webhook for event "${event}" — ignored (not refund/payment).`);
  }

  return res.status(200).json({ ok: true });
});

// ------------------------
// New: Create Payment Page (link) for a plan
// ------------------------
// This endpoint expects JSON body, so we attach express.json() for this route.
// Required fields in body: planId, userId, name (friendly page name) optionally: amount, redirect_url, collect_phone, fixed_amount
app.post('/create-payment-link', express.json(), async (req, res) => {
  const { planId, userId, name, amount, redirect_url, collect_phone = false, fixed_amount = false } = req.body || {};

  if (!planId) return res.status(400).json({ ok: false, message: 'planId is required' });
  if (!userId) return res.status(400).json({ ok: false, message: 'userId is required' });
  if (!name) return res.status(400).json({ ok: false, message: 'name is required (page title)' });

  // Build the body per Paystack's Payment Pages API.
  // We'll set type to "subscription" (so it links to a plan) and pass metadata.userId
  // Also add a custom_fields array so the dashboard shows the user id if you open the transaction.
  // See: Paystack Payment Pages and Metadata docs. 1
  const payload = {
    name,
    type: 'subscription',
    plan: planId,
    metadata: {
      userId,
    },
    custom_fields: [
      {
        display_name: 'User ID',
        variable_name: 'user_id',
        value: userId,
      },
    ],
    // optional properties
    collect_phone,
    fixed_amount,
  };

  if (amount) payload.amount = amount; // amount is optional (in kobo/cents)
  if (redirect_url) payload.redirect_url = redirect_url;

  try {
    const response = await axios.post('https://api.paystack.co/page', payload, {
      headers: {
        Authorization: `Bearer ${PAYSTACK_SECRET}`,
        'Content-Type': 'application/json',
      },
      timeout: 10_000,
    });

    // Paystack returns a response with data object. Return minimal info to caller.
    const pageData = response.data && response.data.data ? response.data.data : response.data;

    // The created page usually has a slug; you can build public url with https://paystack.com/pay/[slug]
    // Return the page data so caller can redirect or store details.
    return res.status(201).json({ ok: true, page: pageData });
  } catch (err) {
    console.error('Error creating Paystack payment page:', err?.response?.data || err.message || err);
    const errorDetail = err?.response?.data || { message: err.message };
    return res.status(500).json({ ok: false, error: errorDetail });
  }
});

// Simple health check
app.get('/health', (_req, res) => res.json({ ok: true }));

app.listen(PORT, () => {
  console.log(`Paystack webhook server listening on port ${PORT}`);
  console.log('POST /webhook/paystack to receive Paystack callbacks');
  console.log('POST /create-payment-link to create a subscription payment page (expects JSON body).');
});