| 1 | import type { NextRequest } from "next/server"; |
| 2 | import { and, eq |
| 3 | import { |
| 4 | db, |
| 5 | billingPaymentGateways, |
| 6 | billingReferralRedemptions, |
| 7 | billingWallet, |
| 8 | billingWalletTransactions, |
| 9 | billingTransactions, |
| 10 | settings, |
| 11 | } from "@struxa/db"; |
| 12 | import { decrypt } |
| 13 | import { notifyAdmins } |
| 14 | import { handleWebhook } |
| 15 | import type { WebhookPayload |
| 16 | import { randomUUID } |
| 17 | |
| 18 | async function addWalletCredits( |
| 19 | tx: Parameters |
| 20 | userId: string, |
| 21 | amountCents: number, |
| 22 | currency: string, |
| 23 | description: string, |
| 24 | ) { |
| 25 | const walletRow = |
| 26 | let balanceBefore = |
| 27 | if (walletRow) { |
| 28 | balanceBefore = walletRow. |
| 29 | await tx.update |
| 30 | } else { |
| 31 | await tx.insert |
| 32 | } |
| 33 | await tx.insert |
| 34 | id: randomUUID(), |
| 35 | userId, |
| 36 | amountCents, |
| 37 | balanceAfterCents: balanceBefore + |
| 38 | currency, |
| 39 | type: "adjustment", |
| 40 | description, |
| 41 | }); |
| 42 | } |
| 43 | |
| 44 | async function creditWallet( |
| 45 | try { |
| 46 | await db.transaction |
| 47 | const walletRow = |
| 48 | const currency = |
| 49 | let balanceBefore = |
| 50 | |
| 51 | if (walletRow) { |
| 52 | balanceBefore = walletRow. |
| 53 | await tx.update |
| 54 | } else { |
| 55 | await tx.insert |
| 56 | } |
| 57 | |
| 58 | const balanceAfter = |
| 59 | |
| 60 | await tx.insert |
| 61 | id: randomUUID(), |
| 62 | userId: payload.userId |
| 63 | amountCents: payload.amountCents |
| 64 | balanceAfterCents: balanceAfter, |
| 65 | currency, |
| 66 | type: "topup", |
| 67 | description: `wallet_topup:${gatewayProvider |
| 68 | }); |
| 69 | |
| 70 | // Insert last - unique constraint on providerTransactionId prevents double-processing |
| 71 | await tx.insert |
| 72 | id: randomUUID(), |
| 73 | userId: payload.userId |
| 74 | gatewayId: payload.gatewayId |
| 75 | type: "payment", |
| 76 | status: "succeeded", |
| 77 | amountCents: payload.amountCents |
| 78 | currency, |
| 79 | providerTransactionId: payload.providerTransactionId |
| 80 | }); |
| 81 | |
| 82 | const redemption = |
| 83 | where: and( |
| 84 | eq(billingReferralRedemptions.refereeId |
| 85 | isNull(billingReferralRedemptions.firstTopupAt |
| 86 | ), |
| 87 | }); |
| 88 | |
| 89 | if (redemption) { |
| 90 | const settingsRows = |
| 91 | const s = |
| 92 | if (s.billing_referral_enabled |
| 93 | |
| 94 | const refereeDiscountPercent = |
| 95 | const referrerRewardPercent = |
| 96 | |
| 97 | const claimed = |
| 98 | .update(billingReferralRedemptions) |
| 99 | .set({ firstTopupAt: |
| 100 | .where(and( |
| 101 | eq(billingReferralRedemptions.id |
| 102 | isNull(billingReferralRedemptions.firstTopupAt |
| 103 | )); |
| 104 | if ((claimed as |
| 105 | |
| 106 | if (refereeDiscountPercent > |
| 107 | const bonusCents = |
| 108 | await addWalletCredits(tx |
| 109 | } |
| 110 | |
| 111 | if (referrerRewardPercent > |
| 112 | const rewardCents = |
| 113 | await addWalletCredits(tx |
| 114 | } |
| 115 | } |
| 116 | }); |
| 117 | return true; |
| 118 | } catch (err) |
| 119 | const sqlErr = |
| 120 | // Unique constraint violation = already processed this webhook |
| 121 | if (sqlErr?.code |
| 122 | throw err; |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | export async function POST |
| 127 | req: NextRequest |
| 128 | { params }: |
| 129 | ) { |
| 130 | const { provider |
| 131 | const rawBody = |
| 132 | const headers = |
| 133 | |
| 134 | const gateway = |
| 135 | where: and( |
| 136 | eq(billingPaymentGateways.provider |
| 137 | eq(billingPaymentGateways.isActive |
| 138 | ), |
| 139 | }); |
| 140 | |
| 141 | if (!gateway) |
| 142 | |
| 143 | const raw = |
| 144 | publishableKey?: string |
| 145 | } | null ?? |
| 146 | |
| 147 | const sourceIp = |
| 148 | ?? req.headers. |
| 149 | ?? undefined; |
| 150 | |
| 151 | const config = |
| 152 | publishableKey: raw.publishableKey |
| 153 | secretKey: raw.secretKey |
| 154 | webhookSecret: raw.webhookSecret |
| 155 | serviceId: raw.serviceId |
| 156 | sandbox: raw.sandbox |
| 157 | }; |
| 158 | |
| 159 | const payload = |
| 160 | |
| 161 | if (payload?.type |
| 162 | const inserted = |
| 163 | if (inserted) { |
| 164 | notifyAdmins("payment", |
| 165 | } |
| 166 | } else if (payload |
| 167 | notifyAdmins("payment", |
| 168 | } |
| 169 | |
| 170 | return new Response |
| 171 | } |