Skip to content

Commit

Permalink
refactor(general): only create firestore user on successful stripe ch…
Browse files Browse the repository at this point in the history
…arge (#686)
  • Loading branch information
mkue authored Dec 22, 2023
1 parent e13b7dd commit c632511
Show file tree
Hide file tree
Showing 11 changed files with 119 additions and 151 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export function buildContributionsCollection(
icon: 'Paid',
path: CONTRIBUTION_FIRESTORE_PATH,
textSearchEnabled: false,
inlineEditing: false,
initialSort: ['created', 'desc'],
properties: buildProperties<Contribution>({
source: {
Expand Down Expand Up @@ -53,32 +54,54 @@ export function buildContributionsCollection(
},
validation: { required: true },
},
status: {
dataType: 'string',
name: 'Status',
validation: { required: true },
enumValues: [
{ id: StatusKey.SUCCEEDED, label: 'Succeeded' },
{ id: StatusKey.PENDING, label: 'Pending' },
{ id: StatusKey.FAILED, label: 'Failed' },
{ id: StatusKey.UNKNOWN, label: 'Unknown' },
],
defaultValue: StatusKey.SUCCEEDED,
},
amount_chf: {
dataType: 'number',
name: 'Amount Chf (without fees applied)',
name: 'Amount CHF (same as amount if currency is CHF)',
validation: { required: true },
},
fees_chf: {
dataType: 'number',
name: 'Fees Chf',
validation: { required: true },
},
reference_id: {
dataType: 'string',
name: 'External Reference',
Preview: (property) => {
return (
<>
{property.entity?.values.source === ContributionSourceKey.STRIPE ? (
<a
target="_blank"
rel="noopener noreferrer"
href={`https://dashboard.stripe.com/payments/${property.value}`}
>
{property.value}
</a>
) : (
property.value
)}
</>
);
},
},
monthly_interval: {
dataType: 'number',
name: 'Monthly recurrence interval',
},
status: {
dataType: 'string',
name: 'Status',
enumValues: [
{ id: StatusKey.SUCCEEDED, label: 'Succeeded' },
{ id: StatusKey.PENDING, label: 'Pending' },
{ id: StatusKey.FAILED, label: 'Failed' },
{ id: StatusKey.UNKNOWN, label: 'Unknown' },
],
defaultValue: StatusKey.SUCCEEDED,
validation: { required: true },
enumValues: { 0: 'One time', 1: 'Monthly', 3: 'Quarterly', 12: 'Annually' },
},
}),
...collectionProps,
Expand Down
99 changes: 17 additions & 82 deletions admin/src/collections/Users.tsx
Original file line number Diff line number Diff line change
@@ -1,59 +1,10 @@
import { USER_FIRESTORE_PATH, User, UserReferralSource } from '@socialincome/shared/src/types/user';
import { AdditionalFieldDelegate, buildProperties } from 'firecms';
import { buildProperties } from 'firecms';
import { CreateDonationCertificatesAction } from '../actions/CreateDonationCertificatesAction';
import { buildContributionsCollection } from './Contributions';
import { donationCertificateCollection } from './DonationCertificate';
import { buildAuditedCollection } from './shared';

const FirstNameCol: AdditionalFieldDelegate<User> = {
id: 'first_name_col',
name: 'First Name',
Builder: ({ entity }) => <>{entity.values?.personal?.name}</>,
dependencies: ['personal'],
};

const LastNameCol: AdditionalFieldDelegate<User> = {
id: 'last_name_col',
name: 'Last Name',
Builder: ({ entity }) => <>{entity.values?.personal?.lastname}</>,
dependencies: ['personal'],
};

const GenderCol: AdditionalFieldDelegate<User> = {
id: 'gender_col',
name: 'Gender',
Builder: ({ entity }) => <>{entity.values?.personal?.gender}</>,
dependencies: ['personal'],
};

const PhoneCol: AdditionalFieldDelegate<User> = {
id: 'phone_col',
name: 'Phone',
Builder: ({ entity }) => <>{entity.values?.personal?.phone}</>,
dependencies: ['personal'],
};

const CountryCol: AdditionalFieldDelegate<User> = {
id: 'country_col',
name: 'Country',
Builder: ({ entity }) => <>{entity.values?.address?.country}</>,
dependencies: ['address'],
};

const CityCol: AdditionalFieldDelegate<User> = {
id: 'city_col',
name: 'City',
Builder: ({ entity }) => <>{entity.values?.address?.city}</>,
dependencies: ['address'],
};

const ReferralCol: AdditionalFieldDelegate<User> = {
id: 'referral_col',
name: 'Referral',
Builder: ({ entity }) => <>{entity.values?.personal?.referral}</>,
dependencies: ['personal'],
};

export const usersCollection = buildAuditedCollection<User>({
path: USER_FIRESTORE_PATH,
group: 'Contributors',
Expand All @@ -62,40 +13,32 @@ export const usersCollection = buildAuditedCollection<User>({
singularName: 'Contributor',
description: 'Lists all contributors',
textSearchEnabled: true,
permissions: () => ({
edit: true,
create: true,
delete: false,
}),
additionalFields: [FirstNameCol, LastNameCol, GenderCol, PhoneCol, CountryCol, CityCol, ReferralCol],
permissions: () => ({ edit: true, create: true, delete: false }),
subcollections: [buildContributionsCollection(), donationCertificateCollection],
Actions: CreateDonationCertificatesAction,
properties: buildProperties<User>({
test_user: {
name: 'Test User',
institution: {
name: 'Institutional',
dataType: 'boolean',
},
email: {
name: 'Email',
validation: { required: true },
dataType: 'string',
},
auth_user_id: {
name: 'Auth User Id',
dataType: 'string',
readOnly: true,
},
personal: {
name: 'Personal Info',
dataType: 'map',
properties: {
name: {
name: 'Name',
dataType: 'string',
validation: { required: true },
},
lastname: {
name: 'Last Name',
dataType: 'string',
validation: { required: true },
},
gender: {
name: 'Gender',
Expand Down Expand Up @@ -136,6 +79,7 @@ export const usersCollection = buildAuditedCollection<User>({
country: {
name: 'Country',
dataType: 'string',
validation: { required: true },
},
city: {
name: 'City',
Expand All @@ -155,24 +99,10 @@ export const usersCollection = buildAuditedCollection<User>({
},
},
},
institution: {
name: 'Institutional',
dataType: 'boolean',
},
language: {
name: 'Language',
dataType: 'string',
},
location: {
name: 'Location',
description: 'Living location defined by List of ISO 3166 country codes',
dataType: 'string',
validation: {
required: true,
length: 2,
uppercase: true,
},
},
currency: {
name: 'Currency',
dataType: 'string',
Expand All @@ -183,15 +113,20 @@ export const usersCollection = buildAuditedCollection<User>({
},
validation: { required: true },
},
status: {
name: 'Status',
dataType: 'number',
disabled: true,
auth_user_id: {
name: 'Auth User Id',
dataType: 'string',
readOnly: true,
},
stripe_customer_id: {
name: 'stripe customer id',
name: 'Stripe Customer',
dataType: 'string',
readOnly: true,
Preview: (property) => (
<a target="_blank" rel="noopener noreferrer" href={`https://dashboard.stripe.com/customers/${property.value}`}>
{property.value}
</a>
),
},
payment_reference_id: {
name: 'Swiss QR-bill payment reference id',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,19 +39,19 @@ export class PostfinancePaymentsFileImporter {

for (let node of nodes) {
const contribution: BankWireContribution = {
referenceId: parseFloat(select('string(//ns:Refs/ns:AcctSvcrRef)', node) as string),
reference_id: parseFloat(select('string(//ns:Refs/ns:AcctSvcrRef)', node) as string),
currency: (select('string(//ns:Amt/@Ccy)', node) as string).toUpperCase(),
amount: parseFloat(select('string(//ns:Amt)', node) as string),
amount_chf: parseFloat(select('string(//ns:Amt)', node) as string),
fees_chf: 0,
status: StatusKey.SUCCEEDED,
created: toFirebaseAdminTimestamp(DateTime.now()),
source: ContributionSourceKey.WIRE_TRANSFER,
rawContent: node.toString(),
raw_content: node.toString(),
};

const user = await this.firestoreAdmin.findFirst<User>(USER_FIRESTORE_PATH, (q) =>
q.where('paymentReferenceId', '==', contribution.referenceId),
q.where('paymentReferenceId', '==', contribution.reference_id),
);

if (user) {
Expand Down
41 changes: 26 additions & 15 deletions shared/src/stripe/StripeEventHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import {
StripeContribution,
} from '../types/contribution';
import { CountryCode } from '../types/country';
import { USER_FIRESTORE_PATH, User, UserStatusKey, splitName } from '../types/user';
import { Currency, bestGuessCurrency } from '../types/currency';
import { USER_FIRESTORE_PATH, User, splitName } from '../types/user';

export class StripeEventHandler {
readonly stripe: Stripe;
Expand All @@ -31,14 +32,21 @@ export class StripeEventHandler {
const fullCharge = await this.stripe.charges.retrieve(chargeId, {
expand: ['balance_transaction', 'invoice'],
});
await this.storeCharge(fullCharge);
// We only store non-successful charges if the user already exists.
// This prevents us from having users in the database that never made a successful contribution.
if (
fullCharge.status === 'succeeded' ||
(await this.findFirestoreUser(await this.retrieveStripeCustomer(fullCharge.customer as string)))
) {
await this.storeCharge(fullCharge);
}
};

updateUser = async (checkoutSessionId: string, userData: Partial<User>) => {
const checkoutSession = await this.stripe.checkout.sessions.retrieve(checkoutSessionId);
const customer = await this.stripe.customers.retrieve(checkoutSession.customer as string);
if (customer.deleted) throw Error(`Dealing with a deleted Stripe customer (id=${customer.id})`);
const userRef = await this.getOrCreateUser(customer);
const userRef = await this.getOrCreateFirestoreUser(customer);
const user = await userRef.get();
await this.firestoreAdmin.doc<User>(USER_FIRESTORE_PATH, user.id).update(userData);
};
Expand All @@ -53,8 +61,8 @@ export class StripeEventHandler {
/**
* Try to find an existing user using create a new on.
*/
getOrCreateUser = async (customer: Stripe.Customer): Promise<DocumentReference<User>> => {
const userDoc = await this.findUser(customer);
getOrCreateFirestoreUser = async (customer: Stripe.Customer): Promise<DocumentReference<User>> => {
const userDoc = await this.findFirestoreUser(customer);
if (!userDoc) {
console.info(`User not found for stripe customer: ${customer.id}`);
const userToCreate = this.constructUser(customer);
Expand All @@ -68,16 +76,22 @@ export class StripeEventHandler {
};

/**
* First tries to match using the stripe_customer_id otherwise falls back to email.
* First, tries to match using the stripe_customer_id otherwise falls back to email.
*/
findUser = async (customer: Stripe.Customer) => {
findFirestoreUser = async (customer: Stripe.Customer) => {
return (
(await this.firestoreAdmin.findFirst<User>('users', (col) =>
col.where('stripe_customer_id', '==', customer.id),
)) ?? (await this.firestoreAdmin.findFirst<User>('users', (col) => col.where('email', '==', customer.email)))
);
};

retrieveStripeCustomer = async (customerId: string) => {
const customer = await this.stripe.customers.retrieve(customerId);
if (customer.deleted) throw Error(`Dealing with a deleted Stripe customer (id=${customer.id})`);
return customer;
};

/**
* Transforms the stripe charge into our own Contribution representation
*/
Expand All @@ -89,7 +103,7 @@ export class StripeEventHandler {
source: ContributionSourceKey.STRIPE,
created: toFirebaseAdminTimestamp(DateTime.fromSeconds(charge.created)),
amount: charge.amount / 100,
currency: charge.currency,
currency: charge.currency.toUpperCase() as Currency,
amount_chf: balanceTransaction?.amount ? balanceTransaction.amount / 100 : 0,
fees_chf: balanceTransaction?.fee ? balanceTransaction.fee / 100 : 0,
monthly_interval: monthlyInterval,
Expand Down Expand Up @@ -129,22 +143,19 @@ export class StripeEventHandler {
country: customer.address?.country as CountryCode,
},
email: customer.email,
status: UserStatusKey.INITIALIZED,
stripe_customer_id: customer.id,
payment_reference_id: DateTime.now().toMillis(),
currency: bestGuessCurrency(customer.address?.country as CountryCode),
test_user: false,
location: customer.address?.country?.toLowerCase(),
currency: customer.currency,
};
};

/**
* Converts the stripe charge to a contribution and stores it in the contributions subcollection of the corresponding user.
* Converts the stripe charge to a contribution and stores it in the 'contributions' subcollection of the corresponding user.
*/
storeCharge = async (charge: Stripe.Charge): Promise<DocumentReference<StripeContribution>> => {
const customer = await this.stripe.customers.retrieve(charge.customer as string);
if (customer.deleted) throw Error(`Dealing with a deleted Stripe customer (id=${customer.id})`);
const userRef = await this.getOrCreateUser(customer);
const customer = await this.retrieveStripeCustomer(charge.customer as string);
const userRef = await this.getOrCreateFirestoreUser(customer);
const contribution = this.constructContribution(charge);
const contributionRef = (
userRef.collection(CONTRIBUTION_FIRESTORE_PATH) as CollectionReference<StripeContribution>
Expand Down
Loading

0 comments on commit c632511

Please sign in to comment.