Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Paid subscriptions #118

Merged
merged 12 commits into from
Jun 2, 2023
6 changes: 1 addition & 5 deletions src/hooks.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,7 @@ import { collections } from '$lib/server/database';
import { ObjectId } from 'mongodb';
import { addYears } from 'date-fns';

import '$lib/server/currency-lock';
import '$lib/server/order-lock';
import '$lib/server/order-notifications';
import '$lib/server/nostr-notifications';
import '$lib/server/handle-messages';
import '$lib/server/locks';

export const handleError = (({ error, event }) => {
console.error('handleError', error);
Expand Down
17 changes: 17 additions & 0 deletions src/lib/components/ProductItem.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<script lang="ts">
import type { Product } from '$lib/types/Product';
import type { Picture } from '$lib/types/Picture';
import PictureComponent from './Picture.svelte';

export let picture: Picture | undefined;
export let product: Pick<Product, '_id' | 'name'>;
</script>

<div class="flex flex-col text-center">
<a href="/product/{product._id}" class="flex flex-col items-center">
<PictureComponent {picture} class="block h-36" />
<span class="mt-2 line-clamp-3 text-ellipsis max-w-[192px] break-words hyphens-auto"
>{product.name}</span
>
</a>
</div>
52 changes: 36 additions & 16 deletions src/lib/server/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import type { DigitalFile } from '$lib/types/DigitalFile';
import type { Order } from '$lib/types/Order';
import type { NostRNotification } from '$lib/types/NostRNotifications';
import type { NostRReceivedMessage } from '$lib/types/NostRReceivedMessage';
import type { Subscription } from '$lib/types/Subscription';
import type { BootikSubscription } from '$lib/types/BootikSubscription';
import type { PaidSubscription } from '$lib/types/PaidSubscription';

const client = new MongoClient(MONGODB_URL, {
// directConnection: true
Expand All @@ -22,7 +23,8 @@ const db = client.db(MONGODB_DB);
// const users = db.collection<User>('users');
const pictures = db.collection<Picture>('pictures');
const products = db.collection<Product>('products');
const subscriptions = db.collection<Subscription>('subscriptions');
const bootikSubscriptions = db.collection<BootikSubscription>('subscriptions');
const paidSubscriptions = db.collection<PaidSubscription>('subscriptions.paid');
const carts = db.collection<Cart>('carts');
const runtimeConfig = db.collection<RuntimeConfigItem>('runtimeConfig');
const locks = db.collection<Lock>('locks');
Expand All @@ -47,27 +49,45 @@ export const collections = {
orders,
nostrNotifications,
nostrReceivedMessages,
subscriptions
bootikSubscriptions,
paidSubscriptions
};

export function transaction(dbTransactions: WithSessionCallback): Promise<void> {
return client.withSession((session) => session.withTransaction(dbTransactions));
}

client.on('open', () => {
pictures.createIndex({ productId: 1 });
locks.createIndex({ updatedAt: 1 }, { expireAfterSeconds: 60 });
carts.createIndex({ sessionId: 1 }, { unique: true });
orders.createIndex({ sessionId: 1 });
orders.createIndex(
{ 'notifications.paymentStatus.npub': 1, createdAt: -1 },
{ partialFilterExpression: { 'notifications.paymentStatus.npub': { $exists: true } } }
);
orders.createIndex({ number: 1 }, { unique: true });
digitalFiles.createIndex({ productId: 1 });
nostrReceivedMessages.createIndex({ createdAt: -1 });
nostrNotifications.createIndex({ dest: 1 });
subscriptions.createIndex({ npub: 1 }, { sparse: true });
pictures.createIndex({ productId: 1 }).catch(console.error);
locks.createIndex({ updatedAt: 1 }, { expireAfterSeconds: 60 }).catch(console.error);
carts.createIndex({ sessionId: 1 }, { unique: true }).catch(console.error);
orders.createIndex({ sessionId: 1 }).catch(console.error);
orders
.createIndex(
{ 'notifications.paymentStatus.npub': 1, createdAt: -1 },
{ partialFilterExpression: { 'notifications.paymentStatus.npub': { $exists: true } } }
)
.catch(console.error);
orders.createIndex({ number: 1 }, { unique: true }).catch(console.error);
digitalFiles.createIndex({ productId: 1 }).catch(console.error);
nostrReceivedMessages.createIndex({ createdAt: -1 }).catch(console.error);
nostrNotifications.createIndex({ dest: 1 }).catch(console.error);
bootikSubscriptions.createIndex({ npub: 1 }, { sparse: true }).catch(console.error);
paidSubscriptions
.createIndex(
{ npub: 1, productId: 1 },
{ unique: true, partialFilterExpression: { npub: { $exists: true } } }
)
.catch(console.error);
paidSubscriptions.createIndex({ number: 1 }, { unique: true }).catch(console.error);
// See subscription-lock.ts, for searching for subscriptions to remind
// todo: find which index is better
paidSubscriptions
.createIndex({ cancelledAt: 1, paidUntil: 1, 'notifications.type': 1 })
.catch(console.error);
paidSubscriptions
.createIndex({ cancelledAt: 1, 'notifications.type': 1, paidUntil: 1 })
.catch(console.error);
});

export async function withTransaction(cb: WithSessionCallback) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { collections } from './database';
import { collections } from '../database';
import { differenceInMinutes } from 'date-fns';
import { setTimeout } from 'node:timers/promises';
import { processClosed } from './process';
import { Lock } from './lock';
import { processClosed } from '../process';
import { Lock } from '../lock';

const lock = new Lock('currency');

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { ObjectId, type ChangeStreamDocument } from 'mongodb';
import { collections } from './database';
import { Lock } from './lock';
import { collections } from '../database';
import { Lock } from '../lock';
import type { NostRReceivedMessage } from '$lib/types/NostRReceivedMessage';
import { Kind } from 'nostr-tools';
import { ORIGIN } from '$env/static/private';
import { runtimeConfig } from './runtime-config';
import { runtimeConfig } from '../runtime-config';
import { toSatoshis } from '$lib/utils/toSatoshis';
import { addSeconds } from 'date-fns';

Expand Down Expand Up @@ -36,7 +36,9 @@ async function handleChanges(change: ChangeStreamDocument<NostRReceivedMessage>)

const send = (message: string) => sendMessage(senderNpub, message, minCreatedAt);

switch (content.trim().replaceAll(/\s+/g, ' ')) {
const toMatch = content.trim().replaceAll(/\s+/g, ' ');

switch (toMatch) {
case 'help':
await send(
`Commands:
Expand All @@ -45,7 +47,9 @@ async function handleChanges(change: ChangeStreamDocument<NostRReceivedMessage>)
- catalog: Show the catalog
- detailed catalog: Show the catalog, with product descriptions
- subscribe: Subscribe to catalog updates
- unsubscribe: Unsubscribe from catalog updates`
- unsubscribe: Unsubscribe from catalog updates
- subscriptions: Show the list of paid subscriptions associated to your npub
- cancel [subscription number]: Cancel a paid subscription to not be reminded anymore`
);
break;
case 'orders': {
Expand Down Expand Up @@ -100,7 +104,7 @@ async function handleChanges(change: ChangeStreamDocument<NostRReceivedMessage>)
if (!runtimeConfig.discovery) {
await send('Discovery is not enabled for the bootik, you cannot subscribe');
} else {
await collections.subscriptions.updateOne(
await collections.bootikSubscriptions.updateOne(
{ npub: senderNpub },
{
$set: {
Expand All @@ -118,7 +122,7 @@ async function handleChanges(change: ChangeStreamDocument<NostRReceivedMessage>)
}
break;
case 'unsubscribe': {
const result = await collections.subscriptions.deleteOne({ npub: senderNpub });
const result = await collections.bootikSubscriptions.deleteOne({ npub: senderNpub });

if (result.deletedCount) {
await send('You were unsubscribed from the catalog');
Expand All @@ -127,7 +131,63 @@ async function handleChanges(change: ChangeStreamDocument<NostRReceivedMessage>)
}
break;
}
case 'subscriptions': {
const subscriptions = await collections.paidSubscriptions
.find({ npub: senderNpub, paidUntil: { $gt: new Date() } })
.sort({ number: 1 })
.toArray();

if (!subscriptions.length) {
await send('No active subscriptions found for your npub');
} else {
await send(
subscriptions
.map(
(subscription) =>
`- #${subscription.number}: ${ORIGIN}/subscription/${
subscription._id
}, paid until ${subscription.paidUntil.toISOString()}${
subscription.cancelledAt ? ' [cancelled]' : ''
}`
)
.join('\n')
);
}

break;
}
default:
if (toMatch.startsWith('cancel ')) {
const number = parseInt(toMatch.slice('cancel '.length), 10);

if (isNaN(number)) {
await send('Invalid subscription number: ' + toMatch.slice('cancel '.length));
break;
}

const subscription = await collections.paidSubscriptions.findOne({
npub: senderNpub,
number
});

if (!subscription) {
await send('No subscription found with number ' + number + ' for your npub');
break;
}

if (subscription.cancelledAt) {
await send('Subscription #' + number + ' was already cancelled');
break;
}

await collections.paidSubscriptions.updateOne(
{ _id: subscription._id },
{ $set: { cancelledAt: new Date() } }
);

await send('Subscription #' + number + ' was cancelled, you will not be reminded anymore');
break;
}
await send(
`Hello ${
!isPrivateMessage ? 'world' : isCustomer ? 'customer' : 'you'
Expand Down
6 changes: 6 additions & 0 deletions src/lib/server/locks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import './currency-lock';
import './order-lock';
import './order-notifications';
import './nostr-notifications';
import './handle-messages';
import './subscription-lock';
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import type { ChangeStreamDocument } from 'mongodb';
import { Lock } from './lock';
import { processClosed } from './process';
import { Lock } from '../lock';
import { processClosed } from '../process';
import type { NostRNotification } from '$lib/types/NostRNotifications';
import { hexToNpub, nostrPrivateKeyHex, nostrPublicKeyHex, nostrRelays, nostrToHex } from './nostr';
import { fromUnixTime, getUnixTime } from 'date-fns';
import { collections } from './database';
import {
hexToNpub,
nostrPrivateKeyHex,
nostrPublicKeyHex,
nostrRelays,
nostrToHex
} from '../nostr';
import { fromUnixTime, getUnixTime, max } from 'date-fns';
import { collections } from '../database';
import { RelayPool } from 'nostr-relaypool';
import {
getEventHash,
Expand Down Expand Up @@ -122,10 +128,10 @@ async function handleChanges(change: ChangeStreamDocument<NostRNotification>): P
id: '',
content: await nip04.encrypt(nostrPrivateKeyHex, receiverPublicKeyHex, content),
created_at: getUnixTime(
change.fullDocument.minCreatedAt &&
change.fullDocument.minCreatedAt > change.fullDocument.createdAt
? change.fullDocument.minCreatedAt
: change.fullDocument.createdAt
max([
change.fullDocument.minCreatedAt ?? change.fullDocument.createdAt,
change.fullDocument.createdAt
])
),
pubkey: nostrPublicKeyHex,
tags: [['p', receiverPublicKeyHex]],
Expand Down
70 changes: 41 additions & 29 deletions src/lib/server/order-lock.ts → src/lib/server/locks/order-lock.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { collections } from './database';
import { collections, withTransaction } from '../database';
import { setTimeout } from 'node:timers/promises';
import { processClosed } from './process';
import { processClosed } from '../process';
import type { Order } from '$lib/types/Order';
import { listTransactions, orderAddressLabel } from './bitcoin';
import { listTransactions, orderAddressLabel } from '../bitcoin';
import { sum } from '$lib/utils/sum';
import { Lock } from './lock';
import { Lock } from '../lock';
import { inspect } from 'node:util';
import { lndLookupInvoice } from './lightning';
import { lndLookupInvoice } from '../lightning';
import { toSatoshis } from '$lib/utils/toSatoshis';
import { onOrderPaid } from '../orders';

const lock = new Lock('orders');

Expand All @@ -20,7 +21,6 @@ async function maintainOrders() {

const bitcoinOrders = await collections.orders
.find({ 'payment.status': 'pending', 'payment.method': 'bitcoin' })
.project<Pick<Order, 'payment' | 'totalPrice' | '_id'>>({ payment: 1, totalPrice: 1 })
.toArray()
.catch((err) => {
console.error(inspect(err, { depth: 10 }));
Expand All @@ -38,20 +38,25 @@ async function maintainOrders() {
const satReceived = toSatoshis(received, 'BTC');

if (satReceived >= toSatoshis(order.totalPrice.amount, order.totalPrice.currency)) {
await collections.orders.updateOne(
{ _id: order._id },
{
$set: {
'payment.status': 'paid',
'payment.paidAt': new Date(),
'payment.transactions': transactions.map((transaction) => ({
txid: transaction.txid,
amount: transaction.amount
})),
'payment.totalReceived': received
}
}
);
await withTransaction(async (session) => {
await collections.orders.updateOne(
{ _id: order._id },
{
$set: {
'payment.status': 'paid',
'payment.paidAt': new Date(),
'payment.transactions': transactions.map((transaction) => ({
txid: transaction.txid,
amount: transaction.amount
})),
'payment.totalReceived': received
}
},
{ session }
);

await onOrderPaid(order, session);
});
} else if (order.payment.expiresAt < new Date()) {
await collections.orders.updateOne(
{ _id: order._id },
Expand Down Expand Up @@ -80,16 +85,23 @@ async function maintainOrders() {
const invoice = await lndLookupInvoice(order.payment.invoiceId);

if (invoice.state === 'SETTLED') {
await collections.orders.updateOne(
{ _id: order._id },
{
$set: {
'payment.status': 'paid',
'payment.paidAt': invoice.settled_at,
'payment.totalReceived': invoice.amt_paid_sat
}
await withTransaction(async (session) => {
const result = await collections.orders.findOneAndUpdate(
{ _id: order._id },
{
$set: {
'payment.status': 'paid',
'payment.paidAt': invoice.settled_at,
'payment.totalReceived': invoice.amt_paid_sat
}
},
{ returnDocument: 'after' }
);
if (!result.value) {
throw new Error('Failed to update order');
}
);
await onOrderPaid(result.value, session);
});
} else if (invoice.state === 'CANCELED') {
await collections.orders.updateOne(
{ _id: order._id },
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Order } from '$lib/types/Order';
import { ObjectId, type ChangeStreamDocument } from 'mongodb';
import { collections } from './database';
import { Lock } from './lock';
import { collections } from '../database';
import { Lock } from '../lock';
import { ORIGIN } from '$env/static/private';

const lock = new Lock('order-notifications');
Expand Down
Loading